diff --git a/.ai-team/agents/bishop/history.md b/.ai-team/agents/bishop/history.md index cf9b7aa3a..ff0e02e2b 100644 --- a/.ai-team/agents/bishop/history.md +++ b/.ai-team/agents/bishop/history.md @@ -105,3 +105,112 @@ Added `Convert-PageLifecycleMethods` (GAP-05) and `Convert-EventHandlerSignature - GAP-05: Page_Load OnInitializedAsync, Page_Init OnInitialized, Page_PreRender OnAfterRenderAsync(bool firstRender) - GAP-07: Standard EventArgs handlers strip both params; specialized *EventArgs handlers keep the EventArgs param, strip sender - Updated 6 expected test files. All 21 L1 tests pass at 100% line accuracy. + +## Phase 3: Code-Behind C# Transforms — TC13-TC21 (2026-03-30) + +Ported all 10 code-behind transforms from PowerShell regex patterns to C# classes implementing `ICodeBehindTransform`. + +### Transforms Built (in pipeline order) +| Order | Class | Coverage | +|-------|-------|----------| +| 10 | `TodoHeaderTransform` | Injects migration guidance header | +| 100 | `UsingStripTransform` | Strips System.Web.*, Microsoft.AspNet.*, Microsoft.Owin.*, Owin usings | +| 200 | `BaseClassStripTransform` | Removes `: System.Web.UI.Page` etc. from partial classes | +| 300 | `ResponseRedirectTransform` | `Response.Redirect()` → `NavigationManager.NavigateTo()` + [Inject] injection | +| 400 | `SessionDetectTransform` | Detects `Session["key"]`, generates migration guidance block | +| 410 | `ViewStateDetectTransform` | Detects `ViewState["key"]`, generates field replacement suggestions | +| 500 | `IsPostBackTransform` | Unwraps simple `if (!IsPostBack)` guards (brace-counting), TODO for else clauses | +| 600 | `PageLifecycleTransform` | Page_Load→OnInitializedAsync, Page_Init→OnInitialized, Page_PreRender→OnAfterRenderAsync | +| 700 | `EventHandlerSignatureTransform` | Strips (object sender, EventArgs e); keeps specialized EventArgs | +| 800 | `DataBindTransform` | Cross-file DataSource/DataBind handling + InjectItemsAttributes for markup | +| 900 | `UrlCleanupTransform` | .aspx URL literals → clean routes | + +### Infrastructure Changes +- Added `TransformCodeBehind()` public method to `MigrationPipeline` for test access +- Registered all 11 code-behind transforms in `Program.cs` DI container +- Activated real pipeline in `TestHelpers.CreateDefaultPipeline()` (replaced TODO stubs) +- Activated real assertions in `L1TransformTests` for both markup and code-behind tests +- Fixed TC20/TC21 expected markup files: `OnClick="@Handler"` matches EventWiringTransform output + +### Key Learnings +- **IDE0007 enforcement:** Project .editorconfig treats `var` preference as error. Always use `var` over explicit types. +- **Transform ordering matters:** ResponseRedirect strips `~/` but preserves `.aspx`; UrlCleanup then handles `.aspx` patterns on `"~/..."` and relative NavigateTo forms. URLs like `/Products.aspx` survive because URL cleanup patterns don't match leading `/`. +- **TodoHeader as standalone transform (Order 10):** Splitting the TODO header into its own transform class keeps Session/ViewState detect transforms cleaner — they find the marker and insert after it. +- **Test discovery was previously placeholder:** The old tests only verified input ≠ expected. Wiring real pipeline exposed TC20/TC21 markup mismatches from EventWiringTransform `@` prefix. +- **All 72 tests pass:** 21 markup + 8 code-behind + 4 infrastructure + 39 unit tests. + +### Phase 4: Scaffolding, Config Transforms, and Full Pipeline Wiring (Bishop) + +Ported scaffolding, config transforms, and OutputWriter from bwfc-migrate.ps1 to C#. Wired the full `migrate` command pipeline. + +#### New Files (9 total) +| File | Purpose | +|------|---------| +| `Config/DatabaseProviderDetector.cs` | 3-pass DB provider detection from Web.config (providerName → conn string pattern → EntityClient inner) | +| `Config/WebConfigTransformer.cs` | Parses Web.config appSettings + connectionStrings → appsettings.json (XDocument/LINQ to XML) | +| `Io/OutputWriter.cs` | Centralized file writer: dry-run support, UTF-8 no BOM, directory creation, file tracking | +| `Scaffolding/ProjectScaffolder.cs` | Generates .csproj, Program.cs, _Imports.razor, App.razor, Routes.razor, launchSettings.json | +| `Scaffolding/GlobalUsingsGenerator.cs` | Generates GlobalUsings.cs with Blazor infrastructure + conditional Identity usings | +| `Scaffolding/ShimGenerator.cs` | Generates WebFormsShims.cs + conditional IdentityShims.cs | + +#### Pipeline Changes +- `MigrationPipeline.ExecuteAsync()` now runs: scaffold → config → per-file transforms → report +- `MigrationReport` enhanced: JSON serialization (`--report`), console summary, manual items tracking +- `Program.cs` DI wires all new services: ProjectScaffolder, GlobalUsingsGenerator, ShimGenerator, WebConfigTransformer, DatabaseProviderDetector, OutputWriter +- 2-param constructor preserved on `MigrationPipeline` for backward-compatible test usage + +#### Key Patterns +- **ProjectScaffolder detects HasModels/HasIdentity** from source directory structure (Models/, Account/, Login.aspx, Register.aspx) — adjusts .csproj packages and Program.cs boilerplate accordingly +- **WebConfigTransformer skips built-in connection names** (LocalSqlServer, LocalMySqlServer) — matches PS behavior +- **DatabaseProviderDetector maps 4 providers:** SqlClient→SqlServer, SQLite→Sqlite, Npgsql→PostgreSQL, MySql→MySql +- **OutputWriter respects dry-run** — logs what would be written without touching disk +- **All templates are string literals** — no external template files, matching PS approach +- **Build clean:** 0 errors for both CLI and test projects + +### Phase 5: ClientScriptTransform — Phase 1 of PRD ClientScript Migration (Bishop) + +Added `ClientScriptTransform.cs` (Order 850) to the code-behind pipeline. Handles 6 ClientScript/ScriptManager patterns: + +#### Automatable (transforms to IJSRuntime skeleton): +| Pattern | Action | +|---------|--------| +| `RegisterStartupScript()` with inline script | → `await JS.InvokeVoidAsync("eval", ...)` + TODO to refactor eval | +| `RegisterClientScriptInclude()` with URL | → `// TODO: Add "`) +- **Safe pattern:** `(?:"[^"]*"|[^"])*?` — alternates quoted strings and non-quote chars, handles both issues + +### ClientScriptTransform: Switched to Shim-Preserving Mode (Bishop) + +- **Date:** 2026-07-31 +- **What changed:** `ClientScriptTransform.cs` (Order 850) no longer rewrites ClientScript calls to IJSRuntime skeletons. Instead, it preserves calls for use with `ClientScriptShim`. +- **Shim-compatible patterns (prefix stripping, calls preserved):** + - `Page.ClientScript.RegisterStartupScript(...)` → `ClientScript.RegisterStartupScript(...)` (strip prefix) + - `Page.ClientScript.RegisterClientScriptInclude(...)` → `ClientScript.RegisterClientScriptInclude(...)` (strip prefix) + - `Page.ClientScript.RegisterClientScriptBlock(...)` → `ClientScript.RegisterClientScriptBlock(...)` (strip prefix, shim now supports this) + - `ScriptManager.RegisterStartupScript(control, type, key, script, bool)` → `ClientScript.RegisterStartupScript(type, key, script, bool)` (drops first param) +- **Still TODO-marked (no shim support):** + - `GetPostBackEventReference(...)` → TODO with @onclick/EventCallback guidance (shim throws NotSupportedException) + - `ScriptManager.GetCurrent(...)` → TODO with IJSRuntime guidance (no shim equivalent) +- **Removed:** IJSRuntime `[Inject]` injection logic. Replaced with single-line `ClientScriptShim` dependency comment at class level. +- **Key principle:** Jeff's directive — "Zero-rewrite shim approach is PRECISELY what we should be building." CLI preserves Web Forms API calls instead of rewriting them. +- **Tests:** All 349 tests pass (same count as before), updated 20 unit test assertions + TC33 expected output file. +- **Regex approach:** Single `PageOrThisPrefixRegex` with lookahead handles all three shim-compatible methods in one pass. Much simpler than the old per-pattern regexes with inline script extraction. + diff --git a/.ai-team/agents/forge/history.md b/.ai-team/agents/forge/history.md index d278c6ade..3487cef3d 100644 --- a/.ai-team/agents/forge/history.md +++ b/.ai-team/agents/forge/history.md @@ -218,3 +218,56 @@ Team updates (2026-03-04-05): PRs upstream, reports in docs/migration-tests/, be Team update (2026-03-06): WebFormsPageBase is the canonical base class for all migrated pages (not ComponentBase). All agents must use WebFormsPageBase decided by Jeffrey T. Fritz Team update (2026-03-06): LoginView is a native BWFC component do NOT convert to AuthorizeView. Strip asp: prefix only decided by Jeffrey T. Fritz + +### CLI Gap Analysis — webforms-to-blazor Tool (2026-07-25) + +**Task:** Comprehensive gap analysis of `src/BlazorWebFormsComponents.Cli` migration tool. + +**Current state (14 markup + 12 code-behind transforms):** +- Markup: AspPrefix, AjaxToolkit, Attributes, Content, DataSource, Events, Expressions, Form, LoginView, MasterPage, SelectMethod, Template, URL transforms +- Code-behind: BaseClass, DataBind, EventHandler, GetRouteUrl, IsPostBack, PageLifecycle, Response, Session, Todo, UrlCleanup, Using, ViewState transforms +- Scaffolding: .csproj, Program.cs, _Imports.razor, App.razor, Routes.razor, GlobalUsings.cs, WebFormsShims.cs + +**Critical gaps identified (6 — will break real migrations):** +1. **FindControl()** — no transform → compile error (every Web Forms app uses this) +2. **ClientScript / RegisterStartupScript** — no transform → runtime failure +3. **FormsAuthentication** — mentioned in shims but no actual transform +4. **Membership/Roles** — no transform → compile error +5. **VB.NET code-behind** — detected but C# regex patterns fail silently +6. **User controls (.ascx)** — prefix stripped but no cross-file correlation, no tests + +**High-value additions (8 — cover most real-world apps):** +1. ScriptManager code-behind patterns (GetCurrent, RegisterAsyncPostBackControl) +2. Validation group handling (Page.Validate, Page.IsValid) +3. GridView/ListView code-behind patterns (DataKeys, EditIndex) +4. Request object patterns (QueryString, Form, Cookies) +5. Server.MapPath conversion +6. Global.asax pattern extraction +7. Enum attribute conversions (TextMode, Display, GridLines → strong-typed) +8. Static file url(~/) transforms + +**Quality issues in existing transforms:** +1. **ItemType bug** — blindly converts all to TItem, but GridView/ListView/FormView use ItemType +2. **LoginViewTransform conflicts with BWFC component** — should be REMOVED per Jeff's directive +3. IsPostBack edge cases (nested, else-if) produce unparseable output +4. DataBindTransform.InjectItemsAttributes() may not be called from pipeline + +**Scaffold gaps:** +- Missing MainLayout.razor (Routes.razor references it) +- Missing `@using BlazorWebFormsComponents.Enums` in _Imports.razor +- Missing `AddHttpContextAccessor()` in Program.cs + +**Test coverage gaps (32 current → 45+ needed):** +- TC33: .ascx user control (P0) +- TC35: FindControl patterns (P0) +- TC36-38: ClientScript, Membership, FormsAuth (P1) +- TC47: VB.NET code-behind (P1) +- TC48: Enum string attributes (P1) + +**Priority fixes:** +- P0: Remove LoginViewTransform, fix ItemType/TItem logic, add .ascx test +- P1: EnumAttributeTransform, FindControlTransform, RequestAccessTransform, FormsAuthTransform, ScriptManagerCodeBehindTransform, scaffold fixes + +**Estimated coverage improvement:** 70% → 88% L1 mechanical coverage after P0+P1+P2 + +**Decision:** `.squad/decisions/inbox/forge-cli-gap-analysis.md` diff --git a/.ai-team/agents/rogue/history.md b/.ai-team/agents/rogue/history.md index 78abfe896..78ea1d558 100644 --- a/.ai-team/agents/rogue/history.md +++ b/.ai-team/agents/rogue/history.md @@ -145,3 +145,26 @@ Updated all 12 LoginStatus bUnit tests: replaced manual `Mock> $GITHUB_OUTPUT - else - echo "has_script=false" >> $GITHUB_OUTPUT - echo "⚠️ ralph-triage.js not found — run 'squad upgrade' to install" - fi - - - name: Ralph — Smart triage - if: steps.check-script.outputs.has_script == 'true' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - node .squad/templates/ralph-triage.js \ - --squad-dir .squad \ - --output triage-results.json - - - name: Ralph — Apply triage decisions - if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != '' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const path = 'triage-results.json'; - if (!fs.existsSync(path)) { - core.info('No triage results — board is clear'); - return; - } - - const results = JSON.parse(fs.readFileSync(path, 'utf8')); - if (results.length === 0) { - core.info('📋 Board is clear — Ralph found no untriaged issues'); - return; - } - - for (const decision of results) { - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: decision.issueNumber, - labels: [decision.label] - }); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: decision.issueNumber, - body: [ - '### 🔄 Ralph — Auto-Triage', - '', - `**Assigned to:** ${decision.assignTo}`, - `**Reason:** ${decision.reason}`, - `**Source:** ${decision.source}`, - '', - '> Ralph auto-triaged this issue using routing rules.', - '> To reassign, swap the `squad:*` label.' - ].join('\n') - }); - - core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`); - } catch (e) { - core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`); - } - } - - core.info(`🔄 Ralph triaged ${results.length} issue(s)`); - - # Copilot auto-assign step (uses PAT if available) - - name: Ralph — Assign @copilot issues - if: success() - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) return; - - const content = fs.readFileSync(teamFile, 'utf8'); - - // Check if @copilot is on the team with auto-assign - const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot'); - const autoAssign = content.includes(''); - if (!hasCopilot || !autoAssign) return; - - // Find issues labeled squad:copilot with no assignee - try { - const { data: copilotIssues } = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - labels: 'squad:copilot', - state: 'open', - per_page: 5 - }); - - const unassigned = copilotIssues.filter(i => - !i.assignees || i.assignees.length === 0 - ); - - if (unassigned.length === 0) { - core.info('No unassigned squad:copilot issues'); - return; - } - - // Get repo default branch - const { data: repoData } = await github.rest.repos.get({ - owner: context.repo.owner, - repo: context.repo.repo - }); - - for (const issue of unassigned) { - try { - await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - assignees: ['copilot-swe-agent[bot]'], - agent_assignment: { - target_repo: `${context.repo.owner}/${context.repo.repo}`, - base_branch: repoData.default_branch, - custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.` - } - }); - core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`); - } catch (e) { - core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`); - } - } - } catch (e) { - core.info(`No squad:copilot label found or error: ${e.message}`); - } diff --git a/.github/workflows/squad-insider-release.yml b/.github/workflows/squad-insider-release.yml deleted file mode 100644 index 63c6e325e..000000000 --- a/.github/workflows/squad-insider-release.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Squad Insider Release -# dotnet project — configure build, test, and insider release commands below - -on: - push: - branches: [insider] - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Build and test - run: | - # TODO: Add your dotnet build/test commands here - # Go: go test ./... - # Python: pip install -r requirements.txt && pytest - # .NET: dotnet test - # Java (Maven): mvn test - # Java (Gradle): ./gradlew test - echo "No build commands configured — update squad-insider-release.yml" - - - name: Create insider release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # TODO: Add your insider/pre-release commands here - echo "No release commands configured — update squad-insider-release.yml" diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml deleted file mode 100644 index ad140f42d..000000000 --- a/.github/workflows/squad-issue-assign.yml +++ /dev/null @@ -1,161 +0,0 @@ -name: Squad Issue Assign - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - assign-work: - # Only trigger on squad:{member} labels (not the base "squad" label) - if: startsWith(github.event.label.name, 'squad:') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Identify assigned member and trigger work - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const issue = context.payload.issue; - const label = context.payload.label.name; - - // Extract member name from label (e.g., "squad:ripley" → "ripley") - const memberName = label.replace('squad:', '').toLowerCase(); - - // Read team roster — check .squad/ first, fall back to .ai-team/ - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) { - core.warning('No .squad/team.md or .ai-team/team.md found — cannot assign work'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Check if this is a coding agent assignment - const isCopilotAssignment = memberName === 'copilot'; - - let assignedMember = null; - if (isCopilotAssignment) { - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - } else { - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) { - break; - } - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && cells[0].toLowerCase() === memberName) { - assignedMember = { name: cells[0], role: cells[1] }; - break; - } - } - } - } - - if (!assignedMember) { - core.warning(`No member found matching label "${label}"`); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `⚠️ No squad member found matching label \`${label}\`. Check \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.` - }); - return; - } - - // Post assignment acknowledgment - let comment; - if (isCopilotAssignment) { - comment = [ - `### 🤖 Routed to @copilot (Coding Agent)`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - '', - `@copilot has been assigned and will pick this up automatically.`, - '', - `> The coding agent will create a \`copilot/*\` branch and open a draft PR.`, - `> Review the PR as you would any team member's work.`, - ].join('\n'); - } else { - comment = [ - `### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - '', - `${assignedMember.name} will pick this up in the next Copilot session.`, - '', - `> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`, - `> Otherwise, start a Copilot session and say:`, - `> \`${assignedMember.name}, work on issue #${issue.number}\``, - ].join('\n'); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: comment - }); - - core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`); - - # Separate step: assign @copilot using PAT (required for coding agent) - - name: Assign @copilot coding agent - if: github.event.label.name == 'squad:copilot' - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }} - script: | - const owner = context.repo.owner; - const repo = context.repo.repo; - const issue_number = context.payload.issue.number; - - // Get the default branch name (main, master, etc.) - const { data: repoData } = await github.rest.repos.get({ owner, repo }); - const baseBranch = repoData.default_branch; - - try { - await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { - owner, - repo, - issue_number, - assignees: ['copilot-swe-agent[bot]'], - agent_assignment: { - target_repo: `${owner}/${repo}`, - base_branch: baseBranch, - custom_instructions: '', - custom_agent: '', - model: '' - }, - headers: { - 'X-GitHub-Api-Version': '2022-11-28' - } - }); - core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`); - } catch (err) { - core.warning(`Assignment with agent_assignment failed: ${err.message}`); - // Fallback: try without agent_assignment - try { - await github.rest.issues.addAssignees({ - owner, repo, issue_number, - assignees: ['copilot-swe-agent'] - }); - core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`); - } catch (err2) { - core.warning(`Fallback also failed: ${err2.message}`); - } - } diff --git a/.github/workflows/squad-label-enforce.yml b/.github/workflows/squad-label-enforce.yml deleted file mode 100644 index 633d220df..000000000 --- a/.github/workflows/squad-label-enforce.yml +++ /dev/null @@ -1,181 +0,0 @@ -name: Squad Label Enforce - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - enforce: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Enforce mutual exclusivity - uses: actions/github-script@v7 - with: - script: | - const issue = context.payload.issue; - const appliedLabel = context.payload.label.name; - - // Namespaces with mutual exclusivity rules - const EXCLUSIVE_PREFIXES = ['go:', 'release:', 'type:', 'priority:']; - - // Skip if not a managed namespace label - if (!EXCLUSIVE_PREFIXES.some(p => appliedLabel.startsWith(p))) { - core.info(`Label ${appliedLabel} is not in a managed namespace — skipping`); - return; - } - - const allLabels = issue.labels.map(l => l.name); - - // Handle go: namespace (mutual exclusivity) - if (appliedLabel.startsWith('go:')) { - const otherGoLabels = allLabels.filter(l => - l.startsWith('go:') && l !== appliedLabel - ); - - if (otherGoLabels.length > 0) { - // Remove conflicting go: labels - for (const label of otherGoLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - // Post update comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Triage verdict updated → \`${appliedLabel}\`` - }); - } - - // Auto-apply release:backlog if go:yes and no release target - if (appliedLabel === 'go:yes') { - const hasReleaseLabel = allLabels.some(l => l.startsWith('release:')); - if (!hasReleaseLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ['release:backlog'] - }); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `📋 Marked as \`release:backlog\` — assign a release target when ready.` - }); - - core.info('Applied release:backlog for go:yes issue'); - } - } - - // Remove release: labels if go:no - if (appliedLabel === 'go:no') { - const releaseLabels = allLabels.filter(l => l.startsWith('release:')); - if (releaseLabels.length > 0) { - for (const label of releaseLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed release label from go:no issue: ${label}`); - } - } - } - } - - // Handle release: namespace (mutual exclusivity) - if (appliedLabel.startsWith('release:')) { - const otherReleaseLabels = allLabels.filter(l => - l.startsWith('release:') && l !== appliedLabel - ); - - if (otherReleaseLabels.length > 0) { - // Remove conflicting release: labels - for (const label of otherReleaseLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - // Post update comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Release target updated → \`${appliedLabel}\`` - }); - } - } - - // Handle type: namespace (mutual exclusivity) - if (appliedLabel.startsWith('type:')) { - const otherTypeLabels = allLabels.filter(l => - l.startsWith('type:') && l !== appliedLabel - ); - - if (otherTypeLabels.length > 0) { - for (const label of otherTypeLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Issue type updated → \`${appliedLabel}\`` - }); - } - } - - // Handle priority: namespace (mutual exclusivity) - if (appliedLabel.startsWith('priority:')) { - const otherPriorityLabels = allLabels.filter(l => - l.startsWith('priority:') && l !== appliedLabel - ); - - if (otherPriorityLabels.length > 0) { - for (const label of otherPriorityLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Priority updated → \`${appliedLabel}\`` - }); - } - } - - core.info(`Label enforcement complete for ${appliedLabel}`); diff --git a/.github/workflows/squad-preview.yml b/.github/workflows/squad-preview.yml deleted file mode 100644 index 44ccdc764..000000000 --- a/.github/workflows/squad-preview.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Squad Preview Validation -# dotnet project — configure build, test, and validation commands below - -on: - push: - branches: [preview] - -permissions: - contents: read - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Build and test - run: | - # TODO: Add your dotnet build/test commands here - # Go: go test ./... - # Python: pip install -r requirements.txt && pytest - # .NET: dotnet test - # Java (Maven): mvn test - # Java (Gradle): ./gradlew test - echo "No build commands configured — update squad-preview.yml" - - - name: Validate - run: | - # TODO: Add pre-release validation commands here - echo "No validation commands configured — update squad-preview.yml" diff --git a/.github/workflows/squad-promote.yml b/.github/workflows/squad-promote.yml deleted file mode 100644 index 9d315b1d1..000000000 --- a/.github/workflows/squad-promote.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Squad Promote - -on: - workflow_dispatch: - inputs: - dry_run: - description: 'Dry run — show what would happen without pushing' - required: false - default: 'false' - type: choice - options: ['false', 'true'] - -permissions: - contents: write - -jobs: - dev-to-preview: - name: Promote dev → preview - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Fetch all branches - run: git fetch --all - - - name: Show current state (dry run info) - run: | - echo "=== dev HEAD ===" && git log origin/dev -1 --oneline - echo "=== preview HEAD ===" && git log origin/preview -1 --oneline - echo "=== Files that would be stripped ===" - git diff origin/preview..origin/dev --name-only | grep -E "^(\.(ai-team|squad|ai-team-templates)|team-docs/|docs/proposals/)" || echo "(none)" - - - name: Merge dev → preview (strip forbidden paths) - if: ${{ inputs.dry_run == 'false' }} - run: | - git checkout preview - git merge origin/dev --no-commit --no-ff -X theirs || true - - # Strip forbidden paths from merge commit - git rm -rf --cached --ignore-unmatch \ - .ai-team/ \ - .squad/ \ - .ai-team-templates/ \ - team-docs/ \ - "docs/proposals/" || true - - # Commit if there are staged changes - if ! git diff --cached --quiet; then - git commit -m "chore: promote dev → preview (v$(node -e "console.log(require('./package.json').version)"))" - git push origin preview - echo "✅ Pushed preview branch" - else - echo "ℹ️ Nothing to commit — preview is already up to date" - fi - - - name: Dry run complete - if: ${{ inputs.dry_run == 'true' }} - run: echo "🔍 Dry run complete — no changes pushed." - - preview-to-main: - name: Promote preview → main (release) - needs: dev-to-preview - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Fetch all branches - run: git fetch --all - - - name: Show current state - run: | - echo "=== preview HEAD ===" && git log origin/preview -1 --oneline - echo "=== main HEAD ===" && git log origin/main -1 --oneline - echo "=== Version ===" && node -e "console.log('v' + require('./package.json').version)" - - - name: Validate preview is release-ready - run: | - git checkout preview - VERSION=$(node -e "console.log(require('./package.json').version)") - if ! grep -q "## \[$VERSION\]" CHANGELOG.md 2>/dev/null; then - echo "::error::Version $VERSION not found in CHANGELOG.md — update before releasing" - exit 1 - fi - echo "✅ Version $VERSION has CHANGELOG entry" - - # Verify no forbidden files on preview - FORBIDDEN=$(git ls-files | grep -E "^(\.(ai-team|squad|ai-team-templates)/|team-docs/|docs/proposals/)" || true) - if [ -n "$FORBIDDEN" ]; then - echo "::error::Forbidden files found on preview: $FORBIDDEN" - exit 1 - fi - echo "✅ No forbidden files on preview" - - - name: Merge preview → main - if: ${{ inputs.dry_run == 'false' }} - run: | - git checkout main - git merge origin/preview --no-ff -m "chore: promote preview → main (v$(node -e "console.log(require('./package.json').version)"))" - git push origin main - echo "✅ Pushed main — squad-release.yml will tag and publish the release" - - - name: Dry run complete - if: ${{ inputs.dry_run == 'true' }} - run: echo "🔍 Dry run complete — no changes pushed." diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml deleted file mode 100644 index b5f2c62af..000000000 --- a/.github/workflows/squad-release.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Squad Release -# dotnet project — configure build, test, and release commands below - -on: - push: - branches: [main] - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Build and test - run: | - # TODO: Add your dotnet build/test commands here - # Go: go test ./... - # Python: pip install -r requirements.txt && pytest - # .NET: dotnet test - # Java (Maven): mvn test - # Java (Gradle): ./gradlew test - echo "No build commands configured — update squad-release.yml" - - - name: Create release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # TODO: Add your release commands here (e.g., git tag, gh release create) - echo "No release commands configured — update squad-release.yml" diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml deleted file mode 100644 index a58be9b29..000000000 --- a/.github/workflows/squad-triage.yml +++ /dev/null @@ -1,260 +0,0 @@ -name: Squad Triage - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - triage: - if: github.event.label.name == 'squad' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Triage issue via Lead agent - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const issue = context.payload.issue; - - // Read team roster — check .squad/ first, fall back to .ai-team/ - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) { - core.warning('No .squad/team.md or .ai-team/team.md found — cannot triage'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Check if @copilot is on the team - const hasCopilot = content.includes('🤖 Coding Agent'); - const copilotAutoAssign = content.includes(''); - - // Parse @copilot capability profile - let goodFitKeywords = []; - let needsReviewKeywords = []; - let notSuitableKeywords = []; - - if (hasCopilot) { - // Extract capability tiers from team.md - const goodFitMatch = content.match(/🟢\s*Good fit[^:]*:\s*(.+)/i); - const needsReviewMatch = content.match(/🟡\s*Needs review[^:]*:\s*(.+)/i); - const notSuitableMatch = content.match(/🔴\s*Not suitable[^:]*:\s*(.+)/i); - - if (goodFitMatch) { - goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation']; - } - if (needsReviewMatch) { - needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration']; - } - if (notSuitableMatch) { - notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance']; - } - } - - const members = []; - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) { - break; - } - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && cells[0] !== 'Scribe') { - members.push({ - name: cells[0], - role: cells[1] - }); - } - } - } - - // Read routing rules — check .squad/ first, fall back to .ai-team/ - let routingFile = '.squad/routing.md'; - if (!fs.existsSync(routingFile)) { - routingFile = '.ai-team/routing.md'; - } - let routingContent = ''; - if (fs.existsSync(routingFile)) { - routingContent = fs.readFileSync(routingFile, 'utf8'); - } - - // Find the Lead - const lead = members.find(m => - m.role.toLowerCase().includes('lead') || - m.role.toLowerCase().includes('architect') || - m.role.toLowerCase().includes('coordinator') - ); - - if (!lead) { - core.warning('No Lead role found in team roster — cannot triage'); - return; - } - - // Build triage context - const memberList = members.map(m => - `- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\`` - ).join('\n'); - - // Determine best assignee based on issue content and routing - const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase(); - - let assignedMember = null; - let triageReason = ''; - let copilotTier = null; - - // First, evaluate @copilot fit if enabled - if (hasCopilot) { - const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw)); - const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw)); - const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw)); - - if (isGoodFit) { - copilotTier = 'good-fit'; - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - triageReason = '🟢 Good fit for @copilot — matches capability profile'; - } else if (isNeedsReview) { - copilotTier = 'needs-review'; - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - triageReason = '🟡 Routing to @copilot (needs review) — a squad member should review the PR'; - } else if (isNotSuitable) { - copilotTier = 'not-suitable'; - // Fall through to normal routing - } - } - - // If not routed to @copilot, use keyword-based routing - if (!assignedMember) { - for (const member of members) { - const role = member.role.toLowerCase(); - if ((role.includes('frontend') || role.includes('ui')) && - (issueText.includes('ui') || issueText.includes('frontend') || - issueText.includes('css') || issueText.includes('component') || - issueText.includes('button') || issueText.includes('page') || - issueText.includes('layout') || issueText.includes('design'))) { - assignedMember = member; - triageReason = 'Issue relates to frontend/UI work'; - break; - } - if ((role.includes('backend') || role.includes('api') || role.includes('server')) && - (issueText.includes('api') || issueText.includes('backend') || - issueText.includes('database') || issueText.includes('endpoint') || - issueText.includes('server') || issueText.includes('auth'))) { - assignedMember = member; - triageReason = 'Issue relates to backend/API work'; - break; - } - if ((role.includes('test') || role.includes('qa') || role.includes('quality')) && - (issueText.includes('test') || issueText.includes('bug') || - issueText.includes('fix') || issueText.includes('regression') || - issueText.includes('coverage'))) { - assignedMember = member; - triageReason = 'Issue relates to testing/quality work'; - break; - } - if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) && - (issueText.includes('deploy') || issueText.includes('ci') || - issueText.includes('pipeline') || issueText.includes('docker') || - issueText.includes('infrastructure'))) { - assignedMember = member; - triageReason = 'Issue relates to DevOps/infrastructure work'; - break; - } - } - } - - // Default to Lead if no routing match - if (!assignedMember) { - assignedMember = lead; - triageReason = 'No specific domain match — assigned to Lead for further analysis'; - } - - const isCopilot = assignedMember.name === '@copilot'; - const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`; - - // Add the member-specific label - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: [assignLabel] - }); - - // Apply default triage verdict - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ['go:needs-research'] - }); - - // Auto-assign @copilot if enabled - if (isCopilot && copilotAutoAssign) { - try { - await github.rest.issues.addAssignees({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - assignees: ['copilot'] - }); - } catch (err) { - core.warning(`Could not auto-assign @copilot: ${err.message}`); - } - } - - // Build copilot evaluation note - let copilotNote = ''; - if (hasCopilot && !isCopilot) { - if (copilotTier === 'not-suitable') { - copilotNote = `\n\n**@copilot evaluation:** 🔴 Not suitable — issue involves work outside the coding agent's capability profile.`; - } else { - copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`; - } - } - - // Post triage comment - const comment = [ - `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, - `**Reason:** ${triageReason}`, - copilotTier === 'needs-review' ? `\n⚠️ **PR review recommended** — a squad member should review @copilot's work on this one.` : '', - copilotNote, - '', - `---`, - '', - `**Team roster:**`, - memberList, - hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '', - '', - `> To reassign, remove the current \`squad:*\` label and add the correct one.`, - ].filter(Boolean).join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: comment - }); - - core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`); diff --git a/.github/workflows/sync-squad-labels.yml b/.github/workflows/sync-squad-labels.yml deleted file mode 100644 index fbcfd9cc2..000000000 --- a/.github/workflows/sync-squad-labels.yml +++ /dev/null @@ -1,169 +0,0 @@ -name: Sync Squad Labels - -on: - push: - paths: - - '.squad/team.md' - - '.ai-team/team.md' - workflow_dispatch: - -permissions: - issues: write - contents: read - -jobs: - sync-labels: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Parse roster and sync labels - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - - if (!fs.existsSync(teamFile)) { - core.info('No .squad/team.md or .ai-team/team.md found — skipping label sync'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Parse the Members table for agent names - const members = []; - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) { - break; - } - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && cells[0] !== 'Scribe') { - members.push({ - name: cells[0], - role: cells[1] - }); - } - } - } - - core.info(`Found ${members.length} squad members: ${members.map(m => m.name).join(', ')}`); - - // Check if @copilot is on the team - const hasCopilot = content.includes('🤖 Coding Agent'); - - // Define label color palette for squad labels - const SQUAD_COLOR = '9B8FCC'; - const MEMBER_COLOR = '9B8FCC'; - const COPILOT_COLOR = '10b981'; - - // Define go: and release: labels (static) - const GO_LABELS = [ - { name: 'go:yes', color: '0E8A16', description: 'Ready to implement' }, - { name: 'go:no', color: 'B60205', description: 'Not pursuing' }, - { name: 'go:needs-research', color: 'FBCA04', description: 'Needs investigation' } - ]; - - const RELEASE_LABELS = [ - { name: 'release:v0.4.0', color: '6B8EB5', description: 'Targeted for v0.4.0' }, - { name: 'release:v0.5.0', color: '6B8EB5', description: 'Targeted for v0.5.0' }, - { name: 'release:v0.6.0', color: '8B7DB5', description: 'Targeted for v0.6.0' }, - { name: 'release:v1.0.0', color: '8B7DB5', description: 'Targeted for v1.0.0' }, - { name: 'release:backlog', color: 'D4E5F7', description: 'Not yet targeted' } - ]; - - const TYPE_LABELS = [ - { name: 'type:feature', color: 'DDD1F2', description: 'New capability' }, - { name: 'type:bug', color: 'FF0422', description: 'Something broken' }, - { name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' }, - { name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' }, - { name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' }, - { name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' } - ]; - - // High-signal labels — these MUST visually dominate all others - const SIGNAL_LABELS = [ - { name: 'bug', color: 'FF0422', description: 'Something isn\'t working' }, - { name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' } - ]; - - const PRIORITY_LABELS = [ - { name: 'priority:p0', color: 'B60205', description: 'Blocking release' }, - { name: 'priority:p1', color: 'D93F0B', description: 'This sprint' }, - { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' } - ]; - - // Ensure the base "squad" triage label exists - const labels = [ - { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' } - ]; - - for (const member of members) { - labels.push({ - name: `squad:${member.name.toLowerCase()}`, - color: MEMBER_COLOR, - description: `Assigned to ${member.name} (${member.role})` - }); - } - - // Add @copilot label if coding agent is on the team - if (hasCopilot) { - labels.push({ - name: 'squad:copilot', - color: COPILOT_COLOR, - description: 'Assigned to @copilot (Coding Agent) for autonomous work' - }); - } - - // Add go:, release:, type:, priority:, and high-signal labels - labels.push(...GO_LABELS); - labels.push(...RELEASE_LABELS); - labels.push(...TYPE_LABELS); - labels.push(...PRIORITY_LABELS); - labels.push(...SIGNAL_LABELS); - - // Sync labels (create or update) - for (const label of labels) { - try { - await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name - }); - // Label exists — update it - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description - }); - core.info(`Updated label: ${label.name}`); - } catch (err) { - if (err.status === 404) { - // Label doesn't exist — create it - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description - }); - core.info(`Created label: ${label.name}`); - } else { - throw err; - } - } - } - - core.info(`Label sync complete: ${labels.length} labels synced`); diff --git a/.squad/agents/beast/history.md b/.squad/agents/beast/history.md index 30acb5376..1bab136bf 100644 --- a/.squad/agents/beast/history.md +++ b/.squad/agents/beast/history.md @@ -5,6 +5,12 @@ - **Stack:** C#, Blazor, .NET, ASP.NET Web Forms, bUnit, xUnit, MkDocs, Playwright - **Created:** 2026-02-10 + +📌 Team update (2026-08-XX): ClientScriptShim documentation delivery — Updated ClientScriptMigrationGuide.md with prominent new section positioning ClientScriptShim as zero-rewrite path (+2,100 lines total), including supported methods table, before/after example, "How It Works" technical explanation, and migration approach comparison (ClientScriptShim vs. manual IJSRuntime vs. JS modules). Updated BWFC022.md, BWFC023.md, BWFC024.md analyzer docs with cross-references to new ClientScriptShim guidance. mkdocs.yml verified (guide already in nav). Strategy: Lead with easiest path first (ClientScriptShim), then modern alternatives for teams ready to modernize. Enables rapid migration for large Web Forms codebases. — decided by Beast + +📌 Team update (2026-07-30): ClientScript Migration Support PRD delivered 9-section product requirements document (dev-docs/prd-clientscript-migration-support.md, 38K) covering analyzer improvements (BWFC022/023/024), CLI transforms (startup scripts, includes), safe automation boundaries, TODO guidance, documentation (ClientScriptMigrationGuide.md), testing (8 test cases), and 3-phase roadmap (P1: analyzers + transforms + docs, P2: samples, P3: runtime helpers). Based on Forge CLI Gap Analysis 1.2 (HIGH impact gap). Establishes BWFC position: prefer IJSRuntime over ClientScript shim; emit clear TODO for postback patterns; DO NOT emulate __doPostBack. Ready for implementation planning. decided by Beast + + Team update (2026-04-02): Phase 5 delivery complete — 4 CLI reference docs (docs/cli/index.md, transforms.md, todo-conventions.md, report.md) with 1,378 lines and 81 code examples. mkdocs.yml navigation updated, README.md CLI tooling section added. All documentation standards (tabbed syntax, code examples, migration guides) applied. Build: 0 errors. Ready for merge to feature/global-tool-port. — decided by Scribe 📌 Team update (2026-03-24): Documentation task breakdown complete — 8 GitHub issues (#505–#512) created for component doc syntax conversions, ViewState/PostBack migration guide, and cross-linking. Issues labeled squad+type:docs. Coordinate with Forge for content review. #508 (ViewState docs) blocks on PR #503 merge. — decided by Forge @@ -1111,3 +1117,136 @@ This wave establishes **documentation patterns** that will guide future control - mkdocs.yml navigation nesting signals cognitive importance: placing Phase 1 after "Getting Started" (discovery) tells developers "start here before deeper migration" - Cross-linking to Azure/AWS/ASP.NET docs increases doc value without duplicating content (readers can self-serve on secrets management, logging, etc.) + +### CLI Capability Writeup Transform Inventory & Before/After Example (2026-04-03) + +**Task:** Produce a shareable CLI capability summary for the user covering all implemented transforms. + +**Delivered:** Inline chat response (not committed to repo) + +**Contents:** +- Full inventory of all 31 CLI transforms with one-line descriptions, grouped by pipeline stage (Directives, Expressions, Tag Prefixes, Attributes, Normalization, Scaffolding) +- Before/after ASPX Razor example: realistic .aspx fragment (Page directive, MasterPageFile, asp:Button, asp:Label, Eval() binding, runat="server") fully migrated .razor output +- CLI usage synopsis: wfc-migrate migrate --input ./src --output ./out --report migration-report.json +- Coverage callouts: what the tool handles automatically vs. what lands in ManualItem/TODO comments + +**Learnings:** +- Users respond well to before/after examples that use realistic patterns (not toy "Hello World" markup) showing a GridView with DataBind + Eval expressions, a MasterPageFile reference, and event wiring in a single snippet demonstrates breadth of tool coverage immediately +- Transform inventory is most digestible when grouped by pipeline stage (matches mental model of "what gets processed when") rather than alphabetically +- Always mention the --report flag when summarizing CLI capability: the JSON report is the bridge between automated migration and manual follow-up work + +--- + +## Phase 1 ClientScript Migration Documentation (2026-07-30) + +**Task:** Implement Phase 1 deliverables from PRD: Create comprehensive ClientScript migration guide and analyzer reference pages (BWFC022, BWFC023, BWFC024). + +**Status:** ✅ DELIVERED + +**Deliverables:** + +1. **ClientScriptMigrationGuide.md (34.8K, 11 sections)** + - Overview: Why ClientScript patterns differ in Blazor + - Quick Reference Table: 8 major patterns with difficulty ratings + - Detailed Sections with before/after examples: + 1. Startup Scripts (RegisterStartupScript → OnAfterRenderAsync) + 2. Script Includes (RegisterClientScriptInclude → +``` + +**Automation Opportunity:** CLI can detect `RegisterClientScriptInclude`, extract the script path, and suggest adding a static ` +// 2. Or in component: await JS.InvokeAsync("import", "./lib/jquery-ui.min.js"); +// +// TODO(bwfc-clientscript-include): Add to App layout +``` + +**Criteria:** +- ✅ Detectable URL pattern: literal string or simple ResolveUrl() +- ❌ Do NOT attempt: Dynamic URLs, conditional includes + +**Test case:** TC37-ClientScript-RegisterInclude.aspx + +**Phase:** P1 + +--- + +#### 6.3.3 PostBackEventTransform: Detect (Not Transform) + +**Goal:** Flag `Page.ClientScript.GetPostBackEventReference()` calls. + +**Transform Logic:** + +```csharp +// Detect: Page.ClientScript.GetPostBackEventReference(...) +// Generate TODO: +// +// TODO(bwfc-clientscript-postback): GetPostBackEventReference() is not available in Blazor. +// This pattern registers a dynamic postback event handler. In Blazor, use EventCallback instead. +// See: docs/Migration/ClientScriptMigrationGuide.md +``` + +**Criteria:** +- ✅ Detectable pattern: simple method call +- ❌ Do NOT attempt: Transformation (pattern is inherently postback-based) + +**Test case:** TC38-ClientScript-PostBackReference.aspx + +**Phase:** P1 + +--- + +### 6.4 TODO / Manual Rewrite Guidance + +#### 6.4.1 Postback Validation Patterns + +**Patterns:** +- `Page.Validate(validationGroup)` +- `Page.IsValid` checks +- `ClientScript` + form validation + +**TODO Template:** +```csharp +// TODO(bwfc-clientscript-validation): Web Forms validation is postback-driven. +// Blazor uses EditContext-based validation instead. +// +// Web Forms: +// if (!Page.IsValid) { ... } +// +// Blazor equivalent: +// +// +// +// +// +// See: docs/Migration/ClientScriptMigrationGuide.md (Validation section) +``` + +#### 6.4.2 IPostBackEventHandler Implementation + +**TODO Template:** +```csharp +// TODO(bwfc-clientscript-ipostback): IPostBackEventHandler is not available in Blazor. +// Replace with EventCallback: +// +// [Parameter] +// public EventCallback OnPostBackEvent { get; set; } +// +// private async Task RaiseEvent(string argument) +// { +// await OnPostBackEvent.InvokeAsync(argument); +// } +// +// See: docs/Migration/ClientScriptMigrationGuide.md +``` + +#### 6.4.3 ScriptManager Code-Behind Patterns + +**TODO Template (SetFocus):** +```csharp +// TODO(bwfc-clientscript-setfocus): ScriptManager.SetFocus() is not available in Blazor. +// Use JavaScript interop to focus an element: +// +// @ref element="@inputRef" +// protected ElementReference inputRef; +// +// await JS.InvokeVoidAsync("focus", inputRef); +``` + +**TODO Template (RegisterAsyncPostBackControl):** +```csharp +// TODO(bwfc-clientscript-asyncpostback): RegisterAsyncPostBackControl() is not available in Blazor. +// Blazor does not use UpdatePanel async postback model. +// Remove this call. If you need partial updates, use component parameter binding instead. +``` + +--- + +### 6.5 Documentation & Testing Requirements + +#### 6.5.1 New Documentation: ClientScriptMigrationGuide.md + +**Location:** `docs/Migration/ClientScriptMigrationGuide.md` + +**Sections:** +1. **Overview** — Why ClientScript patterns differ in Blazor +2. **StartupScript Migration** — Simple startup scripts with `OnAfterRenderAsync` example +3. **Script Includes** — Static ` + + ``` + +### Common Fix: PostBack Event Reference + +=== "Web Forms (Before)" + ```csharp + public string GetDeleteButtonScript() + { + return Page.ClientScript.GetPostBackEventReference( + new PostBackOptions(btnDelete, "clicked")); + } + ``` + +=== "Blazor (After)" + ```razor + + + @code { + private async Task HandleDelete() + { + await DeleteItemAsync(); + } + } + ``` + +--- + +## Detailed Migration Paths + +For **comprehensive migration guidance** with code examples for each ClientScript method, see: + +📖 **[ClientScriptMigrationGuide.md](../Migration/ClientScriptMigrationGuide.md)** + +Sections: +1. **Startup Scripts** — Most common pattern (Section 1) +2. **Script Includes** — External `.js` files (Section 2) +3. **Script Blocks** — Inline JavaScript (Section 3) +4. **Postback Events** — Dynamic event references (Section 4) +5. **Form Validation** — `Page.IsValid` patterns (Section 5) + +--- + +## Common Mistakes + +### ❌ Don't: Use `eval()` for Complex Scripts + +```csharp +// ❌ WRONG: Embedding complex logic in eval() +await JS.InvokeVoidAsync("eval", @" + function processData() { + // 50 lines of logic... + } + processData(); +"); +``` + +### ✅ Do: Define Functions in JavaScript Modules + +```javascript +// app.js +export function processData() { + // 50 lines of logic... +} +``` + +```csharp +// Component +var module = await JS.InvokeAsync("import", "./app.js"); +await module.InvokeVoidAsync("processData"); +``` + +### ❌ Don't: Skip the `firstRender` Guard + +```csharp +// ❌ WRONG: Script runs on every render +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + await JS.InvokeVoidAsync("applyTheme"); +} +``` + +### ✅ Do: Guard with `if (firstRender)` + +```csharp +// ✅ CORRECT: Script runs only on first render +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + if (firstRender) + { + await JS.InvokeVoidAsync("applyTheme"); + } +} +``` + +--- + +## Related Analyzers + +- **[BWFC023](BWFC023.md)** — IPostBackEventHandler usage +- **[BWFC024](BWFC024.md)** — ScriptManager code-behind usage + +--- + +## Configuration + +To suppress this warning for a specific line: + +```csharp +#pragma warning disable BWFC022 +Page.ClientScript.RegisterStartupScript(/* ... */); +#pragma warning restore BWFC022 +``` + +Or in `.editorconfig`: + +```ini +[*.cs] +dotnet_diagnostic.BWFC022.severity = silent +``` + +--- + +## See Also + +- 📖 [ClientScriptMigrationGuide.md](../Migration/ClientScriptMigrationGuide.md) — Comprehensive migration guide +- 📖 [IJSRuntime Documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability) — Blazor JS interop +- 📖 [Component Lifecycle](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/lifecycle) — OnAfterRenderAsync and friends + +--- + +**Status:** ✅ Active +**Last Updated:** 2026-07-30 +**Owner:** Beast (Technical Writer) diff --git a/docs/Analyzers/BWFC023.md b/docs/Analyzers/BWFC023.md new file mode 100644 index 000000000..0f4dcfe32 --- /dev/null +++ b/docs/Analyzers/BWFC023.md @@ -0,0 +1,387 @@ +# BWFC023: IPostBackEventHandler Usage + +**Diagnostic ID:** `BWFC023` +**Severity:** ⚠️ Warning +**Category:** Migration +**Status:** Active + +--- + +## What It Detects + +This analyzer warns when you implement `IPostBackEventHandler` — a Web Forms interface for custom controls to raise server events in response to client-side actions. + +**Detected patterns:** +- `class MyControl : Control, IPostBackEventHandler` +- `public void RaisePostBackEvent(string eventArgument) { ... }` +- Any implementation of the `IPostBackEventHandler` interface + +--- + +## Example + +```csharp +public partial class MyCustomControl : UserControl, IPostBackEventHandler +{ + // ⚠️ BWFC023: IPostBackEventHandler is not available in Blazor. + // Use EventCallback for event handling instead. + + public event EventHandler OnCustomAction; + + public void RaisePostBackEvent(string eventArgument) + { + if (eventArgument == "action") + { + OnCustomAction?.Invoke(this, EventArgs.Empty); + } + } +} +``` + +--- + +## Why It Matters + +`IPostBackEventHandler` is tightly coupled to Web Forms' **postback event model**: + +- A client-side event triggers `__doPostBack(controlId, eventArgument)` +- The server receives the POST, decodes the event data, and calls `RaisePostBackEvent()` +- The control can raise server-side events in response + +In Blazor: + +- **There is no postback cycle** — events are component method calls +- **No `__doPostBack()` mechanism** — client actions invoke C# methods directly +- **`EventCallback` replaces postback events** — provides parent-child component communication + +Without updating, your migrated code will have **no way to raise events** to parent components. + +--- + +## How to Fix + +Replace `IPostBackEventHandler` with `EventCallback` parameters. + +### Simple Case: No Arguments + +=== "Web Forms (Before)" + ```csharp + public partial class MyControl : UserControl, IPostBackEventHandler + { + public event EventHandler OnAction; + + public void RaisePostBackEvent(string eventArgument) + { + OnAction?.Invoke(this, EventArgs.Empty); + } + } + ``` + +=== "Blazor (After)" + ```razor + @* MyControl.razor *@ + + + + @code { + [Parameter] + public EventCallback OnAction { get; set; } + + private async Task RaiseAction() + { + await OnAction.InvokeAsync(); + } + } + + @* Parent.razor *@ + + + @code { + private async Task HandleAction() + { + // Handle the event + } + } + ``` + +### With Arguments: Single Value + +=== "Web Forms (Before)" + ```csharp + public partial class MyControl : UserControl, IPostBackEventHandler + { + public event EventHandler OnItemSelected; + + public void RaisePostBackEvent(string eventArgument) + { + if (eventArgument.StartsWith("select-")) + { + string itemId = eventArgument.Substring("select-".Length); + OnItemSelected?.Invoke(this, new ItemSelectedEventArgs { ItemId = itemId }); + } + } + } + + public class ItemSelectedEventArgs : EventArgs + { + public string ItemId { get; set; } + } + ``` + +=== "Blazor (After)" + ```razor + @* MyControl.razor *@ + + @foreach (var item in Items) + { + + } + + @code { + [Parameter] + public List Items { get; set; } + + [Parameter] + public EventCallback OnItemSelected { get; set; } + + private async Task SelectItem(string itemId) + { + await OnItemSelected.InvokeAsync(itemId); + } + } + + @* Parent.razor *@ + + + @code { + private async Task HandleItemSelected(string itemId) + { + // Handle selection + } + } + ``` + +### With Arguments: Multiple Values (EventArgs Class) + +If you need to pass multiple values, use a custom class: + +=== "Web Forms (Before)" + ```csharp + public partial class GridControl : UserControl, IPostBackEventHandler + { + public event EventHandler OnRowSelected; + + public void RaisePostBackEvent(string eventArgument) + { + // eventArgument format: "rowId|action" + var parts = eventArgument.Split('|'); + var args = new RowSelectedEventArgs + { + RowId = int.Parse(parts[0]), + Action = parts[1] + }; + OnRowSelected?.Invoke(this, args); + } + } + + public class RowSelectedEventArgs : EventArgs + { + public int RowId { get; set; } + public string Action { get; set; } + } + ``` + +=== "Blazor (After)" + ```razor + @* GridControl.razor *@ + + @foreach (var row in Rows) + { + + @row.Name + + } + + @code { + [Parameter] + public List Rows { get; set; } + + [Parameter] + public EventCallback OnRowSelected { get; set; } + + private async Task HandleRowClick(int rowId, string action) + { + var args = new RowSelectedEventArgs { RowId = rowId, Action = action }; + await OnRowSelected.InvokeAsync(args); + } + } + + public class RowSelectedEventArgs + { + public int RowId { get; set; } + public string Action { get; set; } + } + + @* Parent.razor *@ + + + @code { + private async Task HandleRowSelected(RowSelectedEventArgs args) + { + if (args.Action == "edit") + { + await EditRowAsync(args.RowId); + } + } + } + ``` + +--- + +## Key Differences + +| Aspect | Web Forms | Blazor | +|--------|-----------|--------| +| **Event mechanism** | IPostBackEventHandler + `__doPostBack()` | EventCallback | +| **Event declaration** | `event EventHandler OnEvent` | `[Parameter] EventCallback OnEvent` | +| **Event invocation** | `OnEvent?.Invoke(...)` in `RaisePostBackEvent()` | `await OnEvent.InvokeAsync(...)` | +| **Data passing** | Custom EventArgs classes (optional) | Parameter type `T` (any type) | +| **Parent binding** | Automatic via postback | Explicit `OnEvent="@handler"` parameter | + +--- + +## Real-World Example: Custom Picker Control + +=== "Web Forms (Before)" + ```csharp + public partial class DatePickerControl : UserControl, IPostBackEventHandler + { + public event EventHandler OnDatePicked; + + public void RaisePostBackEvent(string eventArgument) + { + if (DateTime.TryParse(eventArgument, out var date)) + { + OnDatePicked?.Invoke(this, new DatePickedEventArgs { SelectedDate = date }); + } + } + } + + public class DatePickedEventArgs : EventArgs + { + public DateTime SelectedDate { get; set; } + } + ``` + +=== "Blazor (After)" + ```razor + @* DatePickerControl.razor *@ + + + + @code { + [Parameter] + public EventCallback OnDatePicked { get; set; } + + private async Task HandleDateChange(ChangeEventArgs e) + { + if (DateTime.TryParse(e.Value?.ToString(), out var date)) + { + await OnDatePicked.InvokeAsync(date); + } + } + } + + @* Parent.razor *@ + + + @code { + private async Task HandleDatePicked(DateTime date) + { + SelectedDate = date; + } + } + ``` + +--- + +## Common Mistakes + +### ❌ Don't: Forget `await` When Invoking + +```csharp +// ❌ WRONG: Not awaiting the callback +private async Task SelectItem(string itemId) +{ + OnItemSelected.InvokeAsync(itemId); // Missing await! +} +``` + +### ✅ Do: Always `await` EventCallback Invocations + +```csharp +// ✅ CORRECT: Properly awaiting +private async Task SelectItem(string itemId) +{ + await OnItemSelected.InvokeAsync(itemId); +} +``` + +### ❌ Don't: Check if Callback is Null + +```csharp +// ❌ WRONG: EventCallback is never null +if (OnItemSelected != null) +{ + await OnItemSelected.InvokeAsync(itemId); +} +``` + +### ✅ Do: Just Invoke (EventCallback Handles Null) + +```csharp +// ✅ CORRECT: EventCallback is safe to invoke directly +await OnItemSelected.InvokeAsync(itemId); +``` + +--- + +## Related Analyzers + +- **[BWFC022](BWFC022.md)** — Page.ClientScript usage (see **ClientScriptShim** for easy migration) +- **[BWFC024](BWFC024.md)** — ScriptManager code-behind usage + +--- + +## Configuration + +To suppress this warning for a specific line: + +```csharp +#pragma warning disable BWFC023 +public void RaisePostBackEvent(string eventArgument) { } +#pragma warning restore BWFC023 +``` + +Or in `.editorconfig`: + +```ini +[*.cs] +dotnet_diagnostic.BWFC023.severity = silent +``` + +--- + +## See Also + +- 📖 [ClientScriptMigrationGuide.md](../Migration/ClientScriptMigrationGuide.md) — Section 6: IPostBackEventHandler +- 📖 [EventCallback Documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/event-handling) — Blazor event handling +- 📖 [Component Parameters](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/index) — Parameter binding in Blazor + +--- + +**Status:** ✅ Active +**Last Updated:** 2026-07-30 +**Owner:** Beast (Technical Writer) diff --git a/docs/Analyzers/BWFC024.md b/docs/Analyzers/BWFC024.md new file mode 100644 index 000000000..c92a3262b --- /dev/null +++ b/docs/Analyzers/BWFC024.md @@ -0,0 +1,436 @@ +# BWFC024: ScriptManager Code-Behind Usage + +**Diagnostic ID:** `BWFC024` +**Severity:** ⚠️ Warning +**Category:** Migration +**Status:** Active + +--- + +## What It Detects + +This analyzer warns when you use `ScriptManager` code-behind methods like `GetCurrent()`, `SetFocus()`, `RegisterAsyncPostBackControl()`, and similar — Web Forms APIs for managing client scripts and UpdatePanel behavior. + +**Detected patterns:** +- `ScriptManager.GetCurrent(Page)` / `ScriptManager.GetCurrent(this)` +- `.SetFocus(control)` +- `.RegisterAsyncPostBackControl(control)` +- `.RegisterUpdateProgress(...)` +- `.RegisterPostBackControl(...)` + +--- + +## Example + +```csharp +protected void Page_Load(object sender, EventArgs e) +{ + // ⚠️ BWFC024: ScriptManager.GetCurrent() and related methods are not available in Blazor. + ScriptManager sm = ScriptManager.GetCurrent(Page); + + // Set focus + sm.SetFocus(txtSearchBox); + + // Register for async postback + sm.RegisterAsyncPostBackControl(gvData); +} +``` + +--- + +## Why It Matters + +`ScriptManager` methods are deeply tied to **Web Forms' postback and UpdatePanel architecture**: + +- `GetCurrent()` retrieves the page's ScriptManager instance +- `SetFocus()` ensures client focus after postback +- `RegisterAsyncPostBackControl()` enables AJAX partial-page updates +- `RegisterUpdateProgress()` shows progress during UpdatePanel operations + +In Blazor: + +- **No `ScriptManager` instance** — script management is component-scoped +- **No postback lifecycle** — focus handling is explicit +- **No UpdatePanel model** — components handle their own updates natively +- **No async postback concept** — Blazor uses real-time SignalR sync + +These methods have **no direct equivalents**. Each requires a different approach. + +--- + +## How to Fix + +The fix depends on **which** ScriptManager method you're using. + +### Fix 1: SetFocus() → JavaScript Interop + +Focus management in Blazor uses `@ref` and `IJSRuntime`. + +=== "Web Forms (Before)" + ```csharp + protected void Page_Load(object sender, EventArgs e) + { + ScriptManager.SetFocus(txtSearchBox); + } + ``` + +=== "Blazor (After)" + ```razor + @inject IJSRuntime JS + + + + @code { + private ElementReference searchBox; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("focus", searchBox); + } + } + } + ``` + +Or define a helper function in JavaScript: + +```javascript +// app.js +export function focusElement(element) { + element?.focus(); +} +``` + +```csharp +// Component +await module.InvokeVoidAsync("focusElement", searchBox); +``` + +### Fix 2: RegisterAsyncPostBackControl() → Remove + +`RegisterAsyncPostBackControl()` enables partial-page AJAX updates via UpdatePanel. + +**Blazor does NOT support UpdatePanel-style AJAX postbacks.** Blazor components handle all updates natively via parameter binding. + +=== "Web Forms (Before)" + ```csharp + protected void Page_Load(object sender, EventArgs e) + { + // Enable AJAX partial updates for GridView + ScriptManager.RegisterAsyncPostBackControl(gvData); + } + + protected void gvData_SelectedIndexChanged(object sender, EventArgs e) + { + // Partial page refresh in UpdatePanel + gvData.DataSource = GetUpdatedData(); + gvData.DataBind(); + } + ``` + +=== "Blazor (After)" + ```razor + @page "/data" + @inject HttpClient Http + + + + @code { + private List items; + + protected override async Task OnInitializedAsync() + { + items = await Http.GetFromJsonAsync>("/api/items"); + } + + private async Task HandleRowSelected(int itemId) + { + // Component automatically re-renders when data changes + items = await Http.GetFromJsonAsync>("/api/items"); + } + } + ``` + +**Key difference:** Blazor components automatically re-render when state changes. No manual registration needed. + +### Fix 3: RegisterUpdateProgress() → Use Component State + +`RegisterUpdateProgress()` shows a message or spinner during UpdatePanel operations. + +=== "Web Forms (Before)" + ```csharp + ScriptManager.RegisterUpdateProgress(updateProgress, masterUpdateProgress); + + // During async postback, the UpdateProgress displays + protected void LongRunningOperation() + { + System.Threading.Thread.Sleep(5000); // Simulates work + } + ``` + +=== "Blazor (After)" + ```razor + @if (isLoading) + { +
+

Loading data...

+
+ } + + + + @code { + private List items; + private bool isLoading; + + private async Task LoadData() + { + isLoading = true; + try + { + items = await Http.GetFromJsonAsync>("/api/items"); + } + finally + { + isLoading = false; + } + } + } + ``` + +### Fix 4: GetCurrent() → Remove + +`ScriptManager.GetCurrent()` retrieves the page's ScriptManager instance for other operations. + +**In Blazor, there is no page-level `ScriptManager`.** Remove calls to `GetCurrent()` and replace the specific methods (SetFocus, RegisterAsyncPostBackControl, etc.) with the patterns above. + +=== "Web Forms (Before)" + ```csharp + ScriptManager sm = ScriptManager.GetCurrent(Page); + sm.SetFocus(txtField); + sm.RegisterAsyncPostBackControl(gridView); + ``` + +=== "Blazor (After)" + ```razor + @inject IJSRuntime JS + + + + + @code { + private ElementReference field; + private List items; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("focus", field); + } + } + + private async Task HandleRowSelected(int id) + { + // Update component state; Blazor handles re-render + items = await FetchDataAsync(); + } + } + ``` + +--- + +## Migration Quick Reference + +| Web Forms Method | Blazor Equivalent | Approach | +|---|---|---| +| `GetCurrent(Page)` | — | Remove; use component state | +| `.SetFocus(control)` | `JS.InvokeVoidAsync("focus", @ref)` | JavaScript interop | +| `.RegisterAsyncPostBackControl()` | Component parameter binding | Remove; use `@bind` or `EventCallback` | +| `.RegisterPostBackControl()` | — | Remove; not needed in Blazor | +| `.RegisterUpdateProgress()` | Component state flag | Use `@if (isLoading)` UI binding | +| `.IsInAsyncPostBack` | — | Remove; always synchronous in Blazor | + +--- + +## Real-World Example: Search Page with Loading Indicator + +=== "Web Forms (Before)" + ```csharp + public partial class SearchPage : Page + { + protected void Page_Load(object sender, EventArgs e) + { + ScriptManager sm = ScriptManager.GetCurrent(Page); + sm.SetFocus(txtSearchBox); + sm.RegisterUpdateProgress(updateProgress, masterUpdateProgress); + } + + protected void btnSearch_Click(object sender, EventArgs e) + { + // Long-running search + var results = SearchDatabase(txtSearchBox.Text); + gvResults.DataSource = results; + gvResults.DataBind(); + // UpdateProgress shows during postback + } + } + ``` + +=== "Blazor (After)" + ```razor + @page "/search" + @inject IJSRuntime JS + @inject HttpClient Http + + + + + @if (isLoading) + { +
+

Searching...

+
+
+ } + + + + @code { + private ElementReference searchBox; + private string searchQuery; + private List results = new(); + private bool isLoading; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("focus", searchBox); + } + } + + private async Task PerformSearch() + { + if (string.IsNullOrWhiteSpace(searchQuery)) return; + + isLoading = true; + try + { + results = await Http.GetFromJsonAsync>( + $"/api/search?q={Uri.EscapeDataString(searchQuery)}"); + } + finally + { + isLoading = false; + } + } + } + + public class SearchResult + { + public int Id { get; set; } + public string Title { get; set; } + } + ``` + +--- + +## Common Mistakes + +### ❌ Don't: Try to Call ScriptManager Methods + +```csharp +// ❌ WRONG: ScriptManager.GetCurrent() returns null in Blazor +ScriptManager sm = ScriptManager.GetCurrent(Page); // null +sm.SetFocus(txtField); // NullReferenceException! +``` + +### ✅ Do: Use Component-Based Patterns + +```csharp +// ✅ CORRECT: Use @ref and IJSRuntime +@ref="field" +await JS.InvokeVoidAsync("focus", field); +``` + +### ❌ Don't: Use RegisterAsyncPostBackControl() for Partial Updates + +```csharp +// ❌ WRONG: UpdatePanel AJAX is not supported +ScriptManager.RegisterAsyncPostBackControl(gridView); +// gridView.DataBind() won't trigger partial refresh +``` + +### ✅ Do: Let Components Handle Their Own Updates + +```csharp +// ✅ CORRECT: Component re-renders on state change +items = await FetchUpdatedData(); +// Blazor automatically syncs the UI +``` + +### ❌ Don't: Check IsInAsyncPostBack + +```csharp +// ❌ WRONG: No async postback concept in Blazor +if (ScriptManager.GetCurrent(Page).IsInAsyncPostBack) +{ + // This code path doesn't exist +} +``` + +### ✅ Do: Use Component Lifecycle Events + +```csharp +// ✅ CORRECT: Blazor components are always "interactive" +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + if (firstRender) + { + // Component is interactive; safe to use JS interop + } +} +``` + +--- + +## Related Analyzers + +- **[BWFC022](BWFC022.md)** — Page.ClientScript usage (see **ClientScriptShim** for easy migration) +- **[BWFC023](BWFC023.md)** — IPostBackEventHandler usage + +--- + +## Configuration + +To suppress this warning for a specific line: + +```csharp +#pragma warning disable BWFC024 +ScriptManager.GetCurrent(Page).SetFocus(txtField); +#pragma warning restore BWFC024 +``` + +Or in `.editorconfig`: + +```ini +[*.cs] +dotnet_diagnostic.BWFC024.severity = silent +``` + +--- + +## See Also + +- 📖 [ClientScriptMigrationGuide.md](../Migration/ClientScriptMigrationGuide.md) — Section 7: ScriptManager Patterns +- 📖 [IJSRuntime Documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability) — JavaScript interop +- 📖 [Component Lifecycle](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/lifecycle) — OnAfterRenderAsync +- 📖 [EditForm Component](https://learn.microsoft.com/en-us/aspnet/core/blazor/forms-and-input-components) — Form handling in Blazor + +--- + +**Status:** ✅ Active +**Last Updated:** 2026-07-30 +**Owner:** Beast (Technical Writer) diff --git a/docs/Migration/ClientScriptMigrationGuide.md b/docs/Migration/ClientScriptMigrationGuide.md new file mode 100644 index 000000000..e71c738d2 --- /dev/null +++ b/docs/Migration/ClientScriptMigrationGuide.md @@ -0,0 +1,1381 @@ +# ClientScript Migration Guide + +When migrating from ASP.NET Web Forms to Blazor, one of the most critical patterns to understand is **JavaScript execution**. In Web Forms, `Page.ClientScript` and `ScriptManager` manage all client-side scripts. In Blazor, this responsibility falls to `IJSRuntime` and component lifecycle events. + +This guide covers the major ClientScript patterns, why they differ in Blazor, and how to migrate each one. + +--- + +## 🎯 Recommended: ClientScriptShim (Zero-Rewrite Path) + +The **easiest path** for most migrations is the **ClientScriptShim** — a compatibility layer that provides the same API as `Page.ClientScript` but runs on Blazor's `IJSRuntime` internally. + +### What It Is + +`ClientScriptShim` is a scoped Blazor service included in BWFC that: +- **Accepts the same method calls** as Web Forms' `Page.ClientScript` +- **Requires zero code rewrites** — your existing `RegisterStartupScript()`, `RegisterClientScriptBlock()`, etc. calls work unchanged +- **Queues scripts** during component initialization +- **Auto-flushes** in `OnAfterRenderAsync` via `IJSRuntime` +- **Handles deduplication** by type+key (same behavior as Web Forms) + +### Automatic Registration + +When you call `AddBlazorWebFormsComponents()` in your Startup, `ClientScriptShim` is registered as a scoped service and ready to use. + +### How to Use It + +**For components inheriting `BaseWebFormsComponent`:** + +The `ClientScript` property is automatically available — use it exactly as you would in Web Forms: + +```csharp +protected override void OnInitialized() +{ + ClientScript.RegisterStartupScript(GetType(), "init", + "alert('Page loaded!');", true); +} +``` + +**For any other component:** + +Inject `ClientScriptShim` and use it the same way: + +```razor +@inject ClientScriptShim ClientScript + +@code { + protected override void OnInitialized() + { + ClientScript.RegisterStartupScript(GetType(), "init", + "alert('Page loaded!');", true); + } +} +``` + +### Supported Methods + +| Method | Status | Notes | +|--------|--------|-------| +| `RegisterStartupScript(Type, string, string, bool)` | ✅ Supported | Executes in `OnAfterRenderAsync` | +| `RegisterStartupScript(Type, string, string)` | ✅ Supported | `addScriptTags` defaults to false | +| `RegisterClientScriptBlock(Type, string, string, bool)` | ✅ Supported | Executes before startup scripts | +| `RegisterClientScriptBlock(Type, string, string)` | ✅ Supported | `addScriptTags` defaults to false | +| `RegisterClientScriptInclude(string, string)` | ✅ Supported | Dynamically appends ` + + + + + + + +``` + +**Option 2: Dynamic import via IJSRuntime (For Conditional Loads)** + +If the script is only needed conditionally (e.g., admin users only): + +=== "TypeScript/JavaScript" + ```javascript + export async function loadAdminTools() { + // Dynamically import the admin module + const adminModule = await import('./admin-tools.js'); + adminModule.init(); + } + ``` + +=== "Blazor Component" + ```razor + @inject IJSRuntime JS + @inject AuthenticationStateProvider Auth + + @code { + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + var authState = await Auth.GetAuthenticationStateAsync(); + if (authState.User.IsInRole("Admin")) + { + var module = await JS.InvokeAsync( + "import", "./app.js"); + await module.InvokeVoidAsync("loadAdminTools"); + } + } + } + } + ``` + +### Key Differences + +| Aspect | Web Forms | Blazor | +|--------|-----------|--------| +| **Inclusion** | Server-side `RegisterClientScriptInclude()` | HTML ` +``` + +**But avoid this pattern** — it pollutes the global namespace and makes testing harder. Prefer JavaScript modules. + +--- + +## 4. Postback Event References + +### What It Does + +`GetPostBackEventReference()` generates a dynamic JavaScript call to trigger a postback event, often used in client-side event handlers that need to notify the server. + +### Web Forms + +```csharp +public string GetDeleteButtonScript() +{ + // Generate: javascript:__doPostBack('btnDelete','clicked') + return Page.ClientScript.GetPostBackEventReference( + new PostBackOptions(btnDelete, "clicked") + { + PerformValidation = false + }); +} + +// Usage in markup: +// Delete +``` + +When the user clicks the link, `__doPostBack()` POSTs the form back to the server with event data. + +### Blazor Equivalent + +Blazor has **no `__doPostBack()` equivalent** because there's no postback cycle. Instead, bind a click handler directly to a component method using `@onclick` or `EventCallback`: + +=== "Simple Case: @onclick" + ```razor + + + @code { + private async Task HandleDelete() + { + // Handle delete directly in component + await DeleteItem(); + } + } + ``` + +=== "Parameterized Case: EventCallback" + ```razor + + + + @code { + private async Task HandleDelete(int itemId) + { + await DeleteItemAsync(itemId); + } + } + + + @inject NavigationManager Nav + + + + @code { + [Parameter] + public int ItemId { get; set; } + + [Parameter] + public EventCallback OnDelete { get; set; } + + private async Task RaiseDelete() + { + await OnDelete.InvokeAsync(ItemId); + } + } + ``` + +### Key Differences + +| Aspect | Web Forms | Blazor | +|--------|-----------|--------| +| **Mechanism** | JavaScript `__doPostBack()` → HTTP POST | Direct component method call | +| **Server roundtrip** | Full page reload on postback | Blazor diff sync (no page reload) | +| **Validation** | Server-side `Page.IsValid` | `EditContext`-based validation | +| **Event data** | Form-encoded POST body | Method parameters | + +### When to Use Each + +- **`@onclick`** — Simple button click, no complex validation +- **`EventCallback`** — Parent/child communication, reusable components +- **Form handlers** — For multi-field validation, use `EditForm` + `EditContext` + +--- + +## 5. Form Validation Scripts + +### What It Does + +Web Forms uses `Page.IsValid` and `Page.Validate()` to check server-side validators. Client-side validation scripts often run before postback to prevent unnecessary round trips. + +### Web Forms + +```csharp +protected void btnSubmit_Click(object sender, EventArgs e) +{ + // Validators run server-side + if (!Page.IsValid) + { + // Show error + return; + } + + // Process form + SaveData(); +} + +// In markup: +// +// +``` + +### Blazor Equivalent + +Use `EditForm` with `EditContext` and `DataAnnotationsValidator`. Validation is **declarative** (via data annotations) and works on both client and server: + +```razor +@inject HttpClient Http + + + + + +
+ + + +
+ +
+ + + +
+ + +
+ +@code { + private FormModel model = new(); + + private async Task HandleSubmit() + { + // Only called if validation passes + await SaveDataAsync(); + } +} + +public class FormModel +{ + [Required(ErrorMessage = "Name is required")] + public string Name { get; set; } + + [Range(0, 120, ErrorMessage = "Age must be between 0 and 120")] + public int Age { get; set; } +} +``` + +### Key Differences + +| Aspect | Web Forms | Blazor | +|--------|-----------|--------| +| **Declaration** | Server-side validator controls | C# data annotations | +| **Client-side validation** | Rendered JavaScript from validators | Built-in via `DataAnnotationsValidator` | +| **Validation timing** | Submit button click → postback | Form submission or real-time | +| **Custom rules** | Custom validators or `CustomValidator` control | `ValidationAttribute` subclass | + +### Custom Validators + +**Web Forms:** +```csharp + +``` + +**Blazor:** +```csharp +public class DateInPastAttribute : ValidationAttribute +{ + protected override ValidationResult IsValid(object value, ValidationContext ctx) + { + var date = (DateTime?)value; + return date < DateTime.Now + ? ValidationResult.Success + : new ValidationResult("Date must be in the past"); + } +} + +// Usage in model: +[DateInPast] +public DateTime EventDate { get; set; } +``` + +--- + +## 6. IPostBackEventHandler — Custom Event Binding + +### What It Does + +`IPostBackEventHandler` allows controls to raise custom events in response to postback data. Rarely used directly, but common in composite controls. + +### Web Forms + +```csharp +public partial class MyCustomControl : UserControl, IPostBackEventHandler +{ + public event EventHandler OnCustomAction; + + public void RaisePostBackEvent(string eventArgument) + { + if (eventArgument == "myaction") + { + OnCustomAction?.Invoke(this, EventArgs.Empty); + } + } + + // Markup triggers postback: + // +} +``` + +### Blazor Equivalent + +Use `EventCallback` parameters instead: + +```razor + +@code { + [Parameter] + public EventCallback OnCustomAction { get; set; } + + private async Task RaiseCustomAction() + { + await OnCustomAction.InvokeAsync(); + } +} + + + + +@code { + private async Task HandleCustomAction() + { + // Handle the event + } +} +``` + +### With Arguments + +If the postback event passes data: + +=== "Web Forms" + ```csharp + public void RaisePostBackEvent(string eventArgument) + { + if (eventArgument.StartsWith("select-")) + { + string itemId = eventArgument.Replace("select-", ""); + OnItemSelected?.Invoke(this, new ItemSelectedEventArgs { ItemId = itemId }); + } + } + ``` + +=== "Blazor" + ```razor + @code { + [Parameter] + public EventCallback OnItemSelected { get; set; } + + private async Task SelectItem(string itemId) + { + await OnItemSelected.InvokeAsync(itemId); + } + } + ``` + +--- + +## 7. ScriptManager Code-Behind Patterns + +### SetFocus() + +**Web Forms:** +```csharp +ScriptManager.SetFocus(txtUserName); +``` + +**Blazor:** +```razor +@inject IJSRuntime JS + + + +@code { + private ElementReference userNameRef; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("focus", userNameRef); + } + } +} +``` + +### RegisterAsyncPostBackControl() + +**Web Forms:** +```csharp +ScriptManager.RegisterAsyncPostBackControl(gvData); +// Enables AJAX partial page updates via UpdatePanel +``` + +**Blazor:** +`RegisterAsyncPostBackControl()` **has no equivalent** in Blazor because Blazor components handle updates natively via parameter binding and `EventCallback`. Remove this line. + +Instead, let the component update naturally: + +```razor +@page "/data" + + + +@code { + private List items; + + private async Task HandleRowSelected(int itemId) + { + // Component updates automatically via @bind or parameters + var item = await FetchItemAsync(itemId); + items = await FetchItemsAsync(); // Re-render with new data + } +} +``` + +### RegisterUpdateProgress() + +**Web Forms:** +```csharp +ScriptManager.RegisterUpdateProgress(updateProgress, masterUpdateProgress); +// Shows during async postback +``` + +**Blazor:** +Show a loading indicator using component state: + +```razor +
+

Loading...

+
+ + + +@code { + private bool isLoading; + + private async Task FetchData() + { + isLoading = true; + await Task.Delay(2000); // Simulate async work + isLoading = false; + } +} +``` + +### GetCurrent() and Related Methods + +**Web Forms:** +```csharp +ScriptManager sm = ScriptManager.GetCurrent(Page); +sm.SetFocus(txtSearch); +sm.RegisterAsyncPostBackControl(gridView); +``` + +**Blazor:** +`ScriptManager.GetCurrent()` returns `null` in Blazor. **Do not use it.** Instead: +- For `SetFocus`: Use `@ref` + `IJSRuntime` (see above) +- For `RegisterAsyncPostBackControl`: Use component parameter binding +- For `RegisterUpdateProgress`: Use component state + +--- + +## 8. Common Pitfalls and Solutions + +### Pitfall 1: Script Runs Multiple Times Due to Re-renders + +**Problem:** +```razor +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + // ❌ WRONG: Runs every render, not just first + await JS.InvokeVoidAsync("applyTheme"); +} +``` + +**Solution:** +```razor +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + // ✅ CORRECT: Only on first render + if (firstRender) + { + await JS.InvokeVoidAsync("applyTheme"); + } +} +``` + +### Pitfall 2: Prerendering Issues + +In SSR (Server-Side Rendering) or prerendering mode, `OnAfterRenderAsync` runs on the server *without* browser interactivity. `IJSRuntime` calls fail silently. + +**Problem:** +```razor +// ❌ WRONG: Fails during prerendering +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + if (firstRender) + { + await JS.InvokeVoidAsync("applyTheme"); // No JS in SSR + } +} +``` + +**Solution:** +```razor +// ✅ CORRECT: Guard with try-catch or check if interactive +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + if (firstRender) + { + try + { + await JS.InvokeVoidAsync("applyTheme"); + } + catch (InvalidOperationException) + { + // Running in SSR mode; skip JS interop + } + } +} + +// OR use a runtime check: +@inject IComponentRenderingContext RenderContext + +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + if (firstRender && RenderContext.IsInteractive) + { + await JS.InvokeVoidAsync("applyTheme"); + } +} +``` + +### Pitfall 3: Script Timing — Waiting for DOM Elements + +**Problem:** +```javascript +// ❌ WRONG: Element might not exist yet +document.getElementById("myDiv").classList.add("highlight"); +``` + +**Solution:** +```csharp +// ✅ CORRECT: Call from OnAfterRenderAsync, after render +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + if (firstRender) + { + await JS.InvokeVoidAsync("highlightElement", "myDiv"); + } +} +``` + +JavaScript: +```javascript +export function highlightElement(id) { + const elem = document.getElementById(id); + if (elem) { + elem.classList.add("highlight"); + } +} +``` + +### Pitfall 4: Module Import Caching + +**Problem:** +```razor +// ❌ WRONG: Imports module every render +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + var module = await JS.InvokeAsync("import", "./app.js"); + await module.InvokeVoidAsync("init"); +} +``` + +**Solution:** +```razor +// ✅ CORRECT: Cache the module +private IJSObjectReference? module; + +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + if (firstRender) + { + module = await JS.InvokeAsync("import", "./app.js"); + await module.InvokeVoidAsync("init"); + } +} +``` + +### Pitfall 5: Script Deduplication + +In Web Forms, `RegisterStartupScript` with the same key runs only once per page. In Blazor, you must deduplicate manually. + +**Problem:** +```razor +// Component rendered multiple times +foreach (var item in items) +{ + +} + +// ❌ WRONG: Each instance calls applyTheme() +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + if (firstRender) + { + await JS.InvokeVoidAsync("applyTheme"); + } +} +``` + +**Solution:** +```razor + +@foreach (var item in items) +{ + +} + +@code { + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("applyTheme"); // Once, not per child + } + } +} +``` + +Or use a static flag to prevent duplicate initialization: + +```csharp +private static bool isAppInitialized; + +protected override async Task OnAfterRenderAsync(bool firstRender) +{ + if (firstRender && !isAppInitialized) + { + isAppInitialized = true; + await JS.InvokeVoidAsync("applyTheme"); + } +} +``` + +--- + +## 9. What We Don't Support (And Why) + +### `__doPostBack()` and Postback Events + +**Why not?** +- Web Forms postback is an HTTP POST with form-encoded data and event validation +- Blazor is **component-based** with direct method calls, not form postbacks +- Emulating `__doPostBack()` would require replicating the entire Web Forms postback protocol, which defeats the purpose of using Blazor + +**Alternative:** +Use `@onclick`, `EventCallback`, or form submission with `EditForm`. + +### UpdatePanel Async Postback Semantics + +**Why not?** +- UpdatePanel enables partial-page updates via AJAX postback +- Blazor components handle updates natively via parameter binding +- A compatibility layer would be complex, fragile, and undermine Blazor's design + +**Alternative:** +Use Blazor component parameters, `@bind`, and `EventCallback` for interactive updates. + +### Automatic Form Validation Conversion + +**Why not?** +- Web Forms validators are declarative controls with complex state management +- Blazor validation is based on data annotations, which are independent of the component model +- Conversion would require semantic analysis of validator configurations and cannot be automated reliably + +**Alternative:** +Manually rewrite validators as data annotations on your model classes. + +### `ScriptManager` Full API Surface + +**Why not?** +- Only a few `ScriptManager` methods are commonly used; most are framework internals +- Each method has a different (or no) Blazor equivalent +- A full compatibility wrapper would create maintenance burden with minimal benefit + +**Alternative:** +Our Roslyn analyzers (BWFC022, BWFC023, BWFC024) detect problematic patterns and guide you to Blazor equivalents. + +--- + +## 10. Analyzers and CLI Transforms + +To help automate migration detection, BWFC provides three diagnostic rules: + +### BWFC022: PageClientScript Usage Analyzer + +Detects `Page.ClientScript` usage and suggests patterns for each method call. + +**Example:** +```csharp +// ⚠️ BWFC022: Page.ClientScript is not available in Blazor. +// Migration path depends on the pattern: +// - If RegisterStartupScript(): Use OnAfterRenderAsync(IJSRuntime) with firstRender guard. +// - If RegisterClientScriptInclude(): Add + +``` + +### Example 2: Dynamic Data Grid with Inline Editing + +**Web Forms:** +```csharp +protected void Page_Load(object sender, EventArgs e) +{ + if (!IsPostBack) + { + // Include script for inline editing + Page.ClientScript.RegisterClientScriptInclude( + "grideditor", + ResolveUrl("~/lib/grid-editor.js")); + } +} + +public class GridData +{ + public int Id { get; set; } + public string Name { get; set; } +} +``` + +**Blazor:** +```razor +@page "/data-grid" +@inject HttpClient Http + + + + + + + + +@code { + private List items; + + protected override async Task OnInitializedAsync() + { + items = await Http.GetFromJsonAsync>("/api/data"); + } + + private async Task HandleRowSelected(int id) + { + // Update data directly, no __doPostBack needed + var item = items.FirstOrDefault(x => x.Id == id); + if (item != null) + { + item.Name = await PromptForNewName(); + await UpdateItemAsync(item); + } + } + + private async Task Refresh() + { + items = await Http.GetFromJsonAsync>("/api/data"); + } +} + +public class GridData +{ + public int Id { get; set; } + public string Name { get; set; } +} +``` + +### Example 3: Form with Custom Validation and Theme Toggle + +**Web Forms:** +```csharp +protected void Page_Load(object sender, EventArgs e) +{ + if (!IsPostBack) + { + // Validation scripts + Page.ClientScript.RegisterStartupScript( + this.GetType(), + "validate", + "window.validateForm = function() { return $('#form').valid(); };", + true); + + // Theme toggle + Page.ClientScript.RegisterStartupScript( + this.GetType(), + "theme", + "$(function() { applyUserTheme(); });", + true); + } +} + +protected void btnSubmit_Click(object sender, EventArgs e) +{ + if (!Page.IsValid) return; + + // Process +} +``` + +**Blazor:** +```razor +@page "/form" +@inject IJSRuntime JS + + + + + +
+ + + +
+ +
+ + + +
+ + + +
+ +@code { + private FormModel model = new(); + private IJSObjectReference? module; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + module = await JS.InvokeAsync("import", "./app.js"); + await module.InvokeVoidAsync("applyUserTheme"); + } + } + + private async Task HandleSubmit() + { + // Only called if validation passes (DataAnnotationsValidator) + await SaveFormAsync(); + } + + private async Task ToggleTheme() + { + if (module is not null) + { + await module.InvokeVoidAsync("toggleTheme"); + } + } +} + +public class FormModel +{ + [Required(ErrorMessage = "Name is required")] + public string Name { get; set; } + + [Required(ErrorMessage = "Email is required")] + [EmailAddress(ErrorMessage = "Invalid email format")] + public string Email { get; set; } +} +``` + +--- + +## Summary + +| Web Forms | Blazor | Learn More | +|-----------|--------|------------| +| `RegisterStartupScript()` | `OnAfterRenderAsync(IJSRuntime)` | Section 1 | +| `RegisterClientScriptInclude()` | `"); + string url = ResolveUrl("~/Products.aspx"); + } +} +``` + +## Web Forms Usage + +```csharp +// Path resolution +string uploadDir = Server.MapPath("~/uploads"); +string configPath = Server.MapPath("/App_Data/config.xml"); + +// HTML encoding +string safe = Server.HtmlEncode(userInput); +string decoded = Server.HtmlDecode(encodedHtml); + +// URL encoding +string param = Server.UrlEncode(searchTerm); +string original = Server.UrlDecode(encodedParam); + +// URL resolution +string productUrl = ResolveUrl("~/Products.aspx"); +string imageUrl = ResolveClientUrl("~/images/logo.png"); +``` + +## Blazor Usage + +```razor +@inherits WebFormsPageBase + +Logo +
Products + +@code { + private string _uploadDir = ""; + private string _safeHtml = ""; + + protected override void OnInitialized() + { + base.OnInitialized(); + + // ~/uploads → {WebRootPath}/uploads + _uploadDir = Server.MapPath("~/uploads"); + + // HTML encoding for safe output + _safeHtml = Server.HtmlEncode(""); + } + + private void BuildSearchUrl() + { + string term = Server.UrlEncode("blazor web forms"); + // ~/Products.aspx → /Products + string url = ResolveUrl($"~/Search.aspx?q={term}"); + Response.Redirect(url); + } +} +``` + +## Path Resolution + +| Input | `MapPath` Result | Notes | +|---|---|---| +| `"~/uploads"` | `{WebRootPath}/uploads` | `~/` maps to wwwroot | +| `"~/images/logo.png"` | `{WebRootPath}/images/logo.png` | File within wwwroot | +| `"/App_Data/config.xml"` | `{ContentRootPath}/App_Data/config.xml` | Non-tilde paths use content root | +| `""` or `null` | `{ContentRootPath}` | Empty returns content root | + +## URL Transformations + +| Input | `ResolveUrl` Result | Notes | +|---|---|---| +| `"~/Products.aspx"` | `"/Products"` | `~/` stripped, `.aspx` removed | +| `"~/images/logo.png"` | `"/images/logo.png"` | Non-aspx extensions preserved | +| `"/Products"` | `"/Products"` | Already-clean URLs pass through | + +## Migration Path + +| Web Forms | BWFC Shim | Native Blazor | +|---|---|---| +| `Server.MapPath("~/img")` | `Server.MapPath("~/img")` | Inject `IWebHostEnvironment` | +| `Server.HtmlEncode(text)` | `Server.HtmlEncode(text)` | `WebUtility.HtmlEncode(text)` | +| `Server.HtmlDecode(text)` | `Server.HtmlDecode(text)` | `WebUtility.HtmlDecode(text)` | +| `Server.UrlEncode(text)` | `Server.UrlEncode(text)` | `WebUtility.UrlEncode(text)` | +| `Server.UrlDecode(text)` | `Server.UrlDecode(text)` | `WebUtility.UrlDecode(text)` | +| `ResolveUrl("~/page.aspx")` | `ResolveUrl("~/page.aspx")` | Use `NavigationManager.ToAbsoluteUri()` | +| `ResolveClientUrl("~/page.aspx")` | `ResolveClientUrl("~/page.aspx")` | Use `NavigationManager.ToAbsoluteUri()` | + +## Moving On + +`ServerShim` is a migration bridge. As you refactor: + +1. **Replace `MapPath`** — Inject `IWebHostEnvironment` directly and use `env.WebRootPath` or `env.ContentRootPath` +2. **Replace encoding helpers** — Use `System.Net.WebUtility.HtmlEncode()` and `UrlEncode()` directly +3. **Replace `ResolveUrl`** — Use `NavigationManager.ToAbsoluteUri()` and remove `.aspx` references from your routes + +```razor +@* Before (migration shim) *@ +@inherits WebFormsPageBase +@code { + string path = Server.MapPath("~/uploads"); + string url = ResolveUrl("~/Products.aspx"); +} + +@* After (native Blazor) *@ +@inject IWebHostEnvironment Env +@inject NavigationManager Nav +@code { + string path = Path.Combine(Env.WebRootPath, "uploads"); + string url = Nav.ToAbsoluteUri("/Products").ToString(); +} +``` + +## See Also + +- [WebFormsPage](WebFormsPage.md) — Page-level base class providing the `Server` property +- [Response.Redirect](ResponseRedirect.md) — Companion shim for navigation +- [L2 Automation Shims](L2AutomationShims.md) — Overview of all migration automation features diff --git a/docs/cli/index.md b/docs/cli/index.md new file mode 100644 index 000000000..3037f32d6 --- /dev/null +++ b/docs/cli/index.md @@ -0,0 +1,178 @@ +# WebForms to Blazor CLI Tool + +The `webforms-to-blazor` CLI is a powerful command-line tool that automates the first phase of your Web Forms to Blazor migration. It performs deterministic, pattern-based transformations on your Web Forms markup and code-behind to produce Blazor-ready code. + +## What It Does + +This tool **reduces manual migration effort** by: + +- Removing boilerplate Web Forms directives and syntax +- Converting ASP.NET server controls to BWFC components +- Replacing Web Forms expressions with Blazor syntax +- Extracting code patterns and flagging them with TODO comments for Copilot L2 automation +- Scaffolding a new Blazor project structure with shims and services + +The tool processes `.aspx`, `.ascx`, and `.master` files in a fixed sequence, ensuring each transformation builds on the previous one correctly. + +## Installation + +### As a Global Tool + +```bash +dotnet tool install --global Fritz.WebFormsToBlazor +``` + +### From Source + +```bash +cd src/BlazorWebFormsComponents.Cli +dotnet pack +dotnet tool install --global --add-source ./bin/Release Fritz.WebFormsToBlazor +``` + +### Verify Installation + +```bash +webforms-to-blazor --help +``` + +## Quick Start + +### Convert a Single File + +```bash +webforms-to-blazor migrate --input ProductCard.ascx --output ./BlazorComponents +``` + +### Convert a Whole Project + +```bash +webforms-to-blazor migrate --input ./MyWebFormsProject --output ./MyBlazorProject +``` + +The tool will: +1. Scan all `.aspx`, `.ascx`, and `.master` files +2. Apply 33 transforms in sequence +3. Generate a migration report +4. Scaffold supporting files (Program.cs, shims, handlers) + +## Two Commands + +### `migrate` — Full Project Migration + +Transforms an entire Web Forms project to Blazor with scaffolding. + +```bash +webforms-to-blazor migrate \ + --input ./MyWebFormsProject \ + --output ./MyBlazorProject \ + --database SqlServer \ + --scaffold +``` + +**Key Options:** + +- `--input ` — Web Forms project root (required) +- `--output ` — Blazor output directory (required) +- `--database ` — SqlServer, Sqlite, Postgres, Oracle (scaffolds appropriate connection setup) +- `--scaffold` — Generate Program.cs, _Imports.razor, App.razor, and shims +- `--dry-run` — Preview changes without writing files + +**Output:** +- Converted `.razor` files +- Converted `.razor.cs` code-behind +- Generated `Program.cs` with shim registration +- Migration report (`migration-report.json`) + +### `convert` — File-Level Transformation + +Converts individual files without scaffolding. Useful for incremental migrations. + +```bash +webforms-to-blazor convert \ + --input ./Controls/MyControl.ascx \ + --output ./Components/MyControl.razor +``` + +**Key Options:** + +- `--input ` — Single `.ascx` or `.aspx` file (required) +- `--output ` — Output file path +- `--dry-run` — Preview transformation + +## Transform Categories + +The tool applies **33 transforms** organized in three groups: + +1. **Directives** (5) — Page, Master, Control, Register, Import directives +2. **Markup** (19) — Controls, expressions, templates, data binding +3. **Code-Behind** (9) — Using statements, base classes, lifecycle, event handlers + +See **[Transform Reference](transforms.md)** for complete details on each transform, including before/after examples. + +## TODO Comments and L2 Automation + +The tool inserts TODO comments with standardized category slugs so Copilot L2 skills can automatically follow up on migration work: + +```csharp +// TODO(bwfc-lifecycle): Page_Load → OnInitializedAsync +// TODO(bwfc-ispostback): Review IsPostBack guard for Blazor patterns +// TODO(bwfc-session-state): SessionShim auto-wired via [Inject] +``` + +See **[TODO Categories](todo-conventions.md)** for the complete list of 13 categories and how L2 automation uses them. + +## Migration Report + +After migration, the tool generates a `migration-report.json` with: + +- File-by-file transformation summary +- Manual work items flagged by category +- Severity levels (Info, Warning, Error) +- Precise file locations and line numbers + +See **[Report Format](report.md)** for schema and examples. + +## Limitations & Next Steps + +**This tool handles Level 1 transformations only:** + +- ✅ Markup and directive conversion +- ✅ Pattern detection and guidance +- ✅ Boilerplate removal +- ❌ Logic rewriting (use Copilot L2 skills for this) + +After running the CLI: + +1. **Review TODO comments** — each one points to a specific migration pattern +2. **Run Copilot L2 skills** — automated follow-up transforms for complex patterns +3. **Build and test** — verify your Blazor project compiles and runs +4. **Manual tweaks** — business logic, styling, third-party integrations + +## Example: Full Migration Workflow + +```bash +# 1. Scan and transform +webforms-to-blazor migrate \ + --input ./MyApp.Web \ + --output ./MyApp.Blazor \ + --database SqlServer \ + --scaffold + +# 2. Review migration report +cat MyApp.Blazor/migration-report.json | jq '.manualItems[] | select(.severity == "Error")' + +# 3. Build and identify missing pieces +cd MyApp.Blazor +dotnet build + +# 4. Use Copilot CLI for L2 automation +copilot /webforms-migration +``` + +## Next Steps + +- **[Transform Reference](transforms.md)** — See what each transform does with before/after examples +- **[TODO Conventions](todo-conventions.md)** — Understand the TODO categories for L2 automation +- **[Report Schema](report.md)** — Interpret the migration report +- **[Migration Strategies](../Migration/Strategies.md)** — Learn the full migration approach diff --git a/docs/cli/report.md b/docs/cli/report.md new file mode 100644 index 000000000..16f06b321 --- /dev/null +++ b/docs/cli/report.md @@ -0,0 +1,403 @@ +# Migration Report Format + +After running the CLI tool, a `migration-report.json` file is generated with detailed information about the transformation. This document describes the report schema and how to interpret it. + +## Report Structure + +The report is a JSON document with the following top-level fields: + +```json +{ + "migrationDate": "2026-04-02T15:30:45Z", + "sourceProject": "/home/user/MyApp.Web", + "outputProject": "/home/user/MyApp.Blazor", + "projectName": "MyApp.Blazor", + "projectFramework": "net8.0", + "toolVersion": "1.0.0", + "summary": { + "totalFiles": 45, + "filesProcessed": 42, + "filesFailed": 0, + "filesSkipped": 3, + "totalTransforms": 1247, + "totalManualItems": 156, + "criticalIssues": 5, + "warnings": 18, + "infos": 133 + }, + "files": [ /* array of file results */ ], + "manualItems": [ /* array of manual work items */ ], + "categories": [ /* grouped manual items by TODO category */ ] +} +``` + +--- + +## Summary Section + +| Field | Type | Description | +|-------|------|-------------| +| `migrationDate` | string (ISO 8601) | When the migration was run | +| `sourceProject` | string | Path to original Web Forms project | +| `outputProject` | string | Path to new Blazor project | +| `projectName` | string | Name of the generated Blazor project | +| `projectFramework` | string | Target framework (e.g., `net8.0`) | +| `toolVersion` | string | CLI tool version | +| **summary.totalFiles** | integer | Total `.aspx`, `.ascx`, `.master` files found | +| **summary.filesProcessed** | integer | Files successfully converted | +| **summary.filesFailed** | integer | Files with conversion errors | +| **summary.filesSkipped** | integer | Files not processed (e.g., excluded) | +| **summary.totalTransforms** | integer | Total transforms applied across all files | +| **summary.totalManualItems** | integer | Number of TODO comments inserted | +| **summary.criticalIssues** | integer | Count of severity="Error" items | +| **summary.warnings** | integer | Count of severity="Warning" items | +| **summary.infos** | integer | Count of severity="Info" items | + +--- + +## Files Array + +Each file in the `files` array represents a processed source file: + +```json +{ + "sourceFile": "Pages/Product.aspx", + "outputFile": "Pages/Product.razor", + "fileType": "Page", + "status": "Success", + "linesAdded": 12, + "linesRemoved": 8, + "transformsApplied": [ + { + "name": "PageDirective", + "count": 1, + "description": "Converted <%@ Page %> to @page" + }, + { + "name": "ExpressionTransform", + "count": 5, + "description": "Converted <%: %> expressions to @()" + } + ], + "manualItems": [ + { + "line": 45, + "category": "bwfc-datasource", + "severity": "Warning", + "message": "DataSourceID='ProductSource' → wire Items binding and data service" + } + ] +} +``` + +### File Fields + +| Field | Type | Description | +|-------|------|-------------| +| `sourceFile` | string | Relative path in Web Forms project | +| `outputFile` | string | Relative path in Blazor project | +| `fileType` | string | "Page", "Control", "Master" | +| `status` | string | "Success", "Warning", "Error", "Skipped" | +| `linesAdded` | integer | New lines in output (includes TODO comments) | +| `linesRemoved` | integer | Lines removed during conversion | +| `transformsApplied` | array | List of transforms run on this file | +| `manualItems` | array | TODO comments with line numbers | + +--- + +## ManualItem Schema + +Each `manualItem` represents a TODO comment that requires manual follow-up: + +```json +{ + "file": "Pages/Product.aspx", + "line": 45, + "column": 0, + "category": "bwfc-datasource", + "severity": "Warning", + "message": "DataSourceID attribute detected — implement IProductDataService and wire Items binding", + "suggestion": "// TODO(bwfc-datasource): Replace DataSourceID with Items=\"@ProductList\" and create data service", + "relatedTransforms": ["DataSourceIdTransform", "SelectMethodTransform"] +} +``` + +### ManualItem Fields + +| Field | Type | Description | +|-------|------|-------------| +| `file` | string | Relative file path | +| `line` | integer | Line number in output file | +| `column` | integer | Column number (0-based) | +| `category` | string | TODO category slug (e.g., `bwfc-lifecycle`) | +| `severity` | string | "Info", "Warning", "Error" | +| `message` | string | Human-readable description | +| `suggestion` | string | Code suggestion or TODO template | +| `relatedTransforms` | array | Transforms that created this item | + +### Severity Levels + +| Severity | Meaning | Action | +|----------|---------|--------| +| **Error** | Blocking issue — project likely won't compile | Fix immediately | +| **Warning** | Code will compile but behavior may differ | Review before building | +| **Info** | Guidance comment — migrate at your pace | Reference during L2 automation | + +--- + +## Categories Summary + +The `categories` section groups manual items by TODO category: + +```json +{ + "categories": [ + { + "category": "bwfc-lifecycle", + "count": 8, + "severity": "Warning", + "files": [ + "Pages/Product.aspx", + "Pages/Checkout.aspx" + ], + "description": "Page lifecycle methods need conversion to Blazor component lifecycle" + }, + { + "category": "bwfc-datasource", + "count": 12, + "severity": "Info", + "files": [ + "Pages/Product.aspx", + "Pages/Search.aspx", + "Controls/ProductCard.ascx" + ], + "description": "Data binding patterns need to be replaced with component data services" + } + ] +} +``` + +### Category Summary Fields + +| Field | Type | Description | +|-------|------|-------------| +| `category` | string | TODO category slug | +| `count` | integer | Number of items in this category | +| `severity` | string | Highest severity in this category | +| `files` | array | Files affected (unique list) | +| `description` | string | What needs to be done | + +--- + +## Example Report Output + +### Minimal Success Report + +```json +{ + "migrationDate": "2026-04-02T14:30:00Z", + "sourceProject": "./MyApp.Web", + "outputProject": "./MyApp.Blazor", + "projectName": "MyApp.Blazor", + "toolVersion": "1.0.0", + "summary": { + "totalFiles": 5, + "filesProcessed": 5, + "filesFailed": 0, + "filesSkipped": 0, + "totalTransforms": 87, + "totalManualItems": 12, + "criticalIssues": 0, + "warnings": 3, + "infos": 9 + }, + "files": [ + { + "sourceFile": "Default.aspx", + "outputFile": "Pages/Index.razor", + "fileType": "Page", + "status": "Success", + "linesAdded": 3, + "linesRemoved": 2, + "transformsApplied": [ + {"name": "PageDirective", "count": 1}, + {"name": "ExpressionTransform", "count": 6}, + {"name": "AspPrefixTransform", "count": 4} + ], + "manualItems": [ + { + "line": 12, + "category": "bwfc-datasource", + "severity": "Info", + "message": "Review data binding pattern" + } + ] + } + ], + "categories": [ + { + "category": "bwfc-datasource", + "count": 4, + "severity": "Info", + "files": ["Default.aspx"] + } + ] +} +``` + +### Report with Errors + +```json +{ + "summary": { + "totalFiles": 10, + "filesProcessed": 8, + "filesFailed": 2, + "filesSkipped": 0, + "totalManualItems": 45, + "criticalIssues": 3, + "warnings": 15, + "infos": 27 + }, + "files": [ + { + "sourceFile": "Controls/CustomControl.ascx", + "outputFile": "Components/CustomControl.razor", + "fileType": "Control", + "status": "Error", + "statusMessage": "Parser error on line 34: unmatched closing tag", + "manualItems": [ + { + "line": 34, + "severity": "Error", + "category": "bwfc-general", + "message": "Malformed markup — review original file for syntax errors" + } + ] + } + ] +} +``` + +--- + +## How to Use the Report + +### 1. Review Critical Issues First + +```bash +# Filter for errors (blocks compilation) +jq '.manualItems[] | select(.severity == "Error")' migration-report.json + +# Count by category +jq 'group_by(.category) | map({category: .[0].category, count: length})' \ + <(jq '.manualItems[] | select(.severity == "Error")' migration-report.json) +``` + +### 2. Check Summary Statistics + +```bash +# Print summary +jq '.summary' migration-report.json +``` + +### 3. Find Work by Category + +```bash +# All lifecycle TODOs +jq '.manualItems[] | select(.category == "bwfc-lifecycle")' migration-report.json + +# All warnings grouped by category +jq 'group_by(.category) | map({category: .[0].category, count: length})' \ + <(jq '.manualItems[] | select(.severity == "Warning")' migration-report.json) +``` + +### 4. Build a Worklist + +```bash +# Export TODOs for a specific file +jq '.manualItems[] | select(.file == "Product.aspx")' migration-report.json | \ + jq -s 'sort_by(.line)' | \ + jq '.[] | "\(.line): [\(.severity)] \(.category) - \(.message)"' +``` + +### 5. Track Automation Progress + +```bash +# Before L2 automation +jq '.summary' migration-report-before.json + +# After L2 automation (lifecycle conversion) +jq '.summary' migration-report-after-lifecycle.json + +# Calculate improvement +``` + +--- + +## Report Interpretation Guide + +### What Errors Mean + +| Error | Cause | Resolution | +|-------|-------|-----------| +| `Parser error: unmatched closing tag` | Malformed markup in source | Review original `.aspx/.ascx` file for syntax errors | +| `File not found` | Referenced include or code-behind missing | Check project file references | +| `Unsupported directive` | Web Forms-specific directive without Blazor equivalent | Manual conversion needed | + +### What Warnings Mean + +| Warning | Meaning | Action | +|---------|---------|--------| +| `DataSourceID detected` | Needs data service wiring | Implement L2 datasource automation | +| `ViewState usage found` | State pattern conversion needed | Create component fields or parameters | +| `Complex IsPostBack guard` | Hard to unwrap automatically | Review unwrapped code, may need manual adjustment | + +### What Infos Mean + +| Info | Meaning | Action | +|------|---------|--------| +| `TODO comment inserted` | Guidance for developer | Review alongside code during L2 automation | +| `Pattern detected` | Recognized Web Forms pattern | L2 skill can automate this | + +--- + +## Example Workflow: Using the Report to Plan L2 Automation + +```bash +#!/bin/bash + +# 1. Run initial migration +webforms-to-blazor migrate --input ./MyApp.Web --output ./MyApp.Blazor + +# 2. Check what needs manual work +echo "=== CRITICAL ISSUES ===" +jq '.summary.criticalIssues' MyApp.Blazor/migration-report.json + +# 3. Fix errors first +if [ $(jq '.summary.criticalIssues' MyApp.Blazor/migration-report.json) -gt 0 ]; then + echo "Fixing critical issues..." + jq '.manualItems[] | select(.severity == "Error")' MyApp.Blazor/migration-report.json +fi + +# 4. Plan L2 passes by category count +echo "=== WORK BY CATEGORY ===" +jq '.categories | sort_by(-(.count)) | .[] | "\(.count) x \(.category)"' MyApp.Blazor/migration-report.json + +# 5. Run L2 for high-impact categories +# (Use Copilot L2 skills for each category) +copilot /webforms-migration --focus bwfc-lifecycle +copilot /webforms-migration --focus bwfc-datasource +copilot /webforms-migration --focus bwfc-validation + +# 6. Generate updated report +webforms-to-blazor migrate --input ./MyApp.Web --output ./MyApp.Blazor --dry-run > migration-report-updated.json +``` + +--- + +## Next Steps + +- **[TODO Categories](todo-conventions.md)** — Understand what each TODO category means +- **[Transform Reference](transforms.md)** — Learn what each transform does +- **[Back to CLI Overview](index.md)** — Return to main CLI documentation diff --git a/docs/cli/todo-conventions.md b/docs/cli/todo-conventions.md new file mode 100644 index 000000000..1c8449f79 --- /dev/null +++ b/docs/cli/todo-conventions.md @@ -0,0 +1,309 @@ +# TODO Categories & L2 Automation + +The CLI tool inserts standardized TODO comments throughout your code. Each TODO has a category slug in the format `TODO(bwfc-category)`. Copilot L2 skills use these slugs to automatically locate and convert patterns. + +## Category Reference + +### 1. **bwfc-general** +**Scope:** Miscellaneous Web Forms patterns without a more specific category +**Usage:** Catch-all for general migration guidance +**Example:** +```csharp +// TODO(bwfc-general): Event handlers (Button_Click, etc.) → convert to Blazor event callbacks +protected void SubmitButton_Click(object sender, EventArgs e) +{ + // handle click +} +``` + +**L2 Automation:** +- Convert event handler signatures from `(object, EventArgs)` to parameterless or typed callbacks +- Add `EventCallback` bindings where appropriate + +--- + +### 2. **bwfc-lifecycle** +**Scope:** Page lifecycle methods and initialization patterns +**Usage:** Marks `Page_Load`, `Page_Init`, `Page_PreRender`, etc. +**Example:** +```csharp +// TODO(bwfc-lifecycle): Page_Load / Page_Init → OnInitializedAsync / OnParametersSetAsync +protected void Page_Load(object sender, EventArgs e) +{ + LoadProductList(); +} + +// TODO(bwfc-lifecycle): Page_PreRender → OnAfterRenderAsync +protected void Page_PreRender(object sender, EventArgs e) +{ + UpdateStatus(); +} +``` + +**L2 Automation:** +- Convert `Page_Load` to `OnInitializedAsync` +- Convert `Page_Init` to component constructor or `OnInitializedAsync` +- Convert `Page_PreRender` to `OnAfterRenderAsync` +- Wrap async work in `try/catch` for error handling +- Add `StateHasChanged()` calls where needed + +--- + +### 3. **bwfc-ispostback** +**Scope:** IsPostBack detection and guard patterns +**Usage:** Marks `if (!IsPostBack)` or `if (IsPostBack == false)` guards +**Example:** +```csharp +// TODO(bwfc-ispostback): IsPostBack guard — review for Blazor +if (!IsPostBack) +{ + LoadInitialData(); +} +``` + +**L2 Automation:** +- Remove unnecessary `IsPostBack` checks (Blazor components initialize once) +- Extract postback-only logic into separate methods +- Convert event-driven patterns to component state management + +--- + +### 4. **bwfc-viewstate** +**Scope:** ViewState dictionary access +**Usage:** Marks `ViewState["key"]` patterns +**Example:** +```csharp +// TODO(bwfc-viewstate): ViewState usage → component [Parameter] or private fields +ViewState["CurrentPage"] = 1; +int page = (int)(ViewState["CurrentPage"] ?? 0); +``` + +**L2 Automation:** +- Replace `ViewState["key"]` with private component fields +- Convert persisted state to component `[Parameter]` or cascading parameters +- For complex state, suggest Scoped services or ProtectedSessionStorage + +--- + +### 5. **bwfc-session-state** +**Scope:** Session and Cache dictionary access +**Usage:** Marks `Session["key"]` and `Cache["key"]` patterns +**Example:** +```csharp +// --- Session State Migration --- +// TODO(bwfc-session-state): SessionShim auto-wired via [Inject] — Session["CartId"] calls compile against the shim's indexer. +// Session keys found: CartId +// Options: +// (1) ProtectedSessionStorage +// (2) Scoped service via DI +// (3) Cascading parameter from root-level state provider +string cartId = Session["CartId"]; +``` + +**L2 Automation:** +- Map identified Session keys to scoped services +- Create session state provider classes with typed properties +- Wire up ProtectedSessionStorage for critical session data +- Add guidance on distributed caching alternatives for Cache patterns + +--- + +### 6. **bwfc-navigation** +**Scope:** Navigation and redirection +**Usage:** Marks `Response.Redirect()`, `Server.Transfer()`, URL patterns +**Example:** +```csharp +// TODO(bwfc-navigation): Response.Redirect → NavigationManager.NavigateTo +Response.Redirect("~/checkout"); +``` + +**L2 Automation:** +- Convert `Response.Redirect()` to `NavigationManager.NavigateTo()` +- Convert `Server.Transfer()` to component navigation or redirect +- Clean up `~/` paths to absolute routes + +--- + +### 7. **bwfc-datasource** +**Scope:** Data binding and data source controls +**Usage:** Marks `DataBind()`, `DataSourceID`, `DataSource` properties, and data source controls +**Example:** +```csharp +// TODO(bwfc-datasource): Data binding (DataBind, DataSource) → component parameters or OnInitialized +// SQL/LINQ data source patterns → implement IDataService and wire Items binding +ProductGrid.DataBind(); + +// TODO(bwfc-datasource): Implement IProductsDataService to replace SqlDataSource + +``` + +**L2 Automation:** +- Remove `DataBind()` calls (Blazor re-renders automatically) +- Create `IProductsDataService` or similar DI service +- Replace `DataSourceID` with `Items="@ProductList"` binding +- Add `OnInitializedAsync` data loading logic + +--- + +### 8. **bwfc-identity** +**Scope:** Identity, authentication, and role-based access +**Usage:** Marks `LoginView`, `RoleGroups`, `Roles` attributes +**Example:** +```html + + + + + Admin panel + + + + + +@* TODO(bwfc-identity): Convert RoleGroups to policy-based AuthorizeView *@ + + Admin panel + +``` + +**L2 Automation:** +- Map role names to authorization policies +- Create `AuthorizationHandler` implementations for custom policies +- Generate policy registration code in `Program.cs` + +--- + +### 9. **bwfc-master-page** +**Scope:** Master page structure and layout +**Usage:** Marks master page conversions and head content extraction +**Example:** +```razor +@inherits LayoutComponentBase +@* TODO(bwfc-master-page): Review head content extraction for App.razor *@ + + @PageTitle + +
+ @Body +
+``` + +**L2 Automation:** +- Extract head content (meta tags, stylesheets) to `App.razor` +- Verify `@Body` placement in layout +- Check for complex script/style blocks requiring special handling + +--- + +### 10. **bwfc-routing** +**Scope:** URL routing and page routes +**Usage:** Marks `GetRouteUrl()`, route attribute patterns +**Example:** +```csharp +// TODO(bwfc-routing): Page.GetRouteUrl() → Use NavigationManager.GetUriByPage() or Router.TryResolveRoute() +string url = Page.GetRouteUrl("ProductRoute", new { id = 123 }); +``` + +**L2 Automation:** +- Replace `GetRouteUrl()` with `NavigationManager` routes +- Create strongly-typed route builders if needed +- Verify `@page` directives match Web Forms routing + +--- + +### 11. **bwfc-validation** +**Scope:** ASP.NET validation controls and patterns +**Usage:** Marks `RequiredFieldValidator`, `RegularExpressionValidator`, etc. +**Example:** +```html +@* TODO(bwfc-validation): ASP.NET validators → DataAnnotations + *@ + +``` + +**L2 Automation:** +- Convert validator declarations to `[Required]`, `[RegularExpression]`, etc. attributes +- Generate `` for display +- Add `` to form + +--- + +### 12. **bwfc-ajax** +**Scope:** ASP.NET AJAX UpdatePanel, ScriptManager, AJAX extenders +**Usage:** Marks UpdatePanel/ScriptManager patterns +**Example:** +```html +@* TODO(bwfc-ajax): UpdatePanel preserved as markup — remove code-behind UpdatePanel API calls; use StateHasChanged() instead *@ + + + + + +``` + +**L2 Automation:** +- Remove `UpdatePanel.Update()` calls (replace with `StateHasChanged()`) +- Remove `ScriptManager` references and script injection code +- Convert AJAX extenders to Blazor components (tooltips, modals, etc.) + +--- + +### 13. **bwfc-custom-control** +**Scope:** Custom Web Forms controls and user control conversion +**Usage:** Marks unrecognized or custom control conversions +**Example:** +```html +@* TODO(bwfc-custom-control): Custom control — map to Blazor component *@ + +``` + +**L2 Automation:** +- Scan for custom control declarations in Register directives +- Create Blazor component wrapper classes +- Migrate custom control markup and code-behind to Razor components + +--- + +## How L2 Automation Uses Categories + +**Copilot L2 skills scan your migrated code for TODO categories and:** + +1. **Group by category** — Find all `bwfc-datasource` patterns, all `bwfc-lifecycle` patterns, etc. +2. **Analyze context** — Use line numbers and surrounding code to understand each pattern +3. **Generate transforms** — Create targeted code changes for each category +4. **Apply iteratively** — Run multiple L2 passes for complex migrations (lifecycle → datasource → validation) + +**Example Workflow:** + +```bash +# After CLI migration +webforms-to-blazor migrate --input MyApp.Web --output MyApp.Blazor + +# Review TODO categories in generated code +grep -r "TODO(bwfc-" MyApp.Blazor + +# Use Copilot L2 for automated follow-up +copilot /webforms-migration --focus bwfc-lifecycle # Convert lifecycle methods +copilot /webforms-migration --focus bwfc-datasource # Wire data services +copilot /webforms-migration --focus bwfc-validation # Add DataAnnotations +``` + +## Best Practices + +### During Migration + +- **Don't remove TODO comments** — They guide L2 automation +- **Preserve category slugs** — If you move code, keep the TODO with it +- **Group related work** — TODO comments on consecutive lines can be batch-converted + +### After Migration + +1. **Review errors first** — Sort migration report by severity +2. **Fix blocking issues** — Master pages, routing, basic compilation +3. **Run L2 passes** — One category at a time (lifecycle, then datasource, etc.) +4. **Test between passes** — Ensure each automation step improves stability + +## Next Steps + +- **[Transform Reference](transforms.md)** — See details on each transform +- **[Migration Report](report.md)** — Learn to interpret the report +- **[Back to CLI Overview](index.md)** — Return to main documentation diff --git a/docs/cli/transforms.md b/docs/cli/transforms.md new file mode 100644 index 000000000..e9adede97 --- /dev/null +++ b/docs/cli/transforms.md @@ -0,0 +1,863 @@ +# Transform Reference + +This page documents all 33 transforms applied by the `webforms-to-blazor` CLI tool. Transforms are applied in a fixed sequence to ensure correct output. + +## Transform Execution Order + +| Order | Name | Type | Category | Purpose | +|-------|------|------|----------|---------| +| 10 | TodoHeader | Code-Behind | Meta | Inject TODO guidance header | +| 20 | PageDirective | Markup | Directive | Convert `<%@ Page %>` → `@page` | +| 30 | MasterDirective | Markup | Directive | Convert `<%@ Master %>` → Blazor layout | +| 40 | ControlDirective | Markup | Directive | Convert `<%@ Control %>` → `@inherits` | +| 50 | RegisterDirective | Markup | Directive | Handle `<%@ Register %>` for custom controls | +| 60 | ImportDirective | Markup | Directive | Convert `<%@ Import %>` → `@using` | +| 250 | MasterPageTransform | Markup | Markup | Convert `` → `@Body` | +| 300 | ContentWrapperTransform | Markup | Markup | Wrap loose content in `
` if needed | +| 310 | FormWrapperTransform | Markup | Markup | Convert `
` to Blazor form | +| 400 | ExpressionTransform | Markup | Markup | Convert `<%: %>`, `<%= %>` to `@()` | +| 510 | LoginViewTransform | Markup | Markup | Convert `` → `` | +| 520 | SelectMethodTransform | Markup | Markup | Flag SelectMethod/InsertMethod/etc. | +| 610 | AjaxToolkitPrefixTransform | Markup | Markup | Remove `ajaxToolkit:` prefixes | +| 620 | AspPrefixTransform | Markup | Markup | Remove `asp:` prefixes from controls | +| 700 | AttributeStripTransform | Markup | Markup | Remove `runat="server"`, `AutoEventWireup` | +| 750 | EventWiringTransform | Markup | Markup | Convert `OnClick="X"` → `OnClick="@X"` | +| 780 | UrlReferenceTransform | Markup | Markup | Convert `~/` paths to `/` | +| 800 | TemplatePlaceholderTransform | Markup | Markup | Convert `Item` → `context` in templates | +| 810 | AttributeNormalizeTransform | Markup | Markup | Normalize attribute values (booleans, enums) | +| 820 | DataSourceIdTransform | Markup | Markup | Replace DataSourceID with Items binding | +| 30 | GetRouteUrlTransform | Code-Behind | Code-Behind | Flag `Page.GetRouteUrl()` calls | +| 50 | GetRouteUrlTransform | Markup | Markup | Flag `<%: Page.GetRouteUrl() %>` expressions | +| 400 | SessionDetectTransform | Code-Behind | Code-Behind | Detect Session/Cache, inject shim references | +| 410 | ViewStateDetectTransform | Code-Behind | Code-Behind | Detect ViewState usage, flag migration | +| 500 | IsPostBackTransform | Code-Behind | Code-Behind | Unwrap `if (!IsPostBack)` guards | +| 510 | PageLifecycleTransform | Code-Behind | Code-Behind | Convert Page_Load, Page_Init → Blazor lifecycle | +| 520 | EventHandlerSignatureTransform | Code-Behind | Code-Behind | Adapt event handler signatures | +| 30 | BaseClassStripTransform | Code-Behind | Code-Behind | Remove `System.Web.UI.Page` base class | +| 20 | UsingStripTransform | Code-Behind | Code-Behind | Remove Web Forms and ASP.NET using statements | +| 25 | ResponseRedirectTransform | Code-Behind | Code-Behind | Convert `Response.Redirect()` → `NavigationManager.NavigateTo()` | +| 40 | DataBindTransform | Code-Behind | Code-Behind | Flag `DataBind()` calls | +| 50 | UrlCleanupTransform | Code-Behind | Code-Behind | Clean URL literals in code | + +--- + +## Directive Transforms + +### 1. PageDirective (Order: 20) + +**Converts ASP.NET Page directives to Blazor routes.** + +**Web Forms:** +```html +<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Product.aspx.cs" Inherits="MyApp.Product" %> +

Products

+``` + +**Output:** +```razor +@page "/product" +@inherits MyApp.Product + +

Products

+``` + +**What It Does:** +- Extracts `Inherits` attribute → `@inherits` +- Infers route from filename or `Url` attribute +- Removes boilerplate attributes (Language, AutoEventWireup, CodeBehind) +- Adds TODO if custom routing logic is detected + +--- + +### 2. MasterDirective (Order: 30) + +**Converts Master Page directives to Blazor layout components.** + +**Web Forms:** +```html +<%@ Master Language="C#" CodeBehind="Site.master.cs" Inherits="MyApp.Site" %> +``` + +**Output:** +```razor +@inherits LayoutComponentBase +
+ @Body +
+``` + +**What It Does:** +- Replaces `<%@ Master %>` with `@inherits LayoutComponentBase` +- Converts `` to `@Body` +- Strips `runat="server"` from head/form tags +- Adds TODO for complex head content extraction + +--- + +### 3. ControlDirective (Order: 40) + +**Converts User Control directives to Blazor component inheritance.** + +**Web Forms:** +```html +<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="MenuBar.ascx.cs" Inherits="MyApp.MenuBar" %> +``` + +**Output:** +```razor +@inherits MyApp.MenuBar + + +``` + +**What It Does:** +- Extracts `Inherits` attribute → `@inherits` +- Removes boilerplate attributes +- Preserves component markup + +--- + +### 4. RegisterDirective (Order: 50) + +**Handles custom control registration.** + +**Web Forms:** +```html +<%@ Register Namespace="MyCompany.Controls" Assembly="MyCompany.Web" TagPrefix="my" %> + +``` + +**Output:** +```razor +@* TODO(bwfc-general): Custom control — reference as Blazor component *@ + +``` + +**What It Does:** +- Removes Register directive (Blazor uses `@using`) +- Flags custom controls with TODO +- Allows developer to map to appropriate Blazor component + +--- + +### 5. ImportDirective (Order: 60) + +**Converts Import directives to Blazor usings.** + +**Web Forms:** +```html +<%@ Import Namespace="System.Collections.Generic" %> +<%@ Import Namespace="MyApp.Services" %> +``` + +**Output:** +```razor +@using System.Collections.Generic +@using MyApp.Services +``` + +**What It Does:** +- Direct conversion to `@using` +- Preserves namespace imports +- Placed at top of component + +--- + +## Markup Transforms + +### 6. MasterPageTransform (Order: 250) + +**Converts master page layout elements to Blazor.** + +**Details:** +- Replaces `` blocks with `@Body` +- Strips `runat="server"` from `` and `` tags +- Injects `@inherits LayoutComponentBase` +- Adds TODO comment for head content review + +**Example:** +```html + + + Site Master + + + + Default content + + + + +@inherits LayoutComponentBase +@* TODO(bwfc-master-page): Review head content extraction for App.razor *@ + + + Site Master + +
+ @Body +
+``` + +--- + +### 7. ContentWrapperTransform (Order: 300) + +**Wraps loose content in a div if necessary.** + +**Purpose:** Blazor requires a single root element. Wraps text nodes and mixed content. + +--- + +### 8. FormWrapperTransform (Order: 310) + +**Converts Web Forms form tags to Blazor EditForm or plain HTML form.** + +**Web Forms:** +```html +
+ + + +``` + +**Output (with EditContext):** +```razor + + + +
+``` + +--- + +### 14. AttributeStripTransform (Order: 700) + +**Removes Web Forms-specific attributes.** + +Removes: +- `runat="server"` +- `AutoEventWireup="true|false"` +- `EnableEventValidation="true|false"` +- `ViewStateMode="Enabled|Disabled|Inherit"` + +**Example:** +```html + + + + + +``` + +--- + +### 15. EventWiringTransform (Order: 750) + +**Converts Web Forms event handler syntax to Blazor.** + +**Web Forms:** +```html + + +``` + +**Output:** +```razor +