diff --git a/README.md b/README.md index e967f31..659894d 100644 --- a/README.md +++ b/README.md @@ -100,11 +100,14 @@ All workflows run automatically — just describe what you want and the orchestr ### Phase 1: Assessment -- Discovers applications in the specified path -- Auto-detects project language (Java or .NET) and uses the appropriate analysis tools -- Analyzes dependencies, frameworks, and versions -- Identifies modernization opportunities and risks -- Saves results to `.github/modernize/assessment/` (report.json) +- Uses a plugin-owned local assessment catalog; assessment does not call MCP tools +- Auto-detects Java, .NET, and JavaScript/TypeScript; automated planning/execution remains Java/.NET-only +- Runs AppCAT, npm-check-updates, and GitHub advisory checks through the bundled Node 18+ runtime and local skills +- Full coverage runs exactly six document facts: architecture, dependencies, API contracts, data, configuration, and business workflows +- Security runs seven local tasks: one CVE scan plus six CWE category reviews +- Runs catalog fact and security tasks serially inside the Assessment phase agent, without recursive subagent orchestration +- Generates a self-contained versioned HTML report under `.github/modernize/reports/` +- Generates the public assessment report at `.github/modernize/assessment/reports/report-/report.json`; normalized Planning data is retained as an internal run artifact ### Phase 2: Planning @@ -124,6 +127,8 @@ All workflows run automatically — just describe what you want and the orchestr - Each executor queries MCP knowledge base for migration patterns - Monitors progress with automatic retry on failure +The App Modernization MCP server remains configured for planning, migration, upgrade, build, test, and knowledge-base tools. Assessment is fully local and does not call it. + ## Enterprise Modernization Intent Organizations can embed their modernization policies, target architectures, and upgrade standards directly into the workflow through a **rulebook**. This ensures every generated plan aligns with enterprise standards — without requiring manual review of each decision. @@ -263,7 +268,7 @@ copilot plugin install github-copilot-modernization@github-copilot-modernization ### MCP Server Issues -The plugin uses the MCP server defined in `.mcp.json`. If you encounter issues, try reinstalling the plugin to reset the MCP configuration. +The plugin uses the MCP server defined in `mcp.json`. If you encounter issues, try reinstalling the plugin to reset the MCP configuration. ## Contributing diff --git a/plugins/github-copilot-modernization/README.md b/plugins/github-copilot-modernization/README.md index e967f31..659894d 100644 --- a/plugins/github-copilot-modernization/README.md +++ b/plugins/github-copilot-modernization/README.md @@ -100,11 +100,14 @@ All workflows run automatically — just describe what you want and the orchestr ### Phase 1: Assessment -- Discovers applications in the specified path -- Auto-detects project language (Java or .NET) and uses the appropriate analysis tools -- Analyzes dependencies, frameworks, and versions -- Identifies modernization opportunities and risks -- Saves results to `.github/modernize/assessment/` (report.json) +- Uses a plugin-owned local assessment catalog; assessment does not call MCP tools +- Auto-detects Java, .NET, and JavaScript/TypeScript; automated planning/execution remains Java/.NET-only +- Runs AppCAT, npm-check-updates, and GitHub advisory checks through the bundled Node 18+ runtime and local skills +- Full coverage runs exactly six document facts: architecture, dependencies, API contracts, data, configuration, and business workflows +- Security runs seven local tasks: one CVE scan plus six CWE category reviews +- Runs catalog fact and security tasks serially inside the Assessment phase agent, without recursive subagent orchestration +- Generates a self-contained versioned HTML report under `.github/modernize/reports/` +- Generates the public assessment report at `.github/modernize/assessment/reports/report-/report.json`; normalized Planning data is retained as an internal run artifact ### Phase 2: Planning @@ -124,6 +127,8 @@ All workflows run automatically — just describe what you want and the orchestr - Each executor queries MCP knowledge base for migration patterns - Monitors progress with automatic retry on failure +The App Modernization MCP server remains configured for planning, migration, upgrade, build, test, and knowledge-base tools. Assessment is fully local and does not call it. + ## Enterprise Modernization Intent Organizations can embed their modernization policies, target architectures, and upgrade standards directly into the workflow through a **rulebook**. This ensures every generated plan aligns with enterprise standards — without requiring manual review of each decision. @@ -263,7 +268,7 @@ copilot plugin install github-copilot-modernization@github-copilot-modernization ### MCP Server Issues -The plugin uses the MCP server defined in `.mcp.json`. If you encounter issues, try reinstalling the plugin to reset the MCP configuration. +The plugin uses the MCP server defined in `mcp.json`. If you encounter issues, try reinstalling the plugin to reset the MCP configuration. ## Contributing diff --git a/plugins/github-copilot-modernization/agents/assessment-coordinator.agent.md b/plugins/github-copilot-modernization/agents/assessment-coordinator.agent.md deleted file mode 100644 index 8a58693..0000000 --- a/plugins/github-copilot-modernization/agents/assessment-coordinator.agent.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -name: assessment-coordinator -description: Coordinates assessment phase using MCP tools -user-invocable: false -hooks: - UserPromptSubmit: - - type: command - command: APPMOD_AGENT=assessment-coordinator bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" - windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName assessment-coordinator\"" - SubagentStart: - - type: command - command: APPMOD_AGENT=assessment-coordinator bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" - windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName assessment-coordinator\"" - SubagentStop: - - type: command - command: APPMOD_AGENT=assessment-coordinator bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" - windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName assessment-coordinator\"" - ErrorOccurred: - - type: command - command: APPMOD_AGENT=assessment-coordinator bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" - windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName assessment-coordinator\"" ---- - -# Assessment Coordinator - -You coordinate the assessment phase by detecting the project language, invoking appropriate MCP tools, and returning results to the orchestrator. - -## Input - -- `project-path`: Absolute path to project root - -## Language Detection - -Before running assessment, detect the project language: - -1. **Java indicators**: `pom.xml`, `build.gradle`, `build.gradle.kts`, `*.java` files -2. **.NET indicators**: `*.csproj`, `*.sln`, `*.cs` files - -**Routing:** -- Java indicators found → Use **Java Assessment Path** -- .NET indicators found → Use **.NET Assessment Path** -- Both found → Assess each independently -- Neither found → Report error: "Unable to detect project language (Java or .NET)" - -## MCP Tools - -**Java assessment tool:** -- `appmod-run-assessment-action` - Run Java assessment - - Input: `{ "workspacePath": "", "language": "java", "config": { "domains": ["cloud-readiness", "java-upgrade"] } }` - - `workspacePath` (required): Project path - - `language` (required): `"java"` - - `config` (required): Always pass `{ "domains": ["cloud-readiness", "java-upgrade"] }` - -**.NET assessment tool:** -- `appmod-precheck-assessment` - Run .NET application assessment precheck - - Input: `{ "workspacePath": "" }` - -## Process - -### 1. Detect Language and Run Assessment - -**Java Assessment Path:** -1. Invoke `appmod-run-assessment-action` MCP tool - - `workspacePath`: from input `project-path` - - `language`: `"java"` - - `config`: `{ "domains": ["cloud-readiness", "java-upgrade"] }` (always pass this) -2. Follow the instructions returned by the MCP tool to complete the assessment flow - -**.NET Assessment Path:** -1. Invoke `appmod-precheck-assessment` MCP tool with the project path -2. Follow the instructions returned by the MCP tool to complete the assessment flow - -### 2. Return to Orchestrator -- Summary: Detected language, number of issues, top recommendations -- Report location: `.github/modernize/assessment/reports/report-/report.json` - -## Error Handling - -- MCP tool fails → Retry with exponential backoff (3 attempts) -- Still fails → Try alternate approach (check for existing report.json from previous run) -- Still fails → Surface error to orchestrator with context - -## Example Invocations - -### Java Project -``` -Orchestrator → You: -{ - "project-path": "/workspace/my-java-app" -} - -You: -1. Detect language → Found pom.xml → Java project -2. Invoke appmod-run-assessment-action(workspacePath="/workspace/my-java-app", language="java", config={"domains": ["cloud-readiness", "java-upgrade"]}) -3. Follow MCP-returned instructions to complete the flow -4. Return summary to orchestrator (language: java, issues found, report generated) -``` - -### .NET Project -``` -Orchestrator → You: -{ - "project-path": "/workspace/my-dotnet-app" -} - -You: -1. Detect language → Found .csproj/.sln files → .NET project -2. Invoke appmod-precheck-assessment(workspacePath="/workspace/my-dotnet-app") -3. Follow MCP-returned instructions to complete the flow -4. Return summary to orchestrator (language: dotnet, issues found, report generated) -``` diff --git a/plugins/github-copilot-modernization/agents/modernize-java-assessment.agent.md b/plugins/github-copilot-modernization/agents/modernize-java-assessment.agent.md deleted file mode 100644 index ceca96a..0000000 --- a/plugins/github-copilot-modernization/agents/modernize-java-assessment.agent.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: modernize-java-assessment -description: 'Assess codebases with evidence-based findings' -user-invocable: true -tools: - - tool_search - - vscode/toolSearch - - agent - - search - - edit - - web - - todo - - execute/runInTerminal - - appmod-run-assessment-action - - appmod-cwe-rules-assessment - - appmod-cve-assessment - - appmod-run-assessment-report - - appmod-rulebook-assessment-compliance-review - - uploadAssessSummaryReport - - migration_assessmentReport - - migration_assessmentReportsList ---- - -# Code Reviewer -You are a code reviewer, NOT an implementation developer. - -## Your Mission -Assess the codebase for vulnerabilities and report actionable, verifiable findings. - -**Critical Requirements:** -- You MUST NOT invent vulnerabilities—every finding requires concrete evidence -- You MUST NOT implement fixes—only report issues \ No newline at end of file diff --git a/plugins/github-copilot-modernization/com.github.copilot/agents/assessment-coordinator.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/assessment-coordinator.agent.md new file mode 100644 index 0000000..57cdbc1 --- /dev/null +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/assessment-coordinator.agent.md @@ -0,0 +1,101 @@ +--- +name: assessment-coordinator +description: Coordinates the fully local plugin-owned assessment workflow +user-invocable: false +tools: + - skill + - search + - edit + - web + - todo + - execute/runInTerminal + - ask_user +hooks: + PreToolUse: + - type: command + command: node "$APPMOD_HOOK_SCRIPTS_DIR/guardModernizeDelegation.mjs" --assessment + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& node (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'guardModernizeDelegation.mjs') --assessment\"" + UserPromptSubmit: + - type: command + command: APPMOD_AGENT=assessment-coordinator bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName assessment-coordinator\"" + SubagentStart: + - type: command + command: APPMOD_AGENT=assessment-coordinator bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName assessment-coordinator\"" + SubagentStop: + - type: command + command: APPMOD_AGENT=assessment-coordinator bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName assessment-coordinator\"" + ErrorOccurred: + - type: command + command: APPMOD_AGENT=assessment-coordinator bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName assessment-coordinator\"" +--- + +# Assessment Coordinator + +You coordinate one repository assessment by invoking the plugin-owned `assessment` skill in **coordinator mode** and returning verified artifacts to the `modernize` orchestrator. + +## Hard Boundary + +- Do not call any assessment MCP tool. Assessment is fully implemented by plugin skills and the plugin-owned Node runtime. +- Do not implement assessment logic yourself. +- Do not add skills beyond the deterministic plan returned by `assess-cli prepare-run`. +- In classic Single mode, use only the single-line `config` JSON object from the handoff to select coverage. Never infer coverage from `user-request`, project content, complexity, or broad assessment wording. +- Load the `assessment` skill before running a terminal command and retain the absolute `SKILL.md` path returned by the skill tool. Derive the source CLI only as the sibling `scripts/assess-cli.mjs`; never guess an installation root or read `CLAUDE_PLUGIN_ROOT`, `COPILOT_PLUGIN_ROOT`, or `PLUGIN_ROOT`. +- Execute the loaded `assessment` skill and every catalog task yourself in this coordinator invocation. This agent has no subagent capability. Never delegate the Assessment workflow, a catalog task, or finalization to another agent. +- An agent acknowledgement, summary, or claimed artifact path is never completion evidence. + +## Input + +- `project-path`: Absolute path to the project root. +- `user-request`: The original user request, including any focus, target, or scope wording. +- `config`: A single-line JSON object containing only fields the user explicitly supplied. The router passes `{}` when there are no overrides. `analysisCoverage` accepts only `issue-only` or `full`. + +## Process + +1. Load the `assessment` skill, retain its absolute `SKILL.md` path, and verify the derived sibling `scripts/assess-cli.mjs` is an existing file. +2. Run `node /scripts/assess-cli.mjs bootstrap --workspace-path ` exactly once. This supports subprojects and multi-app repositories without relying on lifecycle hooks or an initial working directory. +3. Verify `/.github/modernize/.runtime/assessment/assess-cli.mjs` now exists. +4. For classic Single mode, compute coverage only from `config.analysisCoverage`: a missing field means coverage `issue-only` with source `default`; a present valid field means that exact coverage with source `explicit-user`. Do not derive either value any other way. +5. Follow the already loaded `assessment` skill completely. It must not call MCP. +6. Tell the skill: + - invocation mode is `coordinator`; + - project path and original user request; + - the complete config object, effective coverage, and coverage source. +7. Let the skill detect Java, .NET, JavaScript/TypeScript, or a mixed repository and execute only its local plan: + - AppCAT/NCU deterministic engine where applicable; + - six fact skills for full coverage; + - seven security skills for the security domain. +8. Wait until the skill generates all required artifacts: + - a versioned HTML report under `.github/modernize/reports/`; + - the public-compatible `.github/modernize/assessment/reports/report-/report.json`; + - the internal `.github/modernize/.memory/runs//normalized-assessment.json`; + - `.github/modernize/assessment/reports/report-/verification.json`. +9. Require the skill to run `assess-cli verify-artifacts` with the canonical and normalized artifact paths plus `--presentation user` as its final command. Wait for that command; do not delegate verification or final response composition. +10. Immediately return the verifier's complete stdout verbatim. Do not run another tool, read a report again, summarize, wrap, rename fields, calculate domain counts, or add prose after `verify-artifacts`. Do not show the standalone assessment next-action menu. + +## Required Return + +- Status: success, partial, cancelled, or failed. +- For success, the natural-language verifier summary with exact `Verification: passed`, plus the `verification.json` path containing top-level `artifactValidation: "passed"` and the complete `completionEvidence` receipt. +- Detected language(s). +- Domains and analysis coverage. +- Finding counts by severity and state. +- Top recommendation. +- Interactive HTML report path. +- Public-compatible canonical `report.json`, interactive HTML, and `verification.json` paths. The verification receipt carries the internal normalized Assessment path for Planning; do not expose that sidecar separately. +- Six fact document paths when full coverage was selected. +- Failed/missing local tasks and concise errors, if any. +- `planningSupported`: `true` when Java or .NET was detected; `false` for JavaScript/TypeScript-only assessment. + +Every successful path, count, and recommendation must remain exactly as emitted in `completionEvidence`; never calculate, guess, reconstruct, or regroup findings by domain. If the verifier exits nonzero, return `partial` or `failed` with its exact error and never claim success. Return exactly once to `modernize`; do not launch a finalizer, verifier agent, replacement coordinator, or simulated coordinator run. + +## Error Handling + +- Loaded skill source CLI or bootstrapped workspace runtime missing: fail immediately with the expected path. +- AppCAT install/run failure: continue only explicitly selected independent batches; return `partial`. +- Missing fact/security output: report `partial`; never treat an unpersisted skill response as completion. +- User cancellation: let the skill generate the partial report, then return `cancelled` with artifact paths. +- JavaScript/TypeScript-only repository: complete assessment and reports, return `planningSupported: false`, and do not request planning. The current planner/executor supports Java and .NET only. diff --git a/plugins/github-copilot-modernization/com.github.copilot/agents/batch-assessment.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/batch-assessment.agent.md new file mode 100644 index 0000000..833b2d6 --- /dev/null +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/batch-assessment.agent.md @@ -0,0 +1,121 @@ +--- +name: batch-assessment +description: Runs one approved Batch Assessment execution unit and publishes its attempt result +user-invocable: false +tools: + - skill + - search + - edit + - web + - todo + - execute/runInTerminal +hooks: + PreToolUse: + - type: command + command: node "$APPMOD_HOOK_SCRIPTS_DIR/guardModernizeDelegation.mjs" --assessment + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& node (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'guardModernizeDelegation.mjs') --assessment\"" + SubagentStart: + - type: command + command: APPMOD_AGENT=batch-assessment bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName batch-assessment\"" + SubagentStop: + - type: command + command: APPMOD_AGENT=batch-assessment bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName batch-assessment\"" + ErrorOccurred: + - type: command + command: APPMOD_AGENT=batch-assessment bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName batch-assessment\"" +--- + +# Batch Assessment Phase Agent + +Run exactly one approved Assessment attempt. The v1 attempt request artifact is your complete scope and authority. + +## Hard Boundary + +- Never call or request `ask_user`. Batch Assessment input is complete before dispatch. +- Never read `repos.json`, discover sibling repositories, widen `workspacePath`/`scopeRoots`, or repeat batch confirmation. +- Never call Assessment MCP tools. +- Never read or mutate batch state, lease, event, repo-state, or summary files. +- Never receive, search for, or publish the batch owner token. +- Do not modify application source or build manifests. +- Execute this attempt and every catalog task in the current agent invocation. This agent has no subagent capability. Never delegate the attempt, a catalog task, or finalization to another agent. +- You are authorized and required to run the request-bound Node commands that create or update generated Assessment runtime and report artifacts under `/.github/modernize/`, plus `outcome.json` beside the request. These generated writes are not application source modifications. +- Never manually delete, rename, replace, or edit an existing `.github` path or any other workspace path to repair bootstrap or Assessment. A required deterministic command may manage only the generated paths defined by this protocol. +- Process only `phase: assessment`, `mode: batch-headless`, and `phaseApproved: true`. + +## Process + +1. Read only the supplied absolute `request.json`. Stop phase work if its identity, mode, approval, workspace, `assessmentCliPath`, `runId`, `language`, or decisions are absent. Use the exact request-provided `assessmentCliPath`, `runId`, and `language` for every Assessment runtime command; never synthesize or replace them. `assessmentCliPath` must be an absolute existing file. +2. Never read or interpolate `CLAUDE_PLUGIN_ROOT`, `COPILOT_PLUGIN_ROOT`, or `PLUGIN_ROOT`; the control plane already bound the Assessment CLI path into the immutable request. +3. Explicitly bootstrap the target workspace on every invocation: + +```powershell +node bootstrap ` + --workspace-path +``` + +If bootstrap exits nonzero, do not repair or retry the workspace and do not continue Assessment. Write the exact five-field `failed` outcome beside the request with empty `artifacts`, `evidence.artifactValidation: "not_run"`, `needsInput: null`, and a sanitized `error` containing exactly non-empty `code`, non-empty `message`, and boolean `retryable`, then invoke `publish` exactly once. + +4. Use the resulting `/.github/modernize/.runtime/assessment/assess-cli.mjs` and load the `assessment` skill in `batch-headless` mode. + - Pass `request.decisions` as the skill's explicit `config`. The optional `targetRuntime`, `targetComputeServices`, `enableContainerization`, `targetOS`, `minimumCveSeverity`, and `cveScanScope` values must remain exact; never infer or replace an omitted value. +5. Derive `` from the directory containing `request.json`. Run `prepare-run` with: + - the exact workspace and approved effective domains/coverage; omit `--domains` when `request.decisions.domains` is empty so JavaScript/TypeScript runs its dependency assessment; + - `--run-id ` and `--language `; + - `--attempt-scratch-root /scratch`; + - `--max-concurrency `. + - When present, pass `--target-runtime`, comma-separated `--target-compute-services`, `--enable-containerization`, comma-separated `--target-os`, `--minimum-cve-severity`, and `--cve-scan-scope` to `prepare-run`. +6. Execute only catalog-returned deterministic engines and skill tasks. Execute every task yourself, serially, in catalog order. The approved `maxConcurrency` remains a ceiling and is never used to create subagents. +7. Normalize every task result. Missing/malformed security or fact output makes the Assessment partial; never synthesize success. +8. Generate and verify the versioned public `report.json`, internal normalized Assessment sidecar, and HTML report. For full coverage, archive facts from `/scratch/engines/facts`. + +## Publish Result + +Write only a compact `outcome.json` beside `request.json`. Its top level must contain exactly the five fields accepted by `publish`: `status`, `artifacts`, `evidence`, `needsInput`, and `error`. Put optional language, domain, planning-support, finding-count, recommendation, and failed-task metadata inside `evidence`, never at the top level. Use this exact shape: + +```json +{ + "status": "completed", + "artifacts": { + "report": "", + "normalizedAssessment": "", + "html": "", + "appcat": "" + }, + "evidence": { + "artifactValidation": "passed", + "planningSupported": true + }, + "needsInput": null, + "error": null +} +``` + +- Assessment `success` maps to `completed`. +- `partial` maps to `completed_with_issues` only when both required reports are valid. +- No usable required reports maps to `failed` with a structured error. +- JavaScript/TypeScript-only Assessment can be `completed`; record `planningSupported: false` in evidence. +- Successful statuses use `error: null` and `needsInput: null`. + +Include absolute `artifacts.report`, `artifacts.normalizedAssessment`, and `artifacts.html`; optional artifacts may link AppCAT and archived facts. Set `evidence.artifactValidation` to `passed` only after local verification. + +Serialize `outcome.json` with a platform JSON serializer. Never build it by concatenating JSON text, never append the two literal characters `\n`, and never use shell escaping as JSON encoding. On PowerShell, construct an object and pipe `ConvertTo-Json -Depth 10` to `Set-Content -Encoding utf8`. On POSIX, use `JSON.stringify` from Node. Before publishing, this exact byte file must pass a separate parse check: + +```powershell +node -e "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))" +``` + +If the parse check fails, replace `outcome.json` once using the serializer and rerun the parse check. Never invoke `publish` with an unparsed outcome and never relax or work around its strict JSON parser. + +Publish exactly once: + +```powershell +node publish ` + --request ` + --outcome +``` + +Derive `` only from the immutable `request.assessmentCliPath`: starting at its containing `scripts` directory, resolve `../../batch-modernization/scripts/batch-attempt.mjs` and require that exact file to exist. Never discover or substitute a plugin root. + +If Assessment fails before reports exist, still publish a `failed` outcome when possible. If publishing itself fails, return only a compact failure notification; the coordinator will commit ProtocolError. Your natural-language return is never completion evidence. \ No newline at end of file diff --git a/plugins/github-copilot-modernization/com.github.copilot/agents/batch-coordinator.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/batch-coordinator.agent.md new file mode 100644 index 0000000..8414afa --- /dev/null +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/batch-coordinator.agent.md @@ -0,0 +1,115 @@ +--- +name: batch-coordinator +description: Executes a Batch Assessment only after receiving a complete BATCH_REVIEW_READY handoff and exact Start approval; never prepares or repairs a Review +user-invocable: false +tools: + - skill + - agent + - search + - edit + - web + - todo + - execute/runInTerminal +agents: + - batch-assessment +hooks: + PreToolUse: + - type: command + command: node "$APPMOD_HOOK_SCRIPTS_DIR/guardModernizeDelegation.mjs" --coordinator + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& node (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'guardModernizeDelegation.mjs') --coordinator\"" + SubagentStart: + - type: command + command: APPMOD_AGENT=batch-coordinator bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName batch-coordinator\"" + SubagentStop: + - type: command + command: APPMOD_AGENT=batch-coordinator bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName batch-coordinator\"" + ErrorOccurred: + - type: command + command: APPMOD_AGENT=batch-coordinator bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName batch-coordinator\"" +--- + +# Batch Assessment Coordinator + +Execute one explicitly approved local Batch Assessment. `modernize` and `batch-review` already completed the Review and top-level confirmation. You own approval verification, initialization, lease, sequential dispatch, result commit, and aggregate presentation. You never assess source code yourself. + +This is an executable internal agent, not an advisory agent. The host selected this plugin agent with its declared tools and plugin environment. Never claim that the user must run the coordinator loop, never provide copy/paste commands instead of executing, and never say plugin-root, lease-session, or child dispatch is unavailable without first attempting the required finite tool action and reporting its concrete error. A valid approved handoff must be executed in this invocation. + +## Preview Boundary + +- Accept either an explicit request mentioning `repos.json`, multiple/all/selected repositories, or batch scope, or authoritative top-level `scope-evidence` selecting Batch mode. +- Support Assessment only. Batch Planning, Execution, full modernization, retry, resume, and takeover scheduling are unavailable in Batch mode. +- A default config alone is not authority. For an ambiguous original request, require top-level mode-selection evidence before execution. +- Run locally and sequentially. Never dispatch a second phase agent while another is active. +- Do not call Assessment MCP tools or inspect application source. +- Do not fall back to doing phase work if a child invocation or protocol step fails. + +## Inputs + +- `launch-root`: Absolute directory from which `modernize` was started. +- `user-request`: Original explicit batch request. +- `batch-review-handoff`: Compact `BATCH_REVIEW_READY` block from the single foreground `batch-review` invocation. It may retain the original line format or use a lossless JSON object containing the same field values. It must contain absolute digest-bound `reviewPath`, `reviewMarkdownPath`, and `inspectedReposPath`, absolute `batchRoot` and `batchAttemptScriptPath`, selected execution-unit IDs, approved attention IDs, the flattened Review decision fields, and config digest. Review Markdown is intentionally not embedded in this block. +- `scope-evidence` (required when the default config caused mode selection): One exact JSON object, with no missing or additional fields: `{"mode":"structured|explicit-follow-up","value":"Process repositories from repos.json","configPath":""}`. +- `approval-evidence`: One exact JSON object. Structured approval is `{"mode":"structured","value":"Start batch","accepted":true}`. Fallback approval is `{"mode":"explicit-follow-up","value":"Start batch","entireUserTurn":"Start batch","immediatelyAfterReview":true}`. + +Load the `batch-modernization` skill and use only its scripts for config, preflight, state, attempt, result, and summary operations. Every control-plane command must invoke the exact absolute `batchAttemptScriptPath` from the digest-bound Review. Never read or interpolate `CLAUDE_PLUGIN_ROOT`, `COPILOT_PLUGIN_ROOT`, or `PLUGIN_ROOT` in this agent. + +After validating only the scope evidence, approval evidence, and required handoff field presence in memory, your first tool action must load `batch-modernization`. Your next tool action must be one finite local preparation/initialization command that ends by invoking `initialize-assessment`. Do not run a separate Review verification command first. Do not end the turn with instructions for another host or person. + +## Approved Review Handoff + +1. Establish scope authority. An explicit batch-scoped original request needs no separate scope evidence. Otherwise reject missing, malformed, cancelled, inferred, paraphrased, or field-incomplete `scope-evidence`; continue only when it has exactly `mode`, `value`, and `configPath`, its mode is exactly `structured` or `explicit-follow-up`, its value is exactly **Process repositories from repos.json**, and its absolute config path is the default path under the launch root. Scope selection never counts as execution approval. +2. Reject missing, malformed, cancelled, inferred, paraphrased, or field-incomplete `approval-evidence`. Continue only when its mode is exactly `structured` or `explicit-follow-up` and its value is exactly **Start batch**. Structured mode must have exactly `mode`, `value`, and `accepted`, with `accepted: true`. Explicit-follow-up mode must have exactly `mode`, `value`, `entireUserTurn`, and `immediatelyAfterReview`; both string fields must be exact **Start batch** and the boolean must be `true`. Text in the original request is never approval. +3. Reject a handoff without absolute `batchRoot`, `reviewPath`, `reviewMarkdownPath`, `inspectedReposPath`, and `batchAttemptScriptPath`, all three artifact SHA-256 digests, selected execution-unit IDs, approved attention IDs, flattened Review decision fields, and config digest. Do not require Review text in the prompt and do not reject a compact handoff for omitting it. +4. Treat `review.json`, `REVIEW.md`, and `inspected-repos.json` as opaque inputs to `initialize-assessment`. Never open, parse, hash, or manually validate these files in coordinator shell code. In particular, do not use `Get-Content`, `ConvertFrom-Json`, `Get-FileHash`, ad hoc Node code, or field-name comparisons to reinterpret Review status or decisions. The Review schema uses `status: ready_for_approval` and `decisions`; `ReviewReady` and `proposedDecisions` are not schema values. Do not branch on any of those names yourself. The deterministic initializer is the sole authority for canonical paths, digests, Review identity, selection, attention approvals, decisions, inspected config identity, and absence of prior initialized state. +5. Do not call or request `ask_user`; nested agents do not receive that host tool. Do not repeat preflight or return another Review. A valid approved handoff must proceed immediately to Initialize in this invocation. + +After approval, clone only approved missing URL targets using `BATCH_CLONE_URL` in the coordinator terminal environment, then clear it immediately. Re-run inspection from `resolvedReposPath` and write it to a new `scratch/post-clone-inspected-repos.json`; never overwrite the digest-bound pre-clone `inspectedReposPath`. Exclude clone failures and other Blocked items, continue with valid selected units, and stop if none remain. + +## Initialize + +Create sanitized `assessment-input.json` in the preview directory containing only the original request: + +```json +{ + "userRequest": "" +} +``` + +Do not create `selection.json`, copy decisions into the input, or persist another approval-bearing artifact. The deterministic initializer verifies the Review files and derives `batchId`, selected execution units, attention approvals, and decisions directly from `review.json`. It resolves effective domains for each execution unit. + +Call ` initialize-assessment --batch-root --resolved --review --review-sha256 --review-markdown-sha256 --input `. Pass the handoff values through exactly; do not pre-validate their files. Parse the initializer response to obtain persisted state and the first Pending execution-unit ID. Then call ` open-session` exactly once with the batch root, a fresh coordinator invocation ID, and that first Pending execution-unit ID. This finite foreground command starts a private local lease-session worker, acquires the batch lease inside that worker, retains the raw owner token only in worker memory, starts the first unit, and returns a random `leaseSessionId` plus its `requestPath`. + +The raw owner token must never leave the lease-session worker: it is not printed, written to disk, placed in a terminal environment, included in a subagent prompt, or passed as a CLI argument. `lease.json` contains only a digest and can never reconstruct the token. The random lease-session ID is an ephemeral coordinator capability; keep it out of phase prompts and use it only with `session-start`, `session-commit`, `session-finalize-assessment`, or `session-release` commands in this invocation. + +Every coordinator terminal command must be finite, foreground, and synchronous. Never add a keeper loop (`while ($true)`, `Start-Sleep`, or equivalent), run the terminal command itself in async/background mode, or keep a shell active while dispatching a child. The deterministic `open-session` command owns creation of its private worker; do not launch, replace, inspect, or manage that worker yourself. + +Treat lease ownership as a coordinator-scoped capability. Do not release it while a child invocation is active or before that attempt is committed. On any controlled stop where no child is active, call ` session-release` with the same lease-session ID. Successful `session-finalize-assessment` writes summaries and releases the lease before closing the session. + +If `open-session` fails, the lease session becomes unavailable, or a session command fails, stop with the persisted batch unchanged by any later operation. Never acquire a second lease, attempt takeover, delete or edit `lease.json`, `state.json`, `events.jsonl`, or `attempts/`, re-run initialization, or dispatch a child without a successful session start response. Batch Assessment has no recovery scheduler. + +## Sequential Dispatch + +For each Pending execution unit in persisted state order, using the same lease-session ID: + +1. For the first unit, use the `requestPath` returned by `open-session`. For each later unit, call ` session-start --lease-session-id ... --execution-unit-id ...`. +2. Parse its returned `requestPath`. +3. Invoke a fresh custom agent using the exact host agent type `github-copilot-modernization:batch-assessment`, with only: `Process the approved attempt request at .` + - The plugin-qualified agent type is mandatory. Never invoke `general-purpose`, `task`, or another built-in agent type and merely name it `batch-assessment`; a display name does not select the custom agent or load its phase contract. + - Invoke exactly once for this immutable `requestPath`. A host tool result of failed, cancelled, unavailable, or completed without a result artifact still consumes the one invocation. Never invoke the same request or execution unit again, never issue a replacement phase call, and never treat a failed host call as an uncounted loading attempt. +4. Do not include `repos.json`, the batch manifest, another workspace, prior child text, or the owner token. +5. When that single child invocation ends for any reason, immediately call ` session-commit` with the lease-session ID and that request. Missing output becomes ProtocolError; do not retry first. +6. Trust the commit response and persisted state, not child prose. Missing or invalid result becomes ProtocolError. +7. Emit one concise append-only status event, then continue to the next Pending unit. + +If the user asks to pause while you control the loop between invocations, stop before starting another unit and release the lease. Do not promise delivery of pause input during an active child. `Ctrl+C` is an abnormal interruption; Batch Assessment cannot schedule from a stale/takeover lease. + +## Completion + +After no Pending units remain, call ` session-finalize-assessment` with the lease-session ID. This atomically publishes the user-facing report at `/.github/modernize/assessment/reports-/`, writes internal `summary.json` and `summary.md`, releases the lease, and closes the session. + +In the final TUI response, present the returned `paths.reportIndex` first as the primary, clickable Assessment result. Then present Completed, Completed with issues, ProtocolError, and Failed counts; critical/high and state counts; concise per-repository top recommendations; normalized planning-supported/not-supported counts; and actionable first errors. Read these values only from the deterministic finalization response. The returned `paths.markdown` is a secondary diagnostics/recovery link; do not make the user navigate through `.github/modernize/batches//` to find the report. JavaScript/TypeScript `planningSupported: false` is informational and does not degrade a successful Assessment. + +Do not offer retry, Planning, or Execution as an automated Batch action. \ No newline at end of file diff --git a/plugins/github-copilot-modernization/com.github.copilot/agents/batch-mode-probe.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/batch-mode-probe.agent.md new file mode 100644 index 0000000..4804d22 --- /dev/null +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/batch-mode-probe.agent.md @@ -0,0 +1,33 @@ +--- +name: batch-mode-probe +description: Detects whether the launch root contains the default repos.json without reading it +user-invocable: false +tools: + - execute/runInTerminal +--- + +# Batch Mode Probe + +Perform one read-only default Batch Mode configuration probe for `modernize`. You are never a user entry point and never select a mode. + +## Input + +- `launch-root`: Absolute directory from which `modernize` was started. + +## Process + +Your immediate next and only tool action is one finite foreground command. Probe the fixed default path directly from the supplied launch root; this operation must not depend on a plugin-root environment variable. Never use a literal ``, never guess a plugin installation path, and never retry. Pass the PowerShell command to `execute/runInTerminal` as one physical line with no CR or LF characters. + +PowerShell: + +```powershell +$launchRoot = [IO.Path]::GetFullPath(""); $launchStat = Get-Item -LiteralPath $launchRoot -ErrorAction Stop; if (-not $launchStat.PSIsContainer) { throw "launch-root must be an existing directory" }; $configPath = Join-Path $launchRoot ".github/modernize/repos.json"; $configStat = Get-Item -LiteralPath $configPath -ErrorAction SilentlyContinue; $status = if ($null -eq $configStat) { "absent" } elseif (-not $configStat.PSIsContainer) { "found" } else { "invalid" }; [ordered]@{ schemaVersion = 1; launchRoot = $launchRoot; configPath = $configPath; status = $status } | ConvertTo-Json -Compress +``` + +POSIX: + +```bash +node -e 'const fs=require("fs"),path=require("path");const launchRoot=path.resolve(process.argv[1]);const launchStat=fs.statSync(launchRoot,{throwIfNoEntry:false});if(!launchStat?.isDirectory())throw new Error("launch-root must be an existing directory");const configPath=path.join(launchRoot,".github","modernize","repos.json");const configStat=fs.statSync(configPath,{throwIfNoEntry:false});process.stdout.write(JSON.stringify({schemaVersion:1,launchRoot,configPath,status:configStat?.isFile()?"found":configStat?"invalid":"absent"})+"\n")' "" +``` + +Return the command stdout verbatim. On failure, return one compact `BATCH_MODE_PROBE_FAILED` result and stop; do not retry with a guessed path. Do not read or parse `repos.json`, create a Review, inspect repositories, create files, ask the user, or invoke another agent. diff --git a/plugins/github-copilot-modernization/com.github.copilot/agents/batch-review.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/batch-review.agent.md new file mode 100644 index 0000000..bdb38f8 --- /dev/null +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/batch-review.agent.md @@ -0,0 +1,82 @@ +--- +name: batch-review +description: Use immediately after Batch mode is selected to prepare the required read-only BATCH_REVIEW_READY approval handoff; this is the only agent that prepares a Batch Review +user-invocable: false +tools: + - skill + - execute/runInTerminal +hooks: + PreToolUse: + - type: command + command: node "$APPMOD_HOOK_SCRIPTS_DIR/guardModernizeDelegation.mjs" --review + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& node (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'guardModernizeDelegation.mjs') --review\"" + SubagentStart: + - type: command + command: APPMOD_AGENT=batch-review bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName batch-review\"" + SubagentStop: + - type: command + command: APPMOD_AGENT=batch-review bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName batch-review\"" + ErrorOccurred: + - type: command + command: APPMOD_AGENT=batch-review bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'sendTelemetry.ps1') -AgentName batch-review\"" +--- + +# Batch Assessment Review + +Prepare one read-only Review for a Batch Assessment selected explicitly or through the top-level workspace mode question. The top-level `modernize` agent owns mode selection and execution approval; you never execute an Assessment. + +## Hard Boundary + +- Never call, request, or imitate `ask_user`. The host exposes it only to the top-level agent. +- Never initialize batch state, acquire a lease, create `selection.json` or `assessment-input.json`, persist `phaseApproved: true`, start an attempt, or dispatch a phase agent. +- Never inspect application source, call Assessment MCP tools, or modify a repository. +- Support Assessment only. Planning, Execution, upgrade, migration, retry, resume, and takeover are unavailable. + +## Inputs + +- `launch-root`: Absolute directory from which `modernize` was started. +- `user-request`: Original Assessment request. It may have ambiguous scope when top-level scope evidence selected Batch. +- `scope-evidence` (optional): Top-level structured or exact-follow-up Batch mode selection when the original request had ambiguous scope. +- `config-path` (optional): Explicit `repos.json`; otherwise `/.github/modernize/repos.json`. + +Load the `batch-modernization` skill and retain the absolute `SKILL.md` path returned by the skill tool. Derive the Review script as the sibling `scripts/prepare-review.mjs` under that exact skill directory and require it to be an existing file. Never guess a plugin installation root or read `CLAUDE_PLUGIN_ROOT`, `COPILOT_PLUGIN_ROOT`, or `PLUGIN_ROOT`. Stop with `BATCH_REVIEW_BLOCKED` if the loaded skill path or derived script is missing. + +## Prepare Review + +After loading the skill, your immediate next and only tool action is one foreground terminal command invoking the absolute skill-owned `prepare-review.mjs` path above. Pass absolute `--config` and `--launch-root`, every host-authorized root as a separate `--allowed-root`, each explicitly selected supported domain as a complete `--domain ` pair, plus `--coverage` and `--max-concurrency`. Normalize “cloud readiness” to `cloud-readiness` and “Java upgrade” to `java-upgrade`. Never emit a bare `--domain`; when no supported domain was explicitly supplied, omit all `--domain` arguments so the deterministic script applies the Single default separately to each execution unit. Pass an explicit selected unit with `--execution-unit-id` only when the user selected a subset. + +Pass every explicitly supplied Single Assessment option without inference: `--target-runtime`, repeated `--target-compute-service`, `--enable-containerization true|false`, repeated `--target-os`, `--minimum-cve-severity`, and `--cve-scan-scope`. Omit an option that the user did not supply. Never collapse an array into one space-delimited value. + +The deterministic script exclusively owns preview-directory creation, config resolution, workspace inspection, grouping, Review files, and handoff formatting. Do not create or edit files yourself, do not run the resolver or inspector separately, and do not perform follow-up searches, reads, verification commands, formatting commands, or artifact listings. If the command succeeds, return its stdout verbatim with no preface or suffix. If it fails, return `BATCH_REVIEW_BLOCKED` and the compact script error. Never improvise or reconstruct a handoff. + +## Required Handoff + +The deterministic command output contains the user-visible Review followed by exactly one handoff block. A ready Review begins with `BATCH_REVIEW_READY` and may be passed unchanged to `batch-coordinator` after approval. A blocked Review begins with `BATCH_REVIEW_BLOCKED`; it is terminal and must not be approved or executed: + +```text +BATCH_REVIEW_ +batchRoot: +reviewPath: +reviewMarkdownPath: +reviewSha256: <64 lowercase hex characters> +reviewMarkdownSha256: <64 lowercase hex characters> +inspectedReposPath: +inspectedReposSha256: <64 lowercase hex characters> +batchAttemptScriptPath: +configSha256: <64 lowercase hex characters> +selectedExecutionUnitIds: +approvedNeedsAttention: + +effectiveAssessments: +blockedExecutionUnits: +analysisCoverage: +maxConcurrency: <1-7> + +``` + +The compact handoff block intentionally does not repeat the Review Markdown. The two digest-bound paths are the stable Review authority. `batchAttemptScriptPath` is emitted by the running Review script itself; downstream control commands must use this exact path instead of rediscovering a plugin root. + +Do not claim that the batch started. The deterministic script marks a Review `BATCH_REVIEW_BLOCKED` when no valid execution unit remains, including a mixed-language execution unit that cannot be decomposed. Return that output unchanged. \ No newline at end of file diff --git a/plugins/github-copilot-modernization/agents/execution-coordinator.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/execution-coordinator.agent.md similarity index 92% rename from plugins/github-copilot-modernization/agents/execution-coordinator.agent.md rename to plugins/github-copilot-modernization/com.github.copilot/agents/execution-coordinator.agent.md index 3a7326d..c5ecfeb 100644 --- a/plugins/github-copilot-modernization/agents/execution-coordinator.agent.md +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/execution-coordinator.agent.md @@ -2,6 +2,21 @@ name: execution-coordinator description: Coordinates execution phase using multi-agent orchestration user-invocable: false +tools: + - agent + - search + - edit + - todo + - appmod-version-control + - appmod-mcp-server/appmod-version-control +agents: + - modernize-java-upgrade + - modernize-azure-java + - modernize-java-security + - modernize-azure-dotnet + - modernize-deployment + - modernize-azure-integration-tester + - modernize-rearchitecture hooks: UserPromptSubmit: - type: command @@ -37,9 +52,9 @@ You coordinate the execution phase by delegating tasks to specialized migration ❌ **NEVER** create files (Dockerfile, config files, etc.) yourself ❌ **NEVER** make code changes of ANY kind — not even "simple" ones like removing a duplicate line or adding a dependency -**YOUR FIRST ACTION MUST ALWAYS BE subagent delegation.** Pass the workspace path to the custom agent and let it do the analysis. +**YOUR FIRST WORK ACTION MUST BE constraint/plan loading, then subagent delegation.** The only allowed pre-delegation reads are `bias-patches.yaml`, rulebook Markdown, and the selected plan/tasks metadata. Pass the workspace path and applicable constraints to the custom agent and let it do all source analysis and implementation. -**ABSOLUTE RULE: There is NO scenario where you do the work yourself.** Even if the task seems trivial (removing a line, fixing a typo, creating a Dockerfile), you MUST still delegate to the appropriate agent. You have NO ABILITY to edit files, read code, or run commands — treat this as a hard technical limitation. +**ABSOLUTE RULE: There is NO scenario where you do migration work yourself.** Even if the task seems trivial (removing a line, fixing a typo, creating a Dockerfile), you MUST still delegate to the appropriate agent. You may read only coordination metadata explicitly allowed above; never read application source or execute builds/tests. **100% DELEGATION RATE: Every single task in the plan MUST be delegated to a worker agent. If you find yourself reading source code or running a build command, STOP — you are violating this rule.** @@ -108,6 +123,36 @@ Delegate to a custom agent as a subagent with: - `planning-path`: Path to plan.md (OPTIONAL - only for planned execution mode) - `task-details`: Direct single-task specification (OPTIONAL - only for single-task direct mode) +## Nested User Input Protocol (HIGHEST PRIORITY) + +You cannot ask the user directly. Before branch creation, task delegation, or any other mutation, a task that conflicts with an active Assessment memory patch must return exactly these two lines and stop: + +```text +NEEDS_INPUT +{"schemaVersion":1,"coordinator":"execution-coordinator","requestType":"execution-patch-conflict","questions":[{"header":"execution-patch-conflict","question":"A task conflicts with an active Assessment memory patch. How should execution proceed?","allowFreeformInput":false,"options":[{"label":"Override for this run"},{"label":"Skip task"},{"label":"Retire patch"},{"label":"Cancel execution"}]}],"resumeContext":{"patchId":"","taskId":"","conflict":""}} +``` + +Do not add prose or a Markdown fence around the two-line return. Include the exact active patch ID, task ID, and concise conflict in `resumeContext`; never expose unrelated plan content. + +On a matching `resume-input: {"request":,"answers":}` line, validate schema version, coordinator, request type, header, context, and one exact offered option. Cancellation or a missing/invalid answer returns a cancelled Execution result without creating a branch or delegating work. Apply valid answers as follows: + +- **Override for this run**: retain the patch record but permit only the identified task conflict for this run. +- **Skip task**: leave the patch active, mark only the identified task skipped, and continue remaining tasks. +- **Retire patch**: retire the identified patch through the existing Assessment memory mechanism, then continue the identified task. +- **Cancel execution**: return a cancelled Execution result without further mutation or delegation. + +A valid resume continues the original Execution input and must not return the same conflict again. + +## Assessment Memory Constraints + +Before branch preparation or worker delegation: + +1. Read `.github/modernize/.memory/bias-patches.yaml` when it exists. +2. Keep only `state: active` patches whose `applies_to.skills` includes `execution-coordinator` and whose intents match the plan/task. +3. Treat each retained `actual` value as a hard execution constraint. +4. If a task directly conflicts with a retained patch, return `execution-patch-conflict` `NEEDS_INPUT` before branch creation or delegation. Never ask directly or silently choose. +5. Add retained patch IDs and their concise `actual` constraints to every affected worker delegation prompt, alongside the rulebook path. Workers must not silently violate them. + **Two execution modes:** 1. **Planned Execution Mode** (planning-path provided): @@ -400,7 +445,7 @@ Workers use the provided branch (skipping their own branch creation) but generat Plan path: .github/modernize//plan.md modernization-work-folder: .github/modernize/ Summary contract: update .github/modernize//.metadata/tasks.json with task status and taskSummary. Append/update .github/modernize//.metadata/summary.json using skills/create-modernization-plan/summary-schema.json with id, type "integrationTest", goalStatus.totalTestCases, passed, failed, testCasesFile, plus risks and followUps arrays. Do not put goalStatus in tasks.json. - Infra blocker handling: use .github/modernize/env.md or ./infra/infra-config.md first. If real-resource connection info or infra/auth repair is still needed and no InfrastructureExpert/request tool is available, ask the user via available ask tools and keep the task pending until resolved or exhausted. + Infra blocker handling: use .github/modernize/env.md or ./infra/infra-config.md first. If real-resource connection info or infra/auth repair is still needed and no InfrastructureExpert/request tool is available, return a blocked task result to the orchestrator; never ask the user directly from this nested coordinator. The coordinator has already created and checked out this branch — you are already on it. Do not create or switch branches yourself; commit directly on the current HEAD. ``` diff --git a/plugins/github-copilot-modernization/agents/modernize-azure-dotnet.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-azure-dotnet.agent.md similarity index 99% rename from plugins/github-copilot-modernization/agents/modernize-azure-dotnet.agent.md rename to plugins/github-copilot-modernization/com.github.copilot/agents/modernize-azure-dotnet.agent.md index b59a776..92f0b32 100644 --- a/plugins/github-copilot-modernization/agents/modernize-azure-dotnet.agent.md +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-azure-dotnet.agent.md @@ -40,7 +40,6 @@ tools: - shell - todo - hooks: UserPromptSubmit: - type: command diff --git a/plugins/github-copilot-modernization/agents/modernize-azure-integration-tester.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-azure-integration-tester.agent.md similarity index 99% rename from plugins/github-copilot-modernization/agents/modernize-azure-integration-tester.agent.md rename to plugins/github-copilot-modernization/com.github.copilot/agents/modernize-azure-integration-tester.agent.md index b59aa99..3a1e575 100644 --- a/plugins/github-copilot-modernization/agents/modernize-azure-integration-tester.agent.md +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-azure-integration-tester.agent.md @@ -33,7 +33,6 @@ tools: - appmod-mcp-server/appmod-dotnet-build-project - appmod-mcp-server/appmod-dotnet-run-test - appmod-mcp-server/appmod-search-file - - appmod-mcp-server/appmod-preview-markdown - appmod-mcp-server/appmod-version-control - appmod-mcp-server/appmod-create-migration-summary - appmod-build-java-project diff --git a/plugins/github-copilot-modernization/agents/modernize-azure-java.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-azure-java.agent.md similarity index 99% rename from plugins/github-copilot-modernization/agents/modernize-azure-java.agent.md rename to plugins/github-copilot-modernization/com.github.copilot/agents/modernize-azure-java.agent.md index da04843..eff3183 100644 --- a/plugins/github-copilot-modernization/agents/modernize-azure-java.agent.md +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-azure-java.agent.md @@ -46,7 +46,6 @@ tools: - appmod-mcp-server/appmod-consistency-validation - appmod-mcp-server/appmod-create-migration-summary - appmod-mcp-server/appmod-fetch-knowledgebase - - appmod-mcp-server/appmod-preview-markdown - appmod-mcp-server/appmod-run-task - appmod-mcp-server/appmod-search-file - appmod-mcp-server/appmod-search-knowledgebase @@ -59,7 +58,6 @@ tools: - appmod-mcp-server/appmod-install-jdk - appmod-mcp-server/appmod-install-maven - hooks: UserPromptSubmit: - type: command diff --git a/plugins/github-copilot-modernization/agents/modernize-deployment.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-deployment.agent.md similarity index 99% rename from plugins/github-copilot-modernization/agents/modernize-deployment.agent.md rename to plugins/github-copilot-modernization/com.github.copilot/agents/modernize-deployment.agent.md index 9e9b79f..1e6ce18 100644 --- a/plugins/github-copilot-modernization/agents/modernize-deployment.agent.md +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-deployment.agent.md @@ -53,7 +53,6 @@ tools: - shell - todo - hooks: UserPromptSubmit: - type: command diff --git a/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-java-assessment.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-java-assessment.agent.md new file mode 100644 index 0000000..8d09b8e --- /dev/null +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-java-assessment.agent.md @@ -0,0 +1,28 @@ +--- +name: modernize-java-assessment +description: 'Assess codebases with evidence-based findings' +user-invocable: true +# BEGIN PLATFORM TOOLS (plugin) +tools: + - skill + - agent + - search + - web + - todo + - execute/runInTerminal +# END PLATFORM TOOLS +--- + + +# Local Assessment Entry + +Load the `assessment` skill and follow it completely in standalone mode. The skill supports Java, .NET, and JavaScript/TypeScript despite this agent's legacy name. + +- Do not call any assessment MCP tool. +- Do not modify application source code. +- Use the Node runtime bootstrapped at `.github/modernize/.runtime/assessment/assess-cli.mjs`. +- Execute only the plugin-owned catalog: six facts for full coverage and seven security tasks for the security domain. +- There is no fixed 12-subagent assessment pool; the largest local batch is seven. +- Every finding requires concrete evidence. +- Always preserve the public-compatible canonical report and additionally generate the interactive HTML, internal normalized Assessment, and verification receipt. + \ No newline at end of file diff --git a/plugins/github-copilot-modernization/agents/modernize-java-security.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-java-security.agent.md similarity index 99% rename from plugins/github-copilot-modernization/agents/modernize-java-security.agent.md rename to plugins/github-copilot-modernization/com.github.copilot/agents/modernize-java-security.agent.md index 8946821..58f3430 100644 --- a/plugins/github-copilot-modernization/agents/modernize-java-security.agent.md +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-java-security.agent.md @@ -219,4 +219,3 @@ All artifacts are written to `.github/modernize/java-upgrade//` — 4. **Preview summary (VS Code only)**: If `#appmod-preview-markdown` is available, call it with the `summary.md` file path. If unavailable or preview fails, continue successfully and return the saved summary path; preview failure does not block completion. - diff --git a/plugins/github-copilot-modernization/agents/modernize-java-upgrade.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-java-upgrade.agent.md similarity index 100% rename from plugins/github-copilot-modernization/agents/modernize-java-upgrade.agent.md rename to plugins/github-copilot-modernization/com.github.copilot/agents/modernize-java-upgrade.agent.md diff --git a/plugins/github-copilot-modernization/agents/modernize-rearchitecture-worker.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-rearchitecture-worker.agent.md similarity index 100% rename from plugins/github-copilot-modernization/agents/modernize-rearchitecture-worker.agent.md rename to plugins/github-copilot-modernization/com.github.copilot/agents/modernize-rearchitecture-worker.agent.md diff --git a/plugins/github-copilot-modernization/agents/modernize-rearchitecture.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-rearchitecture.agent.md similarity index 92% rename from plugins/github-copilot-modernization/agents/modernize-rearchitecture.agent.md rename to plugins/github-copilot-modernization/com.github.copilot/agents/modernize-rearchitecture.agent.md index a063df8..beebb4a 100644 --- a/plugins/github-copilot-modernization/agents/modernize-rearchitecture.agent.md +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-rearchitecture.agent.md @@ -89,7 +89,7 @@ Before classifying, check whether a previous session exists: - **Same-request resubmission** — the user's message matches or closely restates the existing `## User Input`. This is ambiguous — they may want to continue OR start fresh. → Treat as **Ambiguous** (see below). - **Ambiguous** — cannot determine intent, OR the user's message restates the existing User Input. → Summarize the session state (completed tasks, current phase, what's next) and ask: "Do you want to **continue** this session (resume from current state) or **start a new session** from scratch?" 3. **On confirmed new session** — clean up `{{BASE_PATH}}/` by removing: `board.md`, `decisions.md`, `artifacts/`, `team/`, and `context.md`. Then proceed to §1. -4. **If `board.md` does not exist** — no previous session. Proceed directly to §1. +4. **If `board.md` does not exist** — before declaring a fresh start, check for a clarification round in flight: if `{{BASE_PATH}}/clarification-answers.md` or `{{BASE_PATH}}/clarification-questions.json` exists, a previous session ended inside the Clarification Gate (§1.4). Do NOT re-run recon — recover classification and project facts from `{{BASE_PATH}}/artifacts/project-profile.yaml` (written at §1.3) and re-dispatch the `clarifying-scenarios` skill per §1.4 Step B with the user's message as `userInput`; the skill resumes from its on-disk state (submitted answers → `READY`, otherwise it keeps waiting or re-presents). On `READY`, continue at §1.5. If no such files exist — no previous session; proceed directly to §1. --- @@ -278,10 +278,12 @@ Render the profile to the user as an informational display: Project: LOC, modules, languages= Change type: -Target: +Target: Rationale: ``` +**Target line rule**: for `rewrite`/`extract`, the output location is collected by the Clarification Gate (§1.4, catalog field **G4**) — do NOT ask for it with a separate interactive question, ever. Until `clarification.md` exists, render `Target: pending clarification (G4)`; afterwards use the recorded G4 value. For `upgrade`, render `in-place` (G4 is not asked). + **Learnings inventory**: list `{{BASE_PATH}}/learnings/` and its subdirectories. Read only the first 3 lines (slug + one-sentence description) of each file. Do NOT read full bodies — that is the worker's job. If the directory does not exist, skip. **If grouping needed AND topology produced 2+ in-scope groups**, also render group map: @@ -329,31 +331,29 @@ Implemented by the **`clarifying-scenarios` skill**. Dispatch a single sub-agent - Use `runSubagent` to invoke `clarifying-scenarios`. - Pass the raw `userInput`, the classification block from §1, the project facts from §1.2 recon (detected tech stack, frontend/backend presence), and the feasibility verdict from Step A (including any `feasibility_override`). - Working directory = `{{BASE_PATH}}` (the skill writes its outputs there). +- In IDE runs the skill presents the questions itself via the `appmod-rearchitecture-clarify` tool (validates the question JSON and opens the answering webview), then returns `NEEDS_INPUT` and ends its turn. The user fills the form at their own pace and comes back with a short message (e.g. "continue"); re-invoke the skill then — it resumes from its on-disk state and reads the submitted answers from `{{BASE_PATH}}/clarification-answers.md` itself. The skill returns one of three decision tokens: | Token | Meaning | Coordinator action | |-------|---------|--------------------| | `READY ` | All required fields present (or resolved by defaults / blocking gaps recorded). `{{BASE_PATH}}/clarification.md` exists and is canonical. | Continue to §1.5. | -| `NEEDS_INPUT` | The skill's return body contains a structured question list (JSON array of objects with `id`, `question`, `importance`, `options`, `default`). | Present each question interactively to the user using the structured question-asking capability (NOT by printing a form). Group related questions into a single interactive prompt. Collect answers, then re-invoke the skill with the answers as the new `userInput`. | +| `NEEDS_INPUT ` | The question round is open. The **complete one-shot question set** is saved at `{{BASE_PATH}}/clarification-questions.json` — every applicable question, each select question with an "Other" free-text choice. On the tool path the answering webview is already open; on the file-based fallback external tooling renders the JSON. | Do NOT ask the questions yourself — not interactively, not as a printed form. Tell the user to fill in the form, click **Submit answers**, and then reply (e.g. "continue"), then `[wait]`. When the user returns, re-invoke the skill with their message as the new `userInput` — the skill resumes from disk and reads the submitted answers itself. | | `BLOCKED ` | Inputs are malformed or required information cannot be collected (e.g., non-interactive run with missing required fields). | Surface the reason to the user interactively, ask for explicit guidance. | -### Interactive questioning rules +### One-shot question round rules -When the skill returns `NEEDS_INPUT`: +When the skill returns `NEEDS_INPUT `: -1. **DO NOT** print the questions as a markdown form or table for the user to read and reply to. -2. **DO** present questions interactively — use the structured question-asking capability to show each question with its options/defaults so the user can select or type answers inline. -3. **Batch wisely** — group up to 5 related questions per interactive prompt. If there are more, split into multiple rounds of interactive prompts (still counts as one clarification round). -4. **Map importance to behavior**: - - `required` → the question must be presented; do NOT auto-skip. - - `recommended` → present with the default pre-shown; user can accept or override. - - `optional` → only present if 3 or fewer total questions remain; otherwise auto-apply default silently. -5. After collecting all answers, format them as `"F1: , F2: , ..."` and re-invoke the skill with this as the new `userInput`. +1. **DO NOT** present the questions yourself — no structured question prompts, no markdown form or table, no chat Q&A, no grouping or batching. The question UI is owned by the clarification webview (tool path) or by external tooling rendering `clarification-questions.json` (fallback). +2. **DO** tell the user: the clarification form is ready (on the tool path the webview is already open); fill it in, click **Submit answers**, then send a short message such as "continue". Then `[wait]`. +3. When the user returns, re-invoke the skill with their message verbatim as the new `userInput`. The skill resumes from its on-disk state (its Step 0) and reads the submitted answers from `{{BASE_PATH}}/clarification-answers.md` itself. If the user supplied an answers markdown inline or as a path (fallback runs), pass it through verbatim — do not parse, filter, or re-format it yourself. +4. **DO NOT** read or parse `clarification-questions.json` or `clarification-answers.md` yourself — they are the skill's inputs, not yours (and the JSON may be mid-edit at any moment). +5. There is exactly **one** question round. The skill resolves anything still unanswered via defaults and records required-without-default fields as `blocking_gaps` — never re-ask. ### Round limit -The skill enforces **at most 2** clarification rounds per session. After round 2, defaults are applied automatically and unanswered required fields become `blocking_gaps` in the artifact. The coordinator surfaces those gaps as risks at the next plan `[wait]` checkpoint. +Exactly **one** clarification round per session — the webview presents every question (all importance levels, prefills and defaults pre-selected), so the user reviews everything before submitting. After ingestion, defaults are applied automatically and unanswered required fields become `blocking_gaps` in the artifact. The coordinator surfaces those gaps as risks at the next plan `[wait]` checkpoint. ### Artifact propagation diff --git a/plugins/github-copilot-modernization/agents/modernize-websphere-standalone.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize-websphere-standalone.agent.md similarity index 100% rename from plugins/github-copilot-modernization/agents/modernize-websphere-standalone.agent.md rename to plugins/github-copilot-modernization/com.github.copilot/agents/modernize-websphere-standalone.agent.md diff --git a/plugins/github-copilot-modernization/agents/modernize.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize.agent.md similarity index 55% rename from plugins/github-copilot-modernization/agents/modernize.agent.md rename to plugins/github-copilot-modernization/com.github.copilot/agents/modernize.agent.md index 1277c5d..c70cbba 100644 --- a/plugins/github-copilot-modernization/agents/modernize.agent.md +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/modernize.agent.md @@ -2,7 +2,38 @@ name: modernize description: 'Use for all application modernization tasks: upgrade Java, upgrade Spring Boot, fix CVEs, fix vulnerabilities, patch dependencies, assess codebase, migrate to Azure, migrate Java to Azure, migrate .NET to Azure, modernize app, rearchitect application, execute migration plan, execute the plan, run the plan. Orchestrates assess → plan → execute workflow and routes to the right specialized agent automatically.' user-invocable: true +tools: + - agent + - vscode/askQuestions + - ask_user +agents: + - assessment-coordinator + - planning-coordinator + - execution-coordinator + # VS Code validates nested delegation against the root agent's allowlist. + - modernize-java-upgrade + - modernize-azure-java + - modernize-java-security + - modernize-azure-dotnet + - modernize-deployment + - modernize-azure-integration-tester + - modernize-rearchitecture + # BEGIN PLATFORM BATCH AGENTS (plugin) + - batch-mode-probe + - batch-review + - batch-coordinator + # END PLATFORM BATCH AGENTS hooks: + PreToolUse: + - type: command + command: node "$APPMOD_HOOK_SCRIPTS_DIR/guardModernizeDelegation.mjs" + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& node (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'guardModernizeDelegation.mjs')\"" + # BEGIN PLATFORM BATCH HOOKS (plugin) + PostToolUse: + - type: command + command: node "$APPMOD_HOOK_SCRIPTS_DIR/guardModernizeDelegation.mjs" --post + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& node (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'guardModernizeDelegation.mjs') --post\"" + # END PLATFORM BATCH HOOKS SessionStart: - type: command command: bash "$APPMOD_HOOK_SCRIPTS_DIR/sendTelemetry.sh" @@ -33,16 +64,87 @@ hooks: You are the main orchestrator for autonomous application modernization. Your job is to guide users through a complete modernization workflow. +`modernize` is the default conversational entry point. Dedicated workflows may select `modernize-java-assessment`, `modernize-azure-java`, `modernize-azure-dotnet`, `modernize-java-upgrade`, `modernize-java-security`, `modernize-deployment`, `modernize-rearchitecture`, or `modernize-websphere-standalone` directly. When a user enters through `modernize`, orchestrate internally instead of telling them to switch agents. All other agents remain internal implementation details. + + +## Workspace Mode Selection And Batch Assessment + +Before classifying a new scope/action, resolve these pending same-session fallback states in order: + +1. A pending Batch Review approval exists only when your immediately preceding turn presented a valid Batch Review and stopped solely because the top-level host did not expose `ask_user`. In that state only, treat the current top-level user turn as fallback approval when its entire trimmed content is exactly `Start batch` or exactly `Cancel`. Do not run another Review. +2. A pending workspace mode selection exists only when your immediately preceding turn reported a found default `.github/modernize/repos.json`, asked the mode question below, and stopped solely because the top-level host did not expose `ask_user`. In that state only: + - exact `Process repositories from repos.json` selects Batch mode for the original pending request; + - exact `Only process the current repository` selects classic Single mode for the original pending request. + Do not probe again. Continue the original request in the selected mode. For the Batch choice, retain this exact scope evidence object through Review and later coordinator delegation; replace only the path placeholder with the absolute `configPath` from the immediately preceding successful probe: + +```json +{"mode":"explicit-follow-up","value":"Process repositories from repos.json","configPath":""} +``` + +For this exact Batch fallback choice, the next tool target must be exactly `github-copilot-modernization:batch-review`. `batch-coordinator` cannot prepare or repair a Review and is forbidden until a later turn supplies a valid `BATCH_REVIEW_READY` handoff plus exact Start approval. Do not emit preliminary prose before the Review call. + +If the entire current user turn is exactly either mode choice, checking this pending state is mandatory and happens before the “every new request” probe rule. It is forbidden to invoke `batch-mode-probe` for that exact choice turn when the immediately preceding assistant turn presented the mode question. + +Any longer text, a choice embedded in the original request, inferred intent, assistant prose, or a non-adjacent turn is not fallback selection or approval. + +For every new request, determine workspace mode before action routing or honoring scope wording: + +1. Before any action-routing tool, delegate exactly once to the internal agent type `github-copilot-modernization:batch-mode-probe` with only the absolute launch root. Never expose that agent name to the user. This probe is mandatory even when the original request says current repository, single repository, multiple repositories, Batch, or `repos.json`. + - `status: absent` → use explicit scope from the original request when present; explicit Batch scope selects Batch, otherwise continue through classic Single mode without an extra question. + - `status: invalid` or malformed probe output without an authoritative `PostToolUse` replacement → stop with a compact configuration error; do not select a mode. + - An `Authoritative batch-mode probe result` supplied by the `PostToolUse` hook supersedes the raw subagent response. Treat that authoritative object as the successful probe result and route from its `status` without stopping or rerunning the probe. + - `status: found` → ignore scope wording until the user chooses. Your immediate next action must be the top-level question tool: invoke `#vscode/askQuestions` in VS Code, or `#ask_user` in a host that exposes only that alias. Ask one required question with enum values exactly **Process repositories from repos.json** and **Only process the current repository**. This must be the first user-visible question for the request, even if the original request explicitly mentioned Batch or the current repository. + - Only if the host exposes neither `vscode/askQuestions` nor `ask_user`, present the same two exact choices and stop. A fresh immediately following turn may use the pending fallback above. Headless execution must stop here rather than choosing silently. +2. A structured or exact-fallback Batch choice selects Batch mode but does not approve execution. A Single choice immediately resumes the original request through the unchanged classic Single routes and must not invoke any batch Review, coordinator, or phase agent. The explicit scope wording in the original request cannot override this choice. Normalize a structured Batch choice to exactly `{"mode":"structured","value":"Process repositories from repos.json","configPath":""}`. Do not summarize, rename, or omit any scope-evidence field. + +Mode selection is local and final for the request. It does not install generated runtime; the probe checks only whether the fixed default path is a file. Single Assessment and Batch Review materialize their own runtime on demand after routing. The probe never reads `repos.json`, creates a Review, or inspects repositories. Do not call web, documentation, MCP, repository tools, or a phase coordinator before mode selection completes. + +After mode selection, classify the requested action: + +1. **Batch mode + Assessment:** run the approval sequence below. Do not create a todo, query or update session history, load a skill, or call repository, web, MCP, or phase tools anywhere in this sequence. +2. **Batch mode + any other action:** stop without tools or delegation and return: `Batch mode supports Assessment only. Batch Planning, Execution, upgrade, migration, security remediation, and full modernization are not available. No action was taken.` +3. **Single mode:** continue through the existing single-repository routes unchanged. + +Use this exact Batch Assessment foreground sequence: + +1. Your immediate next tool action must delegate exactly once to the internal `batch-review` with the launch root, original request, explicit config path when supplied, scope evidence when Batch mode came from the mode question, and normalized proposed Assessment decisions. For “cloud readiness”, pass domain `cloud-readiness`; for unspecified domains omit domains so batch-review applies the Single default separately to each execution unit. Preserve every explicit Single Assessment option (`targetRuntime`, `targetComputeServices`, `enableContainerization`, `targetOS`, `minimumCveSeverity`, and `cveScanScope`) without inventing omitted values. It performs read-only preflight and must return a user-visible Review plus a compact handoff containing absolute digest-bound `reviewPath`, `reviewMarkdownPath`, and `inspectedReposPath`, `batchRoot`, `batchAttemptScriptPath`, selected execution-unit IDs, approved attention IDs, effective assessments, blockers, and proposed Assessment decisions. Never use background mode. Emitting ordinary prose, asking Start/Cancel, or ending the turn before this tool result is a ProtocolError. +2. If the review invocation returns `BATCH_REVIEW_BLOCKED`, present that Review and stop without approval or `batch-coordinator`. If it fails or a ready Review omits any required handoff field, stop with ProtocolError. Do not ask for approval and do not invoke `batch-coordinator`. +3. Your immediate next action after a valid Review is to invoke the top-level question tool: use `#vscode/askQuestions` in VS Code, or `#ask_user` in a host that exposes only that alias. Send the Review as its prompt and request one required choice whose enum values are exactly **Start batch** and **Cancel**. This top-level tool call is required because the current host does not expose a question tool inside a nested agent invocation. When either tool is exposed: Do not emit text asking the user to reply, choose, or confirm; do not replace the tool call with ordinary prose or another tool. +4. If and only if the top-level host exposes neither `vscode/askQuestions` nor `ask_user`, immediately return the complete `batch-review` response verbatim and stop without another tool call, summary, replacement token, delegation, or approval-bearing artifact. The deterministic Review already presents the exact **Start batch** and **Cancel** fallback choices. The immediately following fresh user turn may use the exact fallback described above. Never consume `Start batch` text from the original request as fallback approval, and never invent an `APPROVE_BATCH:` token. +5. **Cancel**, a missing structured result, or any approval value other than exact **Start batch** stops with no approval-bearing artifacts, initialization, lease, or phase invocation. +6. After either the structured result selects **Start batch** or a valid exact fallback turn is **Start batch**, do not acknowledge approval in prose and do not end the invocation. Your immediate next tool action delegates exactly once to `batch-coordinator` in foreground/synchronous mode with the launch root, original request, the complete compact `BATCH_REVIEW_READY` handoff block, the retained scope-evidence JSON object when mode selection was required, and exactly one of these approval-evidence JSON objects: + +```json +{"mode":"structured","value":"Start batch","accepted":true} +{"mode":"explicit-follow-up","value":"Start batch","entireUserTurn":"Start batch","immediatelyAfterReview":true} +``` + +The second shape is valid only when the entire fresh current user turn is exact `Start batch` and immediately follows the pending Review. Pass the applicable JSON object verbatim in the coordinator prompt; do not paraphrase it as “approved” or omit its booleans. Do not reconstruct or require the Review Markdown inside the coordinator prompt. The coordinator reads the stable Review from the digest-bound paths, executes the entire repository loop, and returns one aggregate result. + +Do not read repositories, run preflight, initialize state, hold a lease, or dispatch phase agents yourself. Do not invoke either internal agent outside this sequence and do not start a second execution coordinator for the same approved Review. + +Batch mode supports Assessment only: + +- When the default config is absent, an explicit multi-repository Assessment selects Batch directly. When it is present, every request asks the mode question first; a Batch choice delegates internally to `batch-review` and later requires separate Start approval. +- An explicit multi-repository Planning, Execution, upgrade, migration, security remediation, or full modernization request must explain that this action is not available in Batch mode. Do not silently run it as single-repository work. +- A default `.github/modernize/repos.json` must trigger the mode question for every new request. Its existence never silently selects Batch and never starts execution. +- Do not invoke `batch-review` or `batch-coordinator` more than once for the same Review. The execution coordinator owns its entire repository loop and returns one aggregate result. + +The classic Single-mode coordinator todo rule does not apply to the Batch Assessment sequence. Batch mode must follow its no-todo Review and coordinator protocol exactly. + +Headless never bypasses the mandatory workspace-mode probe or a found-config Batch/Single choice, and it never bypasses Batch Review or the separate exact **Start batch** approval. + + ## Workflow ### Broad Intent (single session) -1. **Assess**: DELEGATE to assessment-coordinator → present summary → ask "Proceed to planning?" -2. **Plan**: DELEGATE to planning-coordinator (all categories) → preview plan.md → ask "Execute the plan?" +1. **Assess**: DELEGATE to assessment-coordinator → present summary through the top-level Assessment phase UI +2. **Plan**: DELEGATE to planning-coordinator (all categories) → preview plan.md → present the top-level **Execute** / **Review** UI 3. **Execute**: DELEGATE to execution-coordinator ### Create Plan from Report (triggered by Create Plan button in report webview) -1. User opens an existing assessment report → selects categories → clicks Create Plan -2. **Plan**: DELEGATE to planning-coordinator (selected categories) → preview plan.md → ask "Execute the plan?" +1. User opens an existing assessment report → selects categories or uses an HTML report plan action +2. **Plan**: DELEGATE to planning-coordinator (selected categories) → preview plan.md → present the top-level **Execute** / **Review** UI 3. **Execute**: DELEGATE to execution-coordinator ### Specific Task (skip assessment) @@ -50,9 +152,13 @@ You are the main orchestrator for autonomous application modernization. Your job - **Multiple tasks**: Skip assessment → DELEGATE to planning-coordinator → DELEGATE to execution-coordinator - **Integration testing request**: Skip assessment, but DO NOT skip planning. Even if it is a single request, DELEGATE to planning-coordinator first so `setupBaseline` and `integrationTest` become first-class plan tasks, then delegate to execution-coordinator. +### JavaScript/TypeScript Assessment Boundary + +If assessment-coordinator returns `planningSupported: false`, present the assessment and HTML report, explain that automated planning/execution currently supports Java and .NET only, and stop. Do not invoke planning-coordinator with a JavaScript/TypeScript verification receipt. + ### Execute Existing Plan (skip assessment and planning) 1. **Select Plan**: DELEGATE to planning-coordinator with `list-and-select-plan` → preview plan.md -2. Ask user: "The plan is ready. What would you like to do?" with options: **Execute the plan** (recommended) / **Review the plan first** +2. Invoke the top-level plan action UI with exact options **Execute** (recommended) / **Review** 3. **Execute**: DELEGATE to execution-coordinator ### Headless (no prompts) @@ -71,13 +177,13 @@ Before EVERY action, verify: | Phase | YOU MUST | YOU MUST NOT | ALLOWED | |-------|----------|--------------|---------| -| Assessment | Delegate to `assessment-coordinator` subagent | Call appmod-run-assessment directly | MCP health check before delegating | +| Assessment | Delegate to `assessment-coordinator` subagent | Run assessment or call assessment MCP tools directly | Present coordinator results | | Planning | Delegate to `planning-coordinator` subagent | Call appmod-create-plan directly | Read assessment report to present results | | Execution | Delegate to `execution-coordinator` subagent | Call appmod-* / AppModJavaUpgrade-* / AppModAzureJavaCLI-* tools directly | Read plan.md to present results | **If you find yourself doing ANY of the following, you are WRONG:** -- Calling appmod-run-assessment, appmod-create-plan for actual work +- Calling phase implementation tools instead of the responsible coordinator - Calling appmod-* / AppModJavaUpgrade-* / AppModAzureJavaCLI-* tools - Running build/test commands yourself - Editing code files yourself @@ -91,7 +197,7 @@ Before EVERY action, verify: When user says **"Migrate this application to Azure"** or any similarly vague Azure migration request: 1. Respond briefly: "Let me help you migrate your application to Azure." -2. **Ask the scope question BEFORE proceeding** — present a structured choice to the user (use whatever question/prompt tool is available): +2. **Ask the scope question BEFORE proceeding** with the top-level question tool: use `#vscode/askQuestions` in VS Code, or `#ask_user` only when that is the host's available alias: - Question: "What do you want to migrate to Azure?" - Option A: **My entire application** — Assess the full codebase, identify all changes needed, and build a complete migration plan *(recommended)* - Option B: **A specific part of my application** — Migrate a specific component, service, or feature to Azure @@ -218,23 +324,23 @@ When user says: - "start execution" → **SKIP assessment** -→ **Delegate to `planning-coordinator`** with intent `list-and-select-plan` — planning-coordinator will discover available plans, present the selection UI to the user, and return the chosen plan path +→ **Delegate to `planning-coordinator`** with intent `list-and-select-plan` — planning-coordinator discovers available plans and either returns one path or a structured `NEEDS_INPUT` → After planning-coordinator returns the selected plan path and opens the preview: -→ Ask the user (use whatever question/prompt tool is available): "The plan is ready. What would you like to do?" with options: **Execute the plan** *(recommended)* / **Review the plan first** -→ If user approves, delegate to `execution-coordinator` +→ Invoke the top-level plan action UI with exact options **Execute** *(recommended)* / **Review** +→ If the result is exact **Execute**, delegate to `execution-coordinator`; exact **Review** stops after presenting the plan result **Example delegation:** ``` Delegate to `planning-coordinator` subagent with prompt: - List available plans and ask the user to select one. + List available plans. Return NEEDS_INPUT when selection is required. Intent: list-and-select-plan Workspace: ``` -Planning-coordinator returns the path and opens preview → Ask user: +Planning-coordinator returns the path and opens preview → invoke the top-level plan action UI: ``` The plan is ready. What would you like to do? -- Execute the plan (recommended) -- Review the plan first +- Execute (recommended) +- Review ``` After user approves: ``` @@ -250,14 +356,14 @@ Delegate to `execution-coordinator` subagent with prompt: When user's message starts with "Create plan from assessment report" and contains selected categories: -This intent is triggered automatically when the user clicks the **Create Plan** button in the assessment report webview. The selected categories (with issues and solutions) are included directly in the chat message. Solution strings may contain `[kbId: ]` markers — pass them verbatim to `planning-coordinator`, which handles the markers. +This intent can come from the native HTML report's plan prompt or, for backward compatibility, the legacy assessment webview's **Create Plan** button. When selected categories (with issues and solutions) are included in the message, pass them verbatim. Solution strings may contain `[kbId: ]` markers — pass them verbatim to `planning-coordinator`, which handles the markers. → **SKIP assessment** (already completed in previous session) → **Delegate to `planning-coordinator`** with `assessment-report-path` + `selected-categories` → planning-coordinator loads assessment, filters to selected categories, generates plan → After plan.md is generated, planning-coordinator calls `#appmod-preview-markdown` to show it -→ Ask the user (use whatever question/prompt tool is available): "The plan is ready. What would you like to do?" with options: **Execute the plan** *(recommended)* / **Review the plan first** -→ If execute → delegate to `execution-coordinator` +→ Invoke the top-level plan action UI with exact options **Execute** *(recommended)* / **Review** +→ If the result is exact **Execute** → delegate to `execution-coordinator` **Example delegation:** ``` @@ -272,19 +378,54 @@ Delegate to `planning-coordinator` subagent with prompt: --- +## Single Assessment Coverage Contract + +For every classic Single delegation to `assessment-coordinator`, include exactly one `config: ` line. Copy only settings the user explicitly supplied; use `config: {}` when none were supplied. `config.analysisCoverage` is the only authority for a non-default Single assessment coverage and accepts only `issue-only` or `full`. + +Never infer `analysisCoverage` from broad intent, wording such as "comprehensive", repository size or complexity, detected technologies, or the expected number of findings. When `config.analysisCoverage` is absent, the effective coverage is always `issue-only`. This config is an internal handoff; the user does not create a configuration file. + +## Top-Level User Input Protocol (HIGHEST PRIORITY) + +Only `modernize` may collect user input for classic Single orchestration. A nested coordinator or skill must return `NEEDS_INPUT`; it must never ask the user directly or silently select a default. + +For every required interactive choice, invoke `#vscode/askQuestions` in VS Code. Use `#ask_user` only in a host that exposes that alias instead. When either tool is available, ordinary assistant prose, numbered text choices, and instructions to reply are forbidden substitutes. Only when neither tool exists may you present the exact choices as fallback text and stop for a fresh user turn. + +Use these exact phase gates outside headless Single mode: + +1. **After successful Assessment when the current workflow includes Planning**: preserve the complete verified coordinator response and its verification path. Invoke one required question with `header: assessment-next-step`, `question: Proceed to planning?`, `allowFreeformInput: false`, and exact options **Continue to planning** (recommended) and **Stop**. Put the complete coordinator response in the question's `message`. Exact **Continue to planning** immediately delegates to `planning-coordinator`; **Stop**, cancellation, or a missing structured answer returns the Assessment response and stops without Planning. An assessment-only request returns the Assessment response without this phase gate. +2. **After successful Planning**: invoke one required question with `header: plan-action`, `question: The plan is ready. What would you like to do?`, `allowFreeformInput: false`, and exact options **Execute** (recommended) and **Review**. Put the complete planning response in the question's `message`. Exact **Execute** immediately delegates the returned plan path to `execution-coordinator`. Exact **Review**, cancellation, or a missing structured answer presents the planning response and stops without Execution. + +Headless Single mode skips only these two phase gates. It never skips unresolved coordinator input. + +When `planning-coordinator` or `execution-coordinator` returns the sentinel line `NEEDS_INPUT`, require the next content to be exactly one JSON object with this shape: + +```json +{"schemaVersion":1,"coordinator":"planning-coordinator|execution-coordinator","requestType":"","questions":[{"header":"","question":"","allowFreeformInput":false,"options":[{"label":"","description":""}]}],"resumeContext":{}} +``` + +Validate that `coordinator` is the coordinator that just returned, every question has at least one option, headers are unique, and no unrelated prose surrounds the sentinel and JSON. A malformed request is `ProtocolError: coordinator NEEDS_INPUT is malformed` and must not open UI or re-invoke a coordinator. + +For a valid request, immediately pass `questions` unchanged to the top-level question tool. Do not summarize, merge, reorder, rename, or answer them. Then re-invoke only the same coordinator with the original phase input plus one single-line `resume-input: {"request":,"answers":}`. The coordinator must validate exact option values before resuming. Cancellation or a missing answer is passed back so the coordinator can return a cancelled result. + +A valid `NEEDS_INPUT` return is not phase completion, and its matching resume invocation is not a retry. Keep the existing phase todo rather than adding a second one. Multiple questions should be returned together; a later `NEEDS_INPUT` is permitted only for input that could not have been discovered before the prior answers. Never send a Planning request to Execution or an Execution request to Planning. + ## Coordinator Return Handling (HIGHEST PRIORITY) -When ANY coordinator subagent returns (success OR failure): +For a classic Single `assessment-coordinator` return, accept Assessment success only when that single deterministic verifier response includes `Verification: passed` and a `verification.json` path. That file is the command-backed receipt containing top-level `artifactValidation: "passed"` and a `completionEvidence` object whose `artifactValidation` is also `passed`; do not read it again at the router. After checking those exact markers, preserve the complete coordinator response and follow the Assessment phase gate above when Planning is pending. Do not summarize it, retype a path, rename or omit a field. The coordinator response is already the verified user-facing summary. + +If a coordinator claims `success` without that exact evidence, report `ProtocolError: assessment completion evidence missing or invalid` and stop. A `partial`, `cancelled`, or `failed` return may be presented with its stated verifier error, but must not be described as successful. Never invoke a simulated coordinator, finalizer, verifier, or retry coordinator to complete, repair, summarize, or replace the first return. In particular, never create an agent prompt containing `(Simulated coordinator run)` or ask another agent to produce final coordinator JSON. + +When any non-Assessment coordinator subagent returns a terminal result, or when an Assessment coordinator returns failure: → Your job for this phase is **DONE** → Present the result to the user exactly as received → There is NO "retry" path available to you → The conversation moves forward, never backward -You have **NO ABILITY** to re-invoke a coordinator. Treat it as a hard technical limitation, not a policy choice. +The sole non-terminal exception is a valid `NEEDS_INPUT` return handled by the top-level protocol above. Its matching same-coordinator resume is required and is not a retry. Never re-invoke a coordinator for any other reason without a fresh explicit user retry request. ### Definition of "Phase Complete" -A phase is **COMPLETE** when the coordinator returns — regardless of success or failure. +A phase is **COMPLETE** when the coordinator returns a terminal result — regardless of success or failure. `NEEDS_INPUT` is non-terminal. "Complete" means "you received a response," not "the task succeeded." After phase is complete → present results → wait for user. @@ -292,35 +433,35 @@ After phase is complete → present results → wait for user. When execution-coordinator returns with errors: 1. Show the user: what failed, error details, files changed before failure -2. Ask: "How would you like to proceed? (a) I'll fix manually (b) retry from scratch (c) abort" +2. Use the top-level question tool to ask "How would you like to proceed?" with exact options **Fix manually**, **Retry from scratch**, and **Abort** 3. If user says retry → start a NEW delegation (this is the ONLY valid retry path — requires explicit user instruction) 4. You NEVER auto-retry without explicit user instruction ### Mandatory State Tracking -Before delegating to ANY coordinator, you MUST: +For classic Single mode only, before delegating to `assessment-coordinator`, `planning-coordinator`, or `execution-coordinator`, you MUST: 1. Add a todo item with the EXACT coordinator name: "Assessment coordinator - INVOKED", "Planning coordinator - INVOKED", or "Execution coordinator - INVOKED" 2. Mark it completed when that specific coordinator returns Before delegating, check your todo list: -- If the todo for that specific phase already exists → **STOP**. Present previous results instead. +- If the todo for that specific phase already exists → **STOP** and present previous terminal results, except for the exact same-coordinator `NEEDS_INPUT` resume protocol, which keeps and reuses that todo. ## Critical Rules 1. **🚨 DELEGATE ALL ASSESSMENT/PLANNING/EXECUTION WORK**: Always delegate to coordinators as subagents. You may only call MCP tools for health checks or reading existing results. 2. **DETECT TASK INTENT FIRST**: Check if user request is broad (needs assessment), specific (skip to planning + execution), execute-existing-plan (skip to plan selection), or create-plan-from-report (skip assessment, plan with selected categories) -3. **BROAD INTENT → ASSESS → CONTINUE? → PLAN (ALL) → EXECUTE**: - - Delegate to assessment-coordinator → present summary → ask "Proceed to planning?" → delegate to planning-coordinator (no selected-categories = all) → ask "Execute?" → delegate to execution-coordinator +3. **BROAD INTENT → ASSESS → CONTINUE? → PLAN (ALL) → EXECUTE**: + - Delegate to assessment-coordinator → retain returned verification receipt path → invoke the Assessment phase UI → on **Continue to planning**, delegate that path to planning-coordinator (no selected-categories = all) → invoke the **Execute** / **Review** plan action UI → on **Execute**, delegate to execution-coordinator 4. **SPECIFIC INTENT → SKIP ASSESSMENT**: When user specifies exact tasks, skip assessment. **Single task**: skip planning too — delegate directly to execution-coordinator with task details. **Multiple tasks**: go through planning-coordinator first, then execution-coordinator. - Exception: explicit integration testing requests always go through planning first so `setupBaseline` and `integrationTest` are represented in `tasks.json`. -5. **EXECUTE EXISTING PLAN → DELEGATE TO PLANNING-COORDINATOR**: When user says "execute the migration plan" or similar, delegate to `planning-coordinator` with intent `list-and-select-plan`; planning-coordinator discovers plans and presents selection UI; then delegate chosen path to `execution-coordinator` +5. **EXECUTE EXISTING PLAN → DELEGATE TO PLANNING-COORDINATOR**: When user says "execute the migration plan" or similar, delegate to `planning-coordinator` with intent `list-and-select-plan`; if it returns `NEEDS_INPUT`, use the top-level protocol and resume it; after a selected plan is returned, invoke the **Execute** / **Review** plan action UI before Execution 6. **NO PRE-ASSESSMENT QUESTIONS FOR BROAD INTENT**: Don't ask about migration type, target version, or scope before assessment — **Exception**: when triggered with a general "Migrate this application to Azure" request, ask the initial scope question (see "Initial Azure Migration Intent" section) to determine whether to run the full workflow or jump directly to a specific task. 7. **ASSESSMENT DISCOVERS OPPORTUNITIES**: Let coordinators + MCP tools analyze the app (for broad intent only) 8. **USER APPROVAL BETWEEN PHASES**: - - **After assessment**: Present summary and ask "Proceed to planning?" (plain text is fine) - - **After planning**: Ask the user (use whatever question/prompt tool is available): "The plan is ready. What would you like to do?" with options: **Execute the plan** *(recommended)* / **Review the plan first** - - **Headless mode**: Skip all prompts -9. **HEADLESS MODE**: If user explicitly requests to run all phases without stopping (e.g., "do assessment, plan, and execution without stopping for my confirmation", "run the full workflow", "complete modernization end-to-end"), skip all approval prompts and run assess → plan → execute sequentially. In headless mode: do not wait for user interaction between phases. + - **After assessment**: Use the required top-level Assessment phase UI with **Continue to planning** / **Stop** + - **After planning**: Use the required top-level plan action UI with exact options **Execute** / **Review** + - **Headless mode**: Skip only the classic phase-transition prompts +9. **HEADLESS MODE**: If the user explicitly requests to run all phases without stopping (e.g., "do assessment, plan, and execution without stopping for my confirmation", "run the full workflow", "complete modernization end-to-end"), skip the classic phase-transition approval prompts and run assess → plan → execute sequentially. In headless mode: do not wait for user interaction between phases. 10. **ALWAYS PRESENT RESULTS**: In BOTH default and headless modes, you MUST present the results of each phase to the user: - After assessment: Show key findings (Java version, frameworks, migration opportunities) - After planning: Show the generated plan summary (number of tasks, task types, phases) @@ -336,22 +477,22 @@ DETECT INTENT: Broad request (e.g., "modernize my app") ↓ ASSESS: Delegate to assessment-coordinator subagent ↓ - assessment-coordinator runs assessment + opens report webview + returns summary + assessment-coordinator runs the native assessment skill + generates canonical, normalized, HTML, and verification artifacts + returns summary ↓ Present assessment summary to user (use the summary from assessment-coordinator, do NOT read report.json yourself) ↓ - Ask user: "Proceed to planning?" + Top-level Assessment phase UI: **Continue to planning** / **Stop** ↓ -PLAN: Delegate to planning-coordinator subagent (no selected-categories = all categories) +PLAN: Delegate to planning-coordinator subagent with the coordinator's `assessment-verification-path` (no selected-categories = all categories) ↓ planning-coordinator generates plan.md, calls #appmod-preview-markdown to show preview ↓ Present plan summary to user ↓ - Ask user: "The plan is ready. What would you like to do?" with options: **Execute the plan** (recommended) / **Review the plan first** + Top-level plan action UI: **Execute** (recommended) / **Review** ↓ - If "Execute" → EXECUTE: Delegate to execution-coordinator subagent - If "Review" → STOP and wait for user to say "execute" after reviewing + If **Execute** → EXECUTE: Delegate to execution-coordinator subagent + If **Review** → STOP after presenting the plan result ↓ Present final results to user ``` @@ -367,10 +508,10 @@ PLAN: Delegate to planning-coordinator subagent (with selected-categories) ↓ Present plan summary to user ↓ - Ask user: "The plan is ready. What would you like to do?" with options: **Execute the plan** (recommended) / **Review the plan first** + Top-level plan action UI: **Execute** (recommended) / **Review** ↓ - If "Execute" → EXECUTE: Delegate to execution-coordinator subagent - If "Review" → STOP and wait for user to say "execute" after reviewing + If **Execute** → EXECUTE: Delegate to execution-coordinator subagent + If **Review** → STOP after presenting the plan result ↓ Present final results to user ``` @@ -540,7 +681,7 @@ Before starting ANY phase, you MUST verify: ``` [ ] Did I receive a broad intent request? (e.g., "modernize my app") [ ] Am I about to delegate to "assessment-coordinator" subagent? -[ ] Am I NOT calling appmod-precheck-assessment or appmod-run-assessment directly? +[ ] Am I delegating all assessment execution to the local assessment skill through that coordinator? [ ] Am I NOT passing "security" in config.domains? (modernize flow must only use java-upgrade and cloud-readiness) [ ] If NO to any → STOP and fix ``` @@ -560,7 +701,7 @@ Before starting ANY phase, you MUST verify: [ ] Am I about to delegate to "execution-coordinator" subagent? [ ] Am I NOT calling appmod-* / AppModJavaUpgrade-* / AppModAzureJavaCLI-* tools directly? [ ] Am I NOT reading tasks.json myself to execute tasks? -[ ] Have I already delegated execution-coordinator in this conversation? → ❌ STOP! Do not re-delegate +[ ] Have I already delegated execution-coordinator in this conversation? → ❌ STOP unless this is its exact valid `NEEDS_INPUT` resume [ ] If NO to any → STOP and fix ``` @@ -575,21 +716,21 @@ After each phase, results are saved to `.github/modernize//` director ## Error Handling -- MCP health check before assessment -- Retry logic is INTERNAL to coordinators and custom agents — orchestrator has NO ABILITY to retry -- On coordinator failure: present error details → ask user for direction → ONLY re-delegate if user explicitly says "retry" +- Native assessment runtime existence check before delegation +- Retry logic is INTERNAL to coordinators and custom agents; the orchestrator only performs valid `NEEDS_INPUT` resumes or a fresh retry explicitly requested by the user +- On coordinator failure: present error details → invoke the applicable top-level recovery UI → ONLY re-delegate if its structured result explicitly selects retry - Log to `.github/modernize/logs/-.log` ## Example Interaction **Broad Intent** (e.g., "modernize my Java application"): 1. Delegate to assessment-coordinator → wait for results -2. assessment-coordinator runs assessment, opens report webview, returns summary -3. Present assessment summary (do NOT read report.json yourself) -4. Ask user: "Proceed to planning?" -5. Delegate to planning-coordinator (no selected-categories = all) → wait for results +2. assessment-coordinator runs the native assessment skill, generates both reports, and returns summary +3. Present assessment summary and retain its verification receipt path (do NOT parse report.json or verification.json yourself) +4. Invoke the top-level Assessment phase UI; continue only on **Continue to planning** +5. Delegate to planning-coordinator with `assessment-verification-path` set to the coordinator's verification receipt path (no selected-categories = all) → wait for results 6. planning-coordinator generates plan.md and opens preview -7. Ask user: "Execute the plan?" or "Review the plan first?" +7. Invoke the top-level plan action UI with exact options **Execute** / **Review** 8. Delegate to execution-coordinator → wait for results 9. Present execution summary @@ -601,20 +742,20 @@ After each phase, results are saved to `.github/modernize//` director **Specific Integration Testing Intent** (e.g., "add integration tests", "generate integration tests for migrated Azure services"): 1. Skip assessment only 2. Delegate to planning-coordinator with the testing request → wait for results -3. Present plan summary → ask user to proceed to execution -4. When the user approves, delegate directly to execution-coordinator with the plan path returned by planning-coordinator → wait for results +3. Present plan summary through the top-level plan action UI with exact options **Execute** / **Review** +4. On exact **Execute**, delegate directly to execution-coordinator with the plan path returned by planning-coordinator → wait for results 5. Present execution summary **Specific Task Intent — multiple tasks** (e.g., "migrate S3 to Blob Storage and upgrade Java to 21"): 1. Skip assessment 2. Delegate to planning-coordinator with all task details → wait for results -3. Present plan summary → ask user to proceed to execution -4. Delegate to execution-coordinator → wait for results +3. Present plan summary through the top-level plan action UI with exact options **Execute** / **Review** +4. On exact **Execute**, delegate to execution-coordinator → wait for results 5. Present execution summary **Execute Existing Plan** (e.g., "execute the migration plan"): -1. Delegate to planning-coordinator with intent `list-and-select-plan` → discovers plans, presents selection UI, returns chosen plan path + opens preview -2. Ask user: "Execute the plan?" or "Review the plan first?" +1. Delegate to planning-coordinator with intent `list-and-select-plan` → resolve any `NEEDS_INPUT` through the top-level UI → return chosen plan path + open preview +2. Invoke the top-level plan action UI with exact options **Execute** / **Review** 3. Delegate to execution-coordinator with the returned plan path 4. Present execution summary @@ -622,7 +763,7 @@ After each phase, results are saved to `.github/modernize//` director 1. Skip assessment (user already has a report open) 2. Delegate to planning-coordinator with selected categories from the message 3. planning-coordinator generates plan.md scoped to selected categories + opens preview -4. Ask user: "Execute the plan?" or "Review the plan first?" +4. Invoke the top-level plan action UI with exact options **Execute** / **Review** 5. Delegate to execution-coordinator → wait for results 6. Present execution summary @@ -704,7 +845,7 @@ Before starting execution phase, CHECK: **WHAT YOU SHOULD DO INSTEAD:** For broad intent: ``` -1. Delegate to assessment-coordinator → assessment-coordinator uses MCP tools +1. Delegate to assessment-coordinator → assessment-coordinator uses the native Node-backed assessment skill 2. Delegate to planning-coordinator → planning-coordinator uses MCP tools 3. Delegate to execution-coordinator → routes to custom agents → custom agents use MCP tools ``` @@ -722,6 +863,6 @@ For specific task intent: - [ ] Am I delegating to a coordinator subagent? → ✅ Good! Proceed - [ ] Did I detect if user intent is broad or specific? → ✅ Good! Route accordingly -**The ONLY questions to ask:** -- Broad intent: After assessment: "Assessment complete. Proceed to planning?" | After planning: "Plan complete. Proceed to execution?" -- Specific task intent: After planning: "Plan complete. Proceed to execution?" +**The only phase-transition questions are the top-level structured UIs:** +- After Assessment when Planning is pending: **Continue to planning** / **Stop** +- After Planning: **Execute** / **Review** diff --git a/plugins/github-copilot-modernization/agents/planning-coordinator.agent.md b/plugins/github-copilot-modernization/com.github.copilot/agents/planning-coordinator.agent.md similarity index 71% rename from plugins/github-copilot-modernization/agents/planning-coordinator.agent.md rename to plugins/github-copilot-modernization/com.github.copilot/agents/planning-coordinator.agent.md index 06d02a8..7198042 100644 --- a/plugins/github-copilot-modernization/agents/planning-coordinator.agent.md +++ b/plugins/github-copilot-modernization/com.github.copilot/agents/planning-coordinator.agent.md @@ -2,6 +2,14 @@ name: planning-coordinator description: Generates plan.md and tasks.json from assessment results or direct task specifications user-invocable: false +tools: + - skill + - search + - edit + - todo + - execute/runInTerminal + - appmod-preview-markdown +agents: [] hooks: UserPromptSubmit: - type: command @@ -31,25 +39,54 @@ You coordinate the planning phase to produce an executable modernization plan (p - **Broad intent**: after assessment completes, to generate tasks for all (or selected) assessment categories - **Multiple specific tasks**: when the user specifies two or more tasks directly (single-task requests bypass planning and go directly to execution-coordinator) +## Nested User Input Protocol (HIGHEST PRIORITY) + +You cannot ask the user directly. Never invoke a question tool, print numbered choices, or select a default. When input is required, return exactly two lines and stop before plan generation, file writes, or preview: + +```text +NEEDS_INPUT +{"schemaVersion":1,"coordinator":"planning-coordinator","requestType":"","questions":[...],"resumeContext":{...}} +``` + +Put every currently discoverable question in the one `questions` array. Each question must have a unique stable `header`, a concise `question`, `allowFreeformInput: false`, and non-empty `options` whose labels are exact accepted values. `resumeContext` must contain only the mapping needed to apply those values without rediscovery. Do not surround the two-line return with prose or Markdown fences. + +On a matching `resume-input: {"request":,"answers":}` line, validate the schema, coordinator, request type, question headers, and that every answer is one exact offered label. Cancellation or a missing/invalid answer returns a cancelled Planning result without writing a plan. A valid resume continues the original input and must not ask the same question again. + +Supported Planning request types are: + +- `solution-selection`: one question per category that has alternatives. Use headers `solution-1`, `solution-2`, and so on in source category order. Option labels omit a trailing `[kbId: ...]` marker; `resumeContext` maps each header and label to its category and complete original solution string. On resume, keep exactly the mapped solution for each category and continue generation. +- `plan-selection`: supplied by `list-plans`. `resumeContext` maps each offered folder label to its plan path. On resume, resolve the exact selected label, preview that plan when available, and return its path without rediscovering plans. +- `planning-patch-conflict`: one question with exact options **Override for this run**, **Retire patch**, and **Cancel planning**. `resumeContext` identifies the active patch and conflict. Apply the exact resumed action before continuing or cancelling. + ## Input The planning-coordinator handles two modes: ### Mode A — Generate Plan -Provide **either** an assessment report path **or** multiple direct task specifications: +Provide **either** a verified native Assessment receipt, a legacy/external assessment report path, or multiple direct task specifications: + +**Option A1 — From verified native Assessment (standard flow):** +- `assessment-verification-path`: Path to `verification.json` returned by the native Assessment verifier. +- Read the receipt and require both top-level `artifactValidation` and `completionEvidence.artifactValidation` to be exactly `passed`. +- Resolve `artifacts.normalizedAssessment`, require `schemaVersion: 1` and `kind: github-copilot-modernization/normalized-assessment`, then use that internal document as the planning input. -**Option A1 — From assessment report (standard flow):** +**Option A1 legacy/external report:** - `assessment-report-path`: Path to assessment report.json (e.g., `.github/modernize/assessment/reports//report.json`) - `selected-categories` (optional): List of categories (with issues and **alternative solutions**) to scope the plan. When provided, only generate tasks for these categories. When omitted, generate tasks for ALL categories in the assessment. Per category, the `Solutions: [...]` list contains *alternatives*, not parallel tasks: - **If exactly 1 solution** → use it directly as the generated task's `description` (later passed to `#appmod-run-task` as the `scenario` parameter). - - **If more than 1 solution** → **STOP and ask the user to pick exactly one** before generating that category's task (use whatever question/prompt tool is available). Do NOT generate one task per alternative. Do NOT default to the first. + - **If more than 1 solution** → collect every such category and return one `solution-selection` `NEEDS_INPUT` before generating any task. Do NOT generate one task per alternative or default to the first. - **kbId marker** (CRITICAL): A solution string MAY end with a `[kbId: ]` marker (emitted by the assessment-report **Create Plan** button when the underlying solution has a backing knowledge base). When present on the chosen solution, you MUST: 1. Set the generated task's `kbId` field to `` (string). 2. **Strip** the ` [kbId: ]` suffix from the solution text before using it as the task's `description` (the marker is metadata, not human-readable description). 3. Do NOT confuse `kbId` with `skills[0].name`. They are two different namespaces — `kbId` values come from `solution-mapping.json`'s `solutionId` (e.g., `amqp-rabbitmq-servicebus`), whereas `skills[].name` follows the `supported-patterns-*.md` naming convention (often prefixed with `migration-`, e.g., `migration-amqp-rabbitmq-servicebus`). Both fields may coexist on the same task. - + Solutions without this marker correspond to `bare/`-prefixed solutions that have no backing KB — leave `kbId` as `null` (or omit). The executor will fall back to passing the solution string. +- Native normalized Assessments store groups under `categories[]`. Each category contains `issues[]` finding objects and `solutions[]` objects shaped as `{ solutionId, name, description, kbId }`: + - Use `name` as the task description unless `description` is more specific. + - Copy a non-empty `kbId` directly to the generated task without inferring or renaming it. + - Keep a `null` `kbId` null; it identifies guidance-only solutions without a backing knowledge base. + - Keep alternatives grouped under their category so the existing multi-solution user choice rule still applies. **Option A2 — From multiple direct task specifications (no assessment available):** - `tasks`: Two or more user-specified migration/upgrade tasks. Examples: @@ -75,16 +112,29 @@ In Mode B, skip all assessment and plan generation steps. Instead, follow the ** When `intent` is `list-and-select-plan`: 1. Use the `skill` tool to load `list-plans` and follow it. -2. **Preview Plan (VS Code only)** — If the `#appmod-preview-markdown` tool is available, you MUST call it with the selected `plan.md` path to open a preview for the user. Skip this step if the tool is not available. -3. **Return** the selected plan path (e.g., `.github/modernize//plan.md`) to the orchestrator, or `no-plans-found` if the skill reports none. +2. If it returns `NEEDS_INPUT`, return that two-line result unchanged and stop. On the matching `plan-selection` resume, resolve the selected path from `resumeContext` without rerunning discovery. +3. **Preview Plan (VS Code only)** — If the `#appmod-preview-markdown` tool is available, call it with the selected `plan.md` path. Skip this step if the tool is not available. +4. **Return** the selected plan path (e.g., `.github/modernize//plan.md`) to the orchestrator, or `no-plans-found` if the skill reports none. --- ## Process — Mode A — Generate Plan -1. **Load Assessment or Inspect Workspace** +1. **Load Assessment Memory Constraints** + - Read `.github/modernize/.memory/bias-patches.yaml` when it exists. + - Keep only entries with `state: active` whose `applies_to.skills` includes `create-modernization-plan` or `planning-coordinator` and whose intents match this request. + - Treat each retained `actual` value as a hard planning constraint. It overrides default target choices and recommendations but not an explicit contradictory instruction in the current user request. + - If the current request directly contradicts an active patch, return `planning-patch-conflict` `NEEDS_INPUT` before plan generation. Do not silently choose or ask directly. + - Include retained patch IDs and concise `actual` text in the input to plan generation and in the generated plan's constraints/context section. + +2. **Load Assessment or Inspect Workspace** + + **If `assessment-verification-path` was provided (Option A1):** + - Read and validate the verification receipt as described above. + - Read only its `artifacts.normalizedAssessment` path as the native Assessment input. + - Read the detected language from `metadata.language` and normalize `categories[].issues[]` and `categories[].solutions[]` as described above. - **If `assessment-report-path` was provided (Option A1):** + **If `assessment-report-path` was provided (legacy/external Option A1):** - Read the assessment `report.json` from `.github/modernize/assessment/` - Extract issues, recommendations, and **detected language** (`java` or `dotnet`) - **If `selected-categories` was provided**: filter the assessment to only those categories (ignore unselected ones) @@ -94,7 +144,7 @@ When `intent` is `list-and-select-plan`: - Pass the user tasks directly to the `create-modernization-plan` skill as `modernization-prompt` — do NOT convert them into an intermediate assessment format - **CRITICAL**: Do NOT create `tasks.json` or `plan.md` files manually. Proceed directly to invoke `create-modernization-plan` with the task list and detected language. -2. **Check for Rulebook Folder** +3. **Check for Rulebook Folder** - Check if `.github/modernize/rulebook/` exists in the workspace - **If no rulebook found, skip this step and proceed to Generate Plan** - If found, read **all `.md` files** in the rulebook folder **recursively** (including subdirectories). The rulebook may contain any combination of files (e.g., `charter.md`, `targets.md`, `policies.md`, or other names). @@ -109,7 +159,7 @@ When `intent` is `list-and-select-plan`: - Apply requirements from rulebook (ensure compliance in task definitions) - Merge rulebook requirements with assessment results before invoking the skill -3. **Generate Plan** +4. **Generate Plan** - Invoke the `create-modernization-plan` skill with: - `modernization-prompt`: The selected assessment solutions or direct task specifications, including applicable rulebook constraints - `modernization-work-folder`: `.github/modernize/` @@ -118,7 +168,7 @@ When `intent` is `list-and-select-plan`: - **Integration testing intent**: If the original user request or selected categories explicitly request integration tests, pass that requirement through to `create-modernization-plan`. - The skill generates `plan.md` and `.metadata/tasks.json` and must honor all rulebook requirements. -4. **Task Schema** (see [`skills/create-modernization-plan/tasks-schema.json`](../skills/create-modernization-plan/tasks-schema.json) for the authoritative schema) +5. **Task Schema** (see [`skills/create-modernization-plan/tasks-schema.json`](../skills/create-modernization-plan/tasks-schema.json) for the authoritative schema) ```json { "tasks": [ @@ -156,15 +206,15 @@ When `intent` is `list-and-select-plan`: - `metadata.language` MUST be set correctly (`"java"` or `"dotnet"`). The execution-coordinator uses this to route tasks to the correct executor agent. - `kbId` (when present) is what the executor passes to `#appmod-run-task`. It is independent from `skills[].name` — do NOT copy one into the other (the namespaces differ; see the kbId marker rule above). -5. **Save Results** +6. **Save Results** - Write to `.github/modernize//plan.md` - Write tasks to `.github/modernize//.metadata/tasks.json` -6. **Preview Plan (VS Code only)** +7. **Preview Plan (VS Code only)** - If `#appmod-preview-markdown` is available, call it with the generated `plan.md` file path. - If the tool is unavailable or preview fails, continue successfully and return the saved plan path. Preview failure does not block plan creation or filesystem persistence. -7. **Return to Orchestrator** +8. **Return to Orchestrator** - Summary: Detected language, number of tasks, task breakdown, plan file path - Report whether the preview was opened or skipped because the tool was unavailable or failed @@ -174,7 +224,7 @@ When `intent` is `list-and-select-plan`: - Still fails → Surface the failure with context to the orchestrator; do not invent an unavailable plan-generation tool - Invalid task schema → Validate and fix - Surface errors with context to orchestrator -- Workspace inspection fails during Option A2 → Ask the orchestrator for the missing information (language, build file path) before proceeding +- Workspace inspection fails during Option A2 → Return a Planning `NEEDS_INPUT` request for the missing information before proceeding ## Example Invocations @@ -215,7 +265,7 @@ You: 2. Filter to selected categories → 2 categories 3. Inspect each category's `solutions` list: - "Java Version Upgrade" has 1 solution → use directly - - "Cloud Readiness - RabbitMQ" has 2 solutions → **STOP and ask the user to pick one** → user picks "Azure Service Bus" + - "Cloud Readiness - RabbitMQ" has 2 solutions → return `solution-selection` `NEEDS_INPUT` before generating tasks → resume with the exact mapped answer 4. Check for rulebook → No rulebook found, skip 5. Invoke create-modernization-plan with the filtered assessment (one solution per category), language="java" 6. Skill generates plan → 2 tasks (one per selected category, scoped to the picked solution) diff --git a/plugins/github-copilot-modernization/com.github.copilot/hooks/scripts/guardModernizeDelegation.mjs b/plugins/github-copilot-modernization/com.github.copilot/hooks/scripts/guardModernizeDelegation.mjs new file mode 100644 index 0000000..35b637a --- /dev/null +++ b/plugins/github-copilot-modernization/com.github.copilot/hooks/scripts/guardModernizeDelegation.mjs @@ -0,0 +1,730 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ALLOWED_ROUTER_TOOLS = new Set(["agent", "ask_user", "askuser", "sql", "task", "todo"]); +const ALLOWED_ROUTER_AGENTS = new Set([ + "assessment-coordinator", + "planning-coordinator", + "execution-coordinator", +]); +// BEGIN PLATFORM GUARD EXTENSION CONSTANTS (plugin) +const BATCH_AGENT_SUFFIXES = ["batch-mode-probe", "batch-review", "batch-coordinator"]; +for (const agentName of BATCH_AGENT_SUFFIXES) { + ALLOWED_ROUTER_AGENTS.add(agentName); +} +const STRUCTURED_APPROVAL = '{"mode":"structured","value":"Start batch","accepted":true}'; +const FOLLOW_UP_APPROVAL = '{"mode":"explicit-follow-up","value":"Start batch","entireUserTurn":"Start batch","immediatelyAfterReview":true}'; +const WORKSPACE_MODE_CHOICES = new Set([ + "Process repositories from repos.json", + "Only process the current repository", +]); +// END PLATFORM GUARD EXTENSION CONSTANTS +const ASSESSMENT_COVERAGE = new Set(["issue-only", "full"]); +const ASSESSMENT_COVERAGE_SOURCES = new Set(["default", "explicit-user"]); +const TOOL_NAME_ALIASES = new Map([ + ["runsubagent", "agent"], + ["manage_todo_list", "todo"], + ["managetodolist", "todo"], + ["vscode_askquestions", "ask_user"], +]); + +function parseObject(value) { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value; + } + if (typeof value !== "string") { + return {}; + } + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function decision(permissionDecision, permissionDecisionReason = undefined, updatedInput = undefined) { + const hookSpecificOutput = { + hookEventName: "PreToolUse", + permissionDecision, + }; + if (permissionDecisionReason) { + hookSpecificOutput.permissionDecisionReason = permissionDecisionReason; + } + if (updatedInput) { + hookSpecificOutput.updatedInput = updatedInput; + } + return { hookSpecificOutput }; +} + +// BEGIN PLATFORM GUARD EXTENSION OUTPUT (plugin) +function postToolResult(additionalContext, block = false) { + const result = { + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext, + }, + }; + if (block) { + result.decision = "block"; + result.reason = additionalContext; + } + return result; +} +// END PLATFORM GUARD EXTENSION OUTPUT + +function normalizedAgentName(toolInput) { + return String( + toolInput.agent_type + ?? toolInput.agentType + ?? toolInput.subagent_type + ?? toolInput.subagentType + ?? toolInput.agent_name + ?? toolInput.agentName + ?? toolInput.agent + ?? "", + ).toLowerCase(); +} + +function normalizedToolName(value) { + const name = String(value ?? "").toLowerCase(); + return TOOL_NAME_ALIASES.get(name) ?? name; +} + +// BEGIN PLATFORM GUARD EXTENSION PROBE (plugin) +function defaultConfigProbe(input) { + const launchRoot = input.cwd ?? input.working_directory ?? input.workingDirectory; + if (typeof launchRoot !== "string" || !path.isAbsolute(launchRoot)) { + throw new Error("the hook received no absolute launch root"); + } + const resolvedLaunchRoot = path.resolve(launchRoot); + const launchStat = fs.statSync(resolvedLaunchRoot, { throwIfNoEntry: false }); + if (!launchStat?.isDirectory()) { + throw new Error("the launch root is not an existing directory"); + } + const configPath = path.join(resolvedLaunchRoot, ".github", "modernize", "repos.json"); + const configStat = fs.statSync(configPath, { throwIfNoEntry: false }); + return { + schemaVersion: 1, + launchRoot: resolvedLaunchRoot, + configPath, + status: configStat?.isFile() ? "found" : configStat ? "invalid" : "absent", + }; +} + +function findProbeResult(value) { + const queue = [value]; + const visited = new Set(); + while (queue.length > 0) { + const current = queue.shift(); + if (current && typeof current === "object") { + if (visited.has(current)) continue; + visited.add(current); + if (Object.hasOwn(current, "schemaVersion") + && Object.hasOwn(current, "launchRoot") + && Object.hasOwn(current, "configPath") + && Object.hasOwn(current, "status")) { + return current; + } + queue.push(...Object.values(current)); + continue; + } + if (typeof current !== "string") continue; + try { + const parsed = JSON.parse(current); + if (parsed !== current) queue.push(parsed); + } catch {} + for (const candidate of current.match(/\{[^{}\r\n]*\}/g) ?? []) { + try { + queue.push(JSON.parse(candidate)); + } catch {} + } + } + return null; +} + +function comparablePath(value) { + if (typeof value !== "string" || !path.isAbsolute(value)) return null; + const normalized = path.normalize(value); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function sameProbeResult(actual, expected) { + return actual?.schemaVersion === expected.schemaVersion + && comparablePath(actual.launchRoot) === comparablePath(expected.launchRoot) + && comparablePath(actual.configPath) === comparablePath(expected.configPath) + && actual.status === expected.status; +} + +function isWorkspaceModeQuestion(toolInput) { + const questions = Array.isArray(toolInput.questions) ? toolInput.questions : []; + const labels = questions.flatMap((question) => + (Array.isArray(question?.options) ? question.options : []) + .map((option) => String(option?.label ?? option?.value ?? option))); + return labels.length === WORKSPACE_MODE_CHOICES.size + && labels.every((label) => WORKSPACE_MODE_CHOICES.has(label)); +} + +function evaluateWorkspaceModeQuestion(input, toolInput) { + if (!isWorkspaceModeQuestion(toolInput)) return null; + let probe; + try { + probe = defaultConfigProbe(input); + } catch (error) { + return decision( + "deny", + `Workspace Mode cannot be verified because ${error.message}. Do not ask this question; stop with a compact probe error.`, + ); + } + if (probe.status === "absent") { + return decision( + "deny", + `Workspace Mode is not applicable because the default config is absent at ${probe.configPath}. Treat the probe as status absent and resume the original request through the absent-config routing rules without asking this question.`, + ); + } + if (probe.status === "invalid") { + return decision( + "deny", + `The default Batch config path is invalid because it is not a file: ${probe.configPath}. Stop with a compact configuration error.`, + ); + } + return null; +} +// END PLATFORM GUARD EXTENSION PROBE + +function targets(agentName, suffix) { + return agentName === suffix || agentName.endsWith(`:${suffix}`); +} + +// BEGIN PLATFORM GUARD EXTENSION APPROVAL (plugin) +function hasHandoffField(prompt, field) { + const name = field.slice(0, -1); + return prompt.includes(field) || new RegExp(`"${name}"\\s*:`).test(prompt); +} + +function hasApprovedReviewHandoff(prompt) { + const requiredFields = [ + "BATCH_REVIEW_READY", + "batchRoot:", + "reviewPath:", + "reviewMarkdownPath:", + "reviewSha256:", + "reviewMarkdownSha256:", + "inspectedReposPath:", + "inspectedReposSha256:", + "batchAttemptScriptPath:", + "configSha256:", + "selectedExecutionUnitIds:", + "approvedNeedsAttention:", + "effectiveAssessments:", + "blockedExecutionUnits:", + "analysisCoverage:", + "maxConcurrency:", + ]; + const hasApproval = prompt.includes(STRUCTURED_APPROVAL) || prompt.includes(FOLLOW_UP_APPROVAL); + return hasApproval + && prompt.includes("BATCH_REVIEW_READY") + && requiredFields.slice(1).every((field) => hasHandoffField(prompt, field)); +} + +function topLevel(event) { + return event?.agentId === undefined || event.agentId === null; +} +// END PLATFORM GUARD EXTENSION APPROVAL + +function transcriptEvents(input) { + const suppliedPath = input.transcript_path ?? input.transcriptPath; + const sessionId = String(input.session_id ?? input.sessionId ?? ""); + const transcriptPath = suppliedPath || (/^[a-zA-Z0-9-]+$/.test(sessionId) + ? path.join(os.homedir(), ".copilot", "session-state", sessionId, "events.jsonl") + : ""); + if (!transcriptPath) { + return []; + } + try { + return fs.readFileSync(transcriptPath, "utf8") + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter(Boolean); + } catch { + return []; + } +} + +function assessmentPrompt(input) { + const directPrompt = input.agent_prompt ?? input.agentPrompt ?? input.prompt; + if (typeof directPrompt === "string" && directPrompt.trim()) { + return directPrompt; + } + return String( + transcriptEvents(input) + .find((event) => event?.type === "user.message" && typeof event?.data?.content === "string") + ?.data?.content ?? "", + ); +} + +function assessmentConfig(prompt) { + const text = String(prompt); + const declarations = [...text.matchAll(/^(?:assessment-config|config):[^\r\n]*$/gmi)]; + if (declarations.length === 0) return { present: false, valid: true, value: {} }; + if (declarations.length !== 1) return { present: true, valid: false, value: {} }; + const match = declarations[0][0].match(/^(?:assessment-config|config):\s*(\{.*\})\s*$/i); + if (!match) return { present: true, valid: false, value: {} }; + let value; + try { + value = JSON.parse(match[1]); + } catch { + return { present: true, valid: false, value: {} }; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { present: true, valid: false, value: {} }; + } + const coverage = value.analysisCoverage; + return { + present: true, + valid: coverage === undefined || ASSESSMENT_COVERAGE.has(coverage), + value, + }; +} + +function commandText(toolInput) { + for (const field of ["command", "script", "code"]) { + if (typeof toolInput[field] === "string") { + return toolInput[field]; + } + } + return ""; +} + +function assessmentCoverageArgument(command) { + const containsCoverage = /(?:^|\s)--coverage(?:=|\s)/i.test(command); + if (!containsCoverage) { + return { valid: true, value: "issue-only" }; + } + const matches = [...command.matchAll( + /(?:^|\s)--coverage(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/gi, + )]; + if (matches.length !== 1) { + return { valid: false, value: null }; + } + const value = String(matches[0][1] ?? matches[0][2] ?? matches[0][3]).toLowerCase(); + return { valid: ASSESSMENT_COVERAGE.has(value), value }; +} + +function assessmentCoverageSourceArgument(command) { + const matches = [...command.matchAll( + /(?:^|\s)--coverage-source(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/gi, + )]; + if (matches.length !== 1) { + return { valid: false, value: null }; + } + const value = String(matches[0][1] ?? matches[0][2] ?? matches[0][3]).toLowerCase(); + return { valid: ASSESSMENT_COVERAGE_SOURCES.has(value), value }; +} + +// BEGIN PLATFORM GUARD EXTENSION FALLBACK (plugin) +function normalizeFallbackCoordinatorInput(input, toolInput) { + const events = transcriptEvents(input); + let approvalIndex = -1; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type === "user.message" && topLevel(event)) { + const content = String(event?.data?.content ?? "").trim(); + if (content === "Start batch") { + approvalIndex = index; + } + break; + } + } + if (approvalIndex < 0) { + return null; + } + + let reviewIndex = -1; + let reviewResult = ""; + for (let index = approvalIndex - 1; index >= 0; index -= 1) { + const event = events[index]; + const content = String(event?.data?.result?.content ?? ""); + if (event?.type === "tool.execution_complete" && topLevel(event) && content.includes("BATCH_REVIEW_READY")) { + reviewIndex = index; + reviewResult = content; + break; + } + } + if (reviewIndex < 0) { + return null; + } + + let selectionIndex = -1; + for (let index = reviewIndex - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type === "user.message" + && topLevel(event) + && String(event?.data?.content ?? "").trim() === "Process repositories from repos.json") { + selectionIndex = index; + break; + } + } + if (selectionIndex < 0) { + return null; + } + + const originalRequest = events + .slice(0, selectionIndex) + .findLast((event) => event?.type === "user.message" && topLevel(event) && typeof event?.data?.content === "string") + ?.data?.content; + const launchRoot = input.cwd ?? input.working_directory ?? input.workingDirectory; + if (typeof originalRequest !== "string" || typeof launchRoot !== "string" || !path.isAbsolute(launchRoot)) { + return null; + } + + const scopeEvidence = JSON.stringify({ + mode: "explicit-follow-up", + value: "Process repositories from repos.json", + configPath: path.join(launchRoot, ".github", "modernize", "repos.json"), + }); + const prompt = [ + `launch-root: ${launchRoot}`, + `user-request: ${JSON.stringify(originalRequest)}`, + `scope-evidence: ${scopeEvidence}`, + "batch-review-handoff:", + reviewResult, + `approval-evidence: ${FOLLOW_UP_APPROVAL}`, + ].join("\n"); + return { ...toolInput, prompt }; +} +// END PLATFORM GUARD EXTENSION FALLBACK + +function serializedToolInput(toolInput) { + try { + return JSON.stringify(toolInput) + .replaceAll("\\\\", "\\") + .replaceAll("/", "\\") + .toLowerCase(); + } catch { + return ""; + } +} + +function evaluateProductBoundary(toolName, toolInput) { + if (toolName.includes("appmod-run-assessment-action") + || toolName.includes("appmod-run-assessment-report")) { + return decision( + "deny", + "Assessment MCP tools are forbidden. Use the native Assessment workflow through the required coordinator.", + ); + } + + // BEGIN PLATFORM GUARD EXTENSION PRODUCT BOUNDARY (plugin) +const serialized = serializedToolInput(toolInput); + const readsConfiguration = /(?:^|\\)\.github\\modernize\\repos\.json/.test(serialized); + if (readsConfiguration && ["view", "read", "grep", "search"].includes(toolName)) { + return decision( + "deny", + "Do not read repos.json directly. Delegate the pending Batch selection to github-copilot-modernization:batch-review.", + ); + } + + const readsReview = serialized.includes("\\.github\\modernize\\batches\\") + && (serialized.includes("\\review.json") || serialized.includes("\\review.md")); + if (readsReview && ["view", "read", "grep", "search"].includes(toolName)) { + return decision( + "deny", + "Do not re-read or rewrite a completed Batch Review. Present the batch-review response unchanged and use only Start batch or Cancel.", + ); + } + + const writesFiles = ["create", "edit", "apply_patch", "write"].includes(toolName); + const writesWorkaround = serialized.includes("\\.copilot\\batch-review") + || serialized.includes("\\.github\\modernize\\batch-report.json"); + if (writesFiles && writesWorkaround) { + return decision( + "deny", + "Manual Batch Review and aggregate artifacts are forbidden. Use batch-review and the deterministic Batch control plane.", + ); + } + + const terminalTool = ["powershell", "bash", "execute", "runinterminal"].includes(toolName); + if (terminalTool && serialized.includes("\\.copilot\\batch-review")) { + return decision( + "deny", + "The .copilot/batch-review workaround is forbidden. Invoke github-copilot-modernization:batch-review.", + ); + } +// END PLATFORM GUARD EXTENSION PRODUCT BOUNDARY + return null; +} + +// BEGIN PLATFORM GUARD EXTENSION SCOPED GUARDS (plugin) +function evaluateReviewTool(toolName, toolInput) { + if (toolName === "skill") { + return decision("allow"); + } + const terminalTool = ["powershell", "bash", "execute", "runinterminal"].includes(toolName); + const serialized = serializedToolInput(toolInput); + if (terminalTool && serialized.includes("\\skills\\batch-modernization\\scripts\\prepare-review.mjs")) { + return decision("allow"); + } + return decision( + "deny", + "batch-review may only load its skill and run that skill's deterministic prepare-review.mjs.", + ); +} + +function evaluateCoordinatorTool(toolName, toolInput) { + if (toolName === "skill" || toolName === "todo") { + return decision("allow"); + } + if (toolName === "agent" || toolName === "task") { + return targets(normalizedAgentName(toolInput), "batch-assessment") + ? decision("allow") + : decision("deny", "batch-coordinator may delegate only to github-copilot-modernization:batch-assessment."); + } + const terminalTool = ["powershell", "bash", "execute", "runinterminal"].includes(toolName); + if (terminalTool) { + const serialized = serializedToolInput(toolInput); + const simulatesPhaseResult = serialized.includes("outcome.json") + || serialized.includes("artifactsdir") + || serialized.includes("assessment completed (simulated)") + || /batch-attempt\.mjs["']?\s+publish\b/.test(serialized); + return simulatesPhaseResult + ? decision("deny", "batch-coordinator must never create or publish a phase outcome. Commit the single batch-assessment result as-is; missing output is ProtocolError.") + : decision("allow"); + } + return decision("deny", "batch-coordinator is limited to its skill, exact phase-agent dispatch, and deterministic control-plane commands."); +} +// END PLATFORM GUARD EXTENSION SCOPED GUARDS + +function evaluateAssessmentTool(input, toolName, toolInput) { + if (toolName !== "agent" && toolName !== "task") { + const terminalTool = ["powershell", "bash", "execute", "runinterminal"].includes(toolName); + const command = commandText(toolInput); + const prompt = assessmentPrompt(input); + let skipCoverageValidation = false; + // BEGIN PLATFORM GUARD EXTENSION ASSESSMENT (plugin) +skipCoverageValidation = /(?:^|\s)--attempt-scratch-root(?:=|\s)/i.test(command) + && /\bbatch-headless\b|\brequest\.json\b/i.test(prompt); +// END PLATFORM GUARD EXTENSION ASSESSMENT + if (terminalTool + && /(?:^|\s)prepare-run(?:\s|$)/i.test(command) + && !skipCoverageValidation) { + const config = assessmentConfig(prompt); + const requested = assessmentCoverageArgument(command); + const requestedSource = assessmentCoverageSourceArgument(command); + if (!config.valid) { + return decision( + "deny", + "Assessment config must be one single-line JSON object with analysisCoverage set only to issue-only or full.", + ); + } + if (!requested.valid) { + return decision( + "deny", + "prepare-run must use one literal --coverage value: issue-only or full.", + ); + } + if (!requestedSource.valid) { + return decision( + "deny", + "Single prepare-run must use one literal --coverage-source value: default or explicit-user.", + ); + } + const expected = config.value.analysisCoverage ?? "issue-only"; + const expectedSource = Object.hasOwn(config.value, "analysisCoverage") + ? "explicit-user" + : "default"; + if (requested.value !== expected) { + return decision( + "deny", + `Assessment coverage must match the explicit assessment-config handoff; expected ${expected}.`, + ); + } + if (requestedSource.value !== expectedSource) { + return decision( + "deny", + `Assessment coverage source must match the explicit assessment-config handoff; expected ${expectedSource}.`, + ); + } + } + return decision("allow"); + } + return decision( + "deny", + "the Assessment phase agent has no subagent capability and must execute catalog skills itself.", + ); +} + +export function evaluateModernizeTool(payload, { scope = "inline" } = {}) { + const input = parseObject(payload); + const nativeToolCall = Array.isArray(input.toolCalls) && input.toolCalls.length === 1 + ? parseObject(input.toolCalls[0]) + : {}; + const toolName = normalizedToolName( + input.tool_name ?? input.toolName ?? input.name ?? nativeToolCall.name ?? "", + ); + if (!toolName) { + return decision("allow"); + } + + const toolInput = parseObject( + input.tool_input + ?? input.toolInput + ?? input.input + ?? input.arguments + ?? input.args + ?? nativeToolCall.args, + ); + const productBoundary = evaluateProductBoundary(toolName, toolInput); + if (productBoundary) { + return productBoundary; + } + + // BEGIN PLATFORM GUARD EXTENSION SCOPES (plugin) +if (scope === "review") { + return evaluateReviewTool(toolName, toolInput); + } + if (scope === "coordinator") { + return evaluateCoordinatorTool(toolName, toolInput); + } +// END PLATFORM GUARD EXTENSION SCOPES + if (scope === "assessment") { + return evaluateAssessmentTool(input, toolName, toolInput); + } + + if (scope === "inline" && !ALLOWED_ROUTER_TOOLS.has(toolName)) { + return decision( + "deny", + "modernize is a router and cannot read, write, search, browse, or execute commands directly; delegate to the required coordinator.", + ); + } + + // BEGIN PLATFORM GUARD EXTENSION QUESTION (plugin) +if (scope === "inline" && (toolName === "ask_user" || toolName === "askuser")) { + const workspaceModeDecision = evaluateWorkspaceModeQuestion(input, toolInput); + if (workspaceModeDecision) return workspaceModeDecision; + } +// END PLATFORM GUARD EXTENSION QUESTION + + if (toolName !== "agent" && toolName !== "task") { + return decision("allow"); + } + + const agentName = normalizedAgentName(toolInput); + let prompt = String(toolInput.prompt ?? toolInput.task ?? toolInput.description ?? ""); + let updatedInput; + + if (scope === "inline" + && ![...ALLOWED_ROUTER_AGENTS].some((allowedAgent) => targets(agentName, allowedAgent))) { + return decision( + "deny", + "modernize can delegate only to phase coordinators; execution workers must be invoked by execution-coordinator.", + ); + } + + if (scope === "inline" && targets(agentName, "assessment-coordinator")) { + const config = assessmentConfig(prompt); + if (!config.valid) { + return decision( + "deny", + "assessment-coordinator requires exactly one single-line JSON config object; analysisCoverage may be only issue-only or full.", + ); + } + if (!config.present) { + prompt = `${prompt.trimEnd()}\nconfig: {}`; + updatedInput = { ...toolInput, prompt }; + } + } + + // BEGIN PLATFORM GUARD EXTENSION DELEGATION (plugin) +if (targets(agentName, "batch-coordinator")) { + const normalizedInput = normalizeFallbackCoordinatorInput(input, toolInput); + if (normalizedInput && hasApprovedReviewHandoff(normalizedInput.prompt)) { + return decision("allow", undefined, normalizedInput); + } + if (!hasApprovedReviewHandoff(prompt)) { + return decision( + "deny", + "batch-coordinator requires the complete BATCH_REVIEW_READY handoff and one exact approval-evidence JSON object. Preserve every handoff field in its line format or as a lossless JSON object. Do not rerun or repair the Review; use the Review already returned in the immediately preceding turn or stop.", + ); + } + } + + const batchIntent = /\bbatch\b|repos\.json|multiple repositories|process repositories/i.test(prompt); + const targetsBatchAgent = BATCH_AGENT_SUFFIXES.some((suffix) => targets(agentName, suffix)); + if (scope === "inline" && batchIntent && !targetsBatchAgent) { + return decision( + "deny", + "Batch scope can be delegated only to batch-mode-probe, batch-review, or an approved batch-coordinator invocation.", + ); + } +// END PLATFORM GUARD EXTENSION DELEGATION + + return decision("allow", undefined, updatedInput); +} + +export function evaluateModernizePostTool(payload) { + // BEGIN PLATFORM GUARD EXTENSION POST TOOL (plugin) +const input = parseObject(payload); + const toolName = normalizedToolName(input.tool_name ?? input.toolName ?? input.name ?? ""); + const toolInput = parseObject(input.tool_input ?? input.toolInput ?? input.input ?? input.arguments); + if ((toolName !== "agent" && toolName !== "task") + || !targets(normalizedAgentName(toolInput), "batch-mode-probe")) { + return {}; + } + + let expected; + try { + expected = defaultConfigProbe(input); + } catch (error) { + return postToolResult( + `BATCH_MODE_PROBE_FAILED: ${error.message}. Stop with a compact probe error.`, + true, + ); + } + + const authoritativeContext = `Authoritative batch-mode probe result: ${JSON.stringify(expected)}. Use this object unchanged for workspace-mode routing.`; + const actual = findProbeResult(input.tool_response ?? input.toolResponse ?? input.output); + if (!sameProbeResult(actual, expected)) { + return postToolResult( + `The raw batch-mode-probe response was superseded by the filesystem check. ${authoritativeContext} Treat the authoritative object as the successful probe result and continue without rerunning the probe.`, + ); + } + return postToolResult(authoritativeContext); +// END PLATFORM GUARD EXTENSION POST TOOL + return {}; +} + +function run() { + let payload = {}; + try { + payload = parseObject(fs.readFileSync(0, "utf8")); + } catch { + payload = {}; + } + if (process.argv.includes("--post")) { + process.stdout.write(`${JSON.stringify(evaluateModernizePostTool(payload))}\n`); + return; + } + let scope = process.argv.includes("--assessment") ? "assessment" : "inline"; + // BEGIN PLATFORM GUARD EXTENSION RUN SCOPE (plugin) +if (process.argv.includes("--review")) { + scope = "review"; + } else if (process.argv.includes("--coordinator")) { + scope = "coordinator"; + } +// END PLATFORM GUARD EXTENSION RUN SCOPE + process.stdout.write(`${JSON.stringify(evaluateModernizeTool(payload, { scope }))}\n`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + run(); +} diff --git a/plugins/github-copilot-modernization/com.github.copilot/hooks/scripts/guardModernizeDelegation.test.mjs b/plugins/github-copilot-modernization/com.github.copilot/hooks/scripts/guardModernizeDelegation.test.mjs new file mode 100644 index 0000000..c1018d3 --- /dev/null +++ b/plugins/github-copilot-modernization/com.github.copilot/hooks/scripts/guardModernizeDelegation.test.mjs @@ -0,0 +1,614 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test, { after } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const scriptsRoot = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const { renderAgentContent, renderGuardContent } = require(path.resolve(scriptsRoot, "..", "..", "..", "scripts", "render-agent-platform.js")); +const guardSourcePath = path.join(scriptsRoot, "guardModernizeDelegation.mjs"); +const { + evaluateModernizePostTool: evaluateVscodePostTool, + evaluateModernizeTool: evaluateVscodeTool, +} = await import(`${pathToFileURL(guardSourcePath).href}?platform=vscode`); +const renderedGuardRoot = fs.mkdtempSync(path.join(os.tmpdir(), "modernize-plugin-guard-")); +const scriptPath = path.join(renderedGuardRoot, "guardModernizeDelegation.mjs"); +fs.writeFileSync( + scriptPath, + renderGuardContent(fs.readFileSync(guardSourcePath, "utf8"), "plugin"), + "utf8", +); +const { + evaluateModernizePostTool, + evaluateModernizeTool, +} = await import(pathToFileURL(scriptPath).href); +after(() => fs.rmSync(renderedGuardRoot, { recursive: true, force: true })); +const modernizeAgentPath = path.resolve(scriptsRoot, "..", "..", "modernize.agent.md"); +const batchModeProbeAgentPath = path.resolve(scriptsRoot, "..", "..", "batch-mode-probe.agent.md"); +const reviewAgentPath = path.resolve(scriptsRoot, "..", "..", "batch-review.agent.md"); +const coordinatorAgentPath = path.resolve(scriptsRoot, "..", "..", "batch-coordinator.agent.md"); +const batchAssessmentAgentPath = path.resolve(scriptsRoot, "..", "..", "batch-assessment.agent.md"); +const assessmentCoordinatorAgentPath = path.resolve(scriptsRoot, "..", "..", "assessment-coordinator.agent.md"); +const FOLLOW_UP_APPROVAL = '{"mode":"explicit-follow-up","value":"Start batch","entireUserTurn":"Start batch","immediatelyAfterReview":true}'; + +function permission(payload) { + return evaluateModernizeTool(payload).hookSpecificOutput.permissionDecision; +} + +function approvedCoordinatorPrompt() { + return [ + "BATCH_REVIEW_READY", + "batchRoot: C:\\workspace\\.github\\modernize\\batches\\batch-1", + "reviewPath: C:\\workspace\\.github\\modernize\\batches\\batch-1\\review.json", + "reviewMarkdownPath: C:\\workspace\\.github\\modernize\\batches\\batch-1\\REVIEW.md", + `reviewSha256: ${"a".repeat(64)}`, + `reviewMarkdownSha256: ${"b".repeat(64)}`, + "inspectedReposPath: C:\\workspace\\.github\\modernize\\batches\\batch-1\\inspected.json", + `inspectedReposSha256: ${"d".repeat(64)}`, + "batchAttemptScriptPath: C:\\plugin\\batch-attempt.mjs", + `configSha256: ${"c".repeat(64)}`, + 'selectedExecutionUnitIds: ["alpha"]', + "approvedNeedsAttention: []", + 'effectiveAssessments: [{"executionUnitId":"alpha","language":"java","domains":["java-upgrade"]}]', + "blockedExecutionUnits: []", + "analysisCoverage: issue-only", + "maxConcurrency: 1", + '{"mode":"structured","value":"Start batch","accepted":true}', + ].join("\n"); +} + +function approvedCoordinatorJsonPrompt() { + return [ + "BATCH_REVIEW_READY handoff:", + JSON.stringify({ + Status: "BATCH_REVIEW_READY", + batchRoot: "C:\\workspace\\.github\\modernize\\batches\\batch-1", + reviewPath: "C:\\workspace\\.github\\modernize\\batches\\batch-1\\review.json", + reviewMarkdownPath: "C:\\workspace\\.github\\modernize\\batches\\batch-1\\REVIEW.md", + reviewSha256: "a".repeat(64), + reviewMarkdownSha256: "b".repeat(64), + inspectedReposPath: "C:\\workspace\\.github\\modernize\\batches\\batch-1\\inspected.json", + inspectedReposSha256: "d".repeat(64), + batchAttemptScriptPath: "C:\\plugin\\batch-attempt.mjs", + configSha256: "c".repeat(64), + selectedExecutionUnitIds: ["alpha"], + approvedNeedsAttention: [], + effectiveAssessments: [{ executionUnitId: "alpha", language: "java", domains: ["java-upgrade"] }], + blockedExecutionUnits: [], + analysisCoverage: "issue-only", + maxConcurrency: 1, + }), + FOLLOW_UP_APPROVAL, + ].join("\n"); +} + +function fallbackTranscript() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "modernize-delegation-")); + const transcriptPath = path.join(root, "events.jsonl"); + const events = [ + { type: "user.message", data: { content: "Assess all configured repositories." } }, + { type: "user.message", data: { content: "Process repositories from repos.json" } }, + { type: "tool.execution_complete", data: { result: { content: approvedCoordinatorPrompt().replace('{"mode":"structured","value":"Start batch","accepted":true}', "") } } }, + { type: "assistant.message", data: { content: "BATCH_REVIEW_READY" } }, + { type: "user.message", data: { content: "Start batch" } }, + ]; + fs.writeFileSync(transcriptPath, `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + return { root, transcriptPath }; +} + +function assessmentTranscript(config = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "assessment-coverage-")); + const transcriptPath = path.join(root, "events.jsonl"); + const prompt = [ + "project-path: C:\\workspace", + 'user-request: "assess my app"', + "mode: coordinator", + `config: ${JSON.stringify(config)}`, + ].join("\n"); + fs.writeFileSync( + transcriptPath, + `${JSON.stringify({ type: "user.message", data: { content: prompt } })}\n`, + "utf8", + ); + return { root, transcriptPath }; +} + +test("VS Code guard does not probe Batch workspace state", () => { + const payload = { + cwd: path.join(os.tmpdir(), "missing-vscode-workspace"), + tool_name: "vscode_askQuestions", + tool_input: { + questions: [{ + header: "Workspace Mode", + options: [ + { label: "Process repositories from repos.json" }, + { label: "Only process the current repository" }, + ], + }], + }, + }; + + assert.equal( + evaluateVscodeTool(payload).hookSpecificOutput.permissionDecision, + "allow", + ); + assert.deepEqual(evaluateVscodePostTool({ + ...payload, + tool_name: "runSubagent", + tool_input: { agentName: "github-copilot-modernization:batch-mode-probe" }, + }), {}); +}); + +test("allows the exact Batch Review agent", () => { + assert.equal(permission({ + tool_name: "task", + tool_input: { + agent_type: "github-copilot-modernization:batch-review", + prompt: "Process repositories from repos.json using Batch Assessment.", + }, + }), "allow"); +}); + +test("rejects batch-coordinator before a ready approved handoff", () => { + const result = evaluateModernizeTool({ + tool_name: "task", + tool_input: { + agent_type: "github-copilot-modernization:batch-coordinator", + prompt: "Prepare a review for repositories from repos.json.", + }, + }); + + assert.equal(result.hookSpecificOutput.permissionDecision, "deny"); + assert.match(result.hookSpecificOutput.permissionDecisionReason, /Do not rerun or repair the Review/); +}); + +test("allows batch-coordinator only with the complete handoff and exact approval", () => { + assert.equal(permission({ + tool_name: "task", + tool_input: JSON.stringify({ + agent_type: "github-copilot-modernization:batch-coordinator", + prompt: approvedCoordinatorPrompt(), + }), + }), "allow"); +}); + +test("allows a lossless JSON encoding of the complete approved handoff", () => { + assert.equal(permission({ + tool_name: "task", + tool_input: { + agent_type: "github-copilot-modernization:batch-coordinator", + prompt: approvedCoordinatorJsonPrompt(), + }, + }), "allow"); +}); + +test("rejects JSON handoffs with a missing field or paraphrased approval", () => { + const approved = approvedCoordinatorJsonPrompt(); + for (const prompt of [ + approved.replace(/"reviewSha256":"[a-f0-9]+",/, ""), + approved.replace(/"inspectedReposSha256":"[a-f0-9]+",/, ""), + approved.replace(FOLLOW_UP_APPROVAL, "The user approved the batch."), + ]) { + assert.equal(permission({ + tool_name: "task", + tool_input: { + agent_type: "github-copilot-modernization:batch-coordinator", + prompt, + }, + }), "deny"); + } +}); + +test("normalizes an incomplete fallback coordinator prompt from trusted transcript evidence", (t) => { + const { root, transcriptPath } = fallbackTranscript(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const result = evaluateModernizeTool({ + session_id: "session-1", + transcript_path: transcriptPath, + cwd: "C:\\workspace", + tool_name: "task", + tool_input: { + agent_type: "github-copilot-modernization:batch-coordinator", + prompt: "BATCH_REVIEW_READY handoff: {\"batchRoot\":\"C:\\\\workspace\\\\batch-1\"}", + }, + }); + + assert.equal(result.hookSpecificOutput.permissionDecision, "allow"); + assert.match(result.hookSpecificOutput.updatedInput.prompt, /batchRoot: C:\\workspace/); + assert.match(result.hookSpecificOutput.updatedInput.prompt, /"mode":"explicit-follow-up"/); + assert.match(result.hookSpecificOutput.updatedInput.prompt, /user-request: "Assess all configured repositories\."/); +}); + +test("does not normalize without an exact current fallback approval", (t) => { + const { root, transcriptPath } = fallbackTranscript(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const lines = fs.readFileSync(transcriptPath, "utf8").trimEnd().split("\n"); + lines[lines.length - 1] = JSON.stringify({ type: "user.message", data: { content: "Please start batch" } }); + fs.writeFileSync(transcriptPath, `${lines.join("\n")}\n`, "utf8"); + + assert.equal(permission({ + transcript_path: transcriptPath, + cwd: "C:\\workspace", + tool_name: "task", + tool_input: { + agent_type: "github-copilot-modernization:batch-coordinator", + prompt: "incomplete", + }, + }), "deny"); +}); + +test("rejects Batch work sent to a Single phase coordinator", () => { + assert.equal(permission({ + tool_name: "task", + tool_input: { + agent_type: "github-copilot-modernization:assessment-coordinator", + prompt: "Assess multiple repositories from repos.json as a batch.", + }, + }), "deny"); +}); + +test("allows classic Single phase delegation", () => { + const result = evaluateModernizeTool({ + tool_name: "task", + tool_input: { + agent_type: "github-copilot-modernization:assessment-coordinator", + prompt: "Assess only the current repository.", + }, + }); + assert.equal(result.hookSpecificOutput.permissionDecision, "allow"); + assert.match(result.hookSpecificOutput.updatedInput.prompt, /\nconfig: \{\}$/); +}); + +test("allows VS Code runSubagent payload for classic Single delegation", () => { + const result = evaluateModernizeTool({ + hook_event_name: "PreToolUse", + tool_name: "runSubagent", + tool_input: { + agentName: "github-copilot-modernization:assessment-coordinator", + prompt: "Migrate the current application to Azure.", + }, + }); + + assert.equal(result.hookSpecificOutput.permissionDecision, "allow"); + assert.match(result.hookSpecificOutput.updatedInput.prompt, /\nconfig: \{\}$/); +}); + +test("exposes execution workers transitively but rejects direct router delegation", () => { + const modernizeAgent = fs.readFileSync(modernizeAgentPath, "utf8"); + for (const worker of [ + "modernize-java-upgrade", + "modernize-azure-java", + "modernize-java-security", + "modernize-azure-dotnet", + "modernize-deployment", + "modernize-azure-integration-tester", + "modernize-rearchitecture", + ]) { + assert.match(modernizeAgent, new RegExp(`^ - ${worker}$`, "m")); + const result = evaluateVscodeTool({ + tool_name: "runSubagent", + tool_input: { agentName: worker, prompt: "Execute a migration task." }, + }).hookSpecificOutput; + assert.equal(result.permissionDecision, "deny"); + assert.match(result.permissionDecisionReason, /execution-coordinator/); + } + + assert.equal(evaluateVscodeTool({ + tool_name: "runSubagent", + tool_input: { agentName: "execution-coordinator", prompt: "Execute the validated plan." }, + }).hookSpecificOutput.permissionDecision, "allow"); +}); + +test("allows the VS Code manage_todo_list router tool", () => { + assert.equal(permission({ + hook_event_name: "PreToolUse", + tool_name: "manage_todo_list", + tool_input: { todoList: [] }, + }), "allow"); +}); + +test("allows the VS Code vscode_askQuestions router tool", () => { + assert.equal(permission({ + hook_event_name: "PreToolUse", + tool_name: "vscode_askQuestions", + tool_input: { + questions: [{ + header: "Migration scope", + question: "What do you want to migrate to Azure?", + allowFreeformInput: false, + }], + }, + }), "allow"); +}); + +test("revalidates the default config before showing the VS Code Workspace Mode UI", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "workspace-mode-question-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const configPath = path.join(root, ".github", "modernize", "repos.json"); + const payload = { + hook_event_name: "PreToolUse", + cwd: root, + tool_name: "vscode_askQuestions", + tool_input: { + questions: [{ + header: "Workspace Mode", + question: "How should I process this workspace?", + options: [ + { label: "Process repositories from repos.json" }, + { label: "Only process the current repository" }, + ], + allowFreeformInput: false, + }], + }, + }; + + const absent = evaluateModernizeTool(payload).hookSpecificOutput; + assert.equal(absent.permissionDecision, "deny"); + assert.match(absent.permissionDecisionReason, /default config is absent/); + + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, '{"repos":[]}\n', "utf8"); + assert.equal(permission(payload), "allow"); + + fs.rmSync(configPath); + fs.mkdirSync(configPath); + const invalid = evaluateModernizeTool(payload).hookSpecificOutput; + assert.equal(invalid.permissionDecision, "deny"); + assert.match(invalid.permissionDecisionReason, /not a file/); + + const unverifiable = evaluateModernizeTool({ ...payload, cwd: undefined }).hookSpecificOutput; + assert.equal(unverifiable.permissionDecision, "deny"); + assert.match(unverifiable.permissionDecisionReason, /no absolute launch root/); +}); + +test("supersedes inconsistent batch-mode-probe responses with authoritative filesystem state", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-mode-post-hook-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const configPath = path.join(root, ".github", "modernize", "repos.json"); + const payload = { + hook_event_name: "PostToolUse", + cwd: root, + tool_name: "runSubagent", + tool_input: { + agentName: "github-copilot-modernization:batch-mode-probe", + prompt: `launch-root: ${root}`, + }, + }; + + const inconsistent = evaluateModernizePostTool({ + ...payload, + tool_response: JSON.stringify({ + schemaVersion: 1, + launchRoot: root, + configPath, + status: "found", + }), + }); + assert.equal(inconsistent.decision, undefined); + assert.equal(inconsistent.reason, undefined); + assert.match(inconsistent.hookSpecificOutput.additionalContext, /"status":"absent"/); + assert.match(inconsistent.hookSpecificOutput.additionalContext, /superseded/); + + const correct = evaluateModernizePostTool({ + ...payload, + tool_response: `stdout:\n${JSON.stringify({ + schemaVersion: 1, + launchRoot: root, + configPath, + status: "absent", + })}`, + }); + assert.equal(correct.decision, undefined); + assert.match(correct.hookSpecificOutput.additionalContext, /"status":"absent"/); + + const malformed = evaluateModernizePostTool({ ...payload, tool_response: "status: found" }); + assert.equal(malformed.decision, undefined); + assert.match(malformed.hookSpecificOutput.additionalContext, /"status":"absent"/); + + assert.deepEqual(evaluateModernizePostTool({ + ...payload, + tool_input: { agentName: "github-copilot-modernization:assessment-coordinator" }, + }), {}); +}); + +test("batch-mode probe uses one physical PowerShell command", () => { + const agent = fs.readFileSync(batchModeProbeAgentPath, "utf8"); + const powershellBlock = agent.match(/PowerShell:\r?\n\r?\n```powershell\r?\n([\s\S]*?)\r?\n```/)?.[1]; + assert.ok(powershellBlock); + assert.equal(powershellBlock.split(/\r?\n/).length, 1); + assert.match(powershellBlock, /ConvertTo-Json -Compress$/); +}); + +test("rejects malformed or unsupported Single assessment config", () => { + for (const config of ["{bad-json}", '{"analysisCoverage":"source-only"}']) { + assert.equal(permission({ + tool_name: "task", + tool_input: { + agent_type: "github-copilot-modernization:assessment-coordinator", + prompt: `Assess only the current repository.\nconfig: ${config}`, + }, + }), "deny"); + } +}); + +test("rejects direct top-level file and command tools", () => { + for (const toolName of ["powershell", "view", "edit", "search", "web"]) { + assert.equal(permission({ tool_name: toolName, tool_input: {} }), "deny", toolName); + } +}); + +test("product-scoped guards reject Assessment bypasses and Batch workaround paths", () => { + for (const payload of [ + { tool_name: "appmod-mcp-server-appmod-run-assessment-action", tool_input: {} }, + { tool_name: "view", tool_input: { path: "C:\\workspace\\.github\\modernize\\repos.json" } }, + { tool_name: "view", tool_input: { path: "C:\\workspace\\.github\\modernize\\batches\\review-1\\REVIEW.md" } }, + { tool_name: "create", tool_input: { path: "C:\\workspace\\.copilot\\batch-review\\REVIEW.md" } }, + ]) { + assert.equal( + evaluateModernizeTool(payload, { scope: "assessment" }).hookSpecificOutput.permissionDecision, + "deny", + ); + } +}); + +test("review guard requires the loaded skill's deterministic script", () => { + assert.equal(evaluateModernizeTool( + { tool_name: "skill", tool_input: { name: "batch-modernization" } }, + { scope: "review" }, + ).hookSpecificOutput.permissionDecision, "allow"); + assert.equal(evaluateModernizeTool( + { tool_name: "powershell", tool_input: { command: "node C:\\plugin\\skills\\batch-modernization\\scripts\\prepare-review.mjs" } }, + { scope: "review" }, + ).hookSpecificOutput.permissionDecision, "allow"); + assert.equal(evaluateModernizeTool( + { tool_name: "powershell", tool_input: { command: "node C:\\plugin\\skills\\batch-modernization\\scripts\\resolve-repos.mjs" } }, + { scope: "review" }, + ).hookSpecificOutput.permissionDecision, "deny"); +}); + +test("coordinator guard allows the phase agent but rejects simulated outcomes", () => { + assert.equal(evaluateModernizeTool( + { + tool_name: "task", + tool_input: { agent_type: "github-copilot-modernization:batch-assessment", prompt: "Process request.json" }, + }, + { scope: "coordinator" }, + ).hookSpecificOutput.permissionDecision, "allow"); + assert.equal(evaluateModernizeTool( + { tool_name: "powershell", tool_input: { command: "node batch-attempt.mjs session-commit --request request.json" } }, + { scope: "coordinator" }, + ).hookSpecificOutput.permissionDecision, "allow"); + assert.equal(evaluateModernizeTool( + { tool_name: "powershell", tool_input: { command: "Set-Content outcome.json '{}'; node batch-attempt.mjs publish --outcome outcome.json" } }, + { scope: "coordinator" }, + ).hookSpecificOutput.permissionDecision, "deny"); +}); + +test("assessment guard rejects every subagent delegation", () => { + for (const agentName of [ + "github-copilot-modernization:modernize", + "github-copilot-modernization:assessment-coordinator", + "github-copilot-modernization:batch-assessment", + "github-copilot-modernization:batch-mode-probe", + "github-copilot-modernization:planning-coordinator", + "general-purpose", + "github-copilot-modernization:assessment-task-worker", + ]) { + assert.equal(evaluateModernizeTool( + { tool_name: "task", tool_input: { agent_type: agentName } }, + { scope: "assessment" }, + ).hookSpecificOutput.permissionDecision, "deny"); + } + assert.equal(evaluateModernizeTool( + { tool_name: "skill", tool_input: { skill: "assessment" } }, + { scope: "assessment" }, + ).hookSpecificOutput.permissionDecision, "allow"); + assert.equal(evaluateModernizeTool( + { tool_name: "runSubagent", tool_input: { agentName: "general-purpose" } }, + { scope: "assessment" }, + ).hookSpecificOutput.permissionDecision, "deny"); +}); + +test("assessment guard enforces default issue-only coverage", (t) => { + const { root, transcriptPath } = assessmentTranscript(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + for (const command of [ + "node assess-cli.mjs prepare-run --workspace-path C:\\workspace --run-id 1 --language java --coverage-source default", + "node assess-cli.mjs prepare-run --workspace-path C:\\workspace --run-id 1 --language java --coverage issue-only --coverage-source default", + ]) { + assert.equal(evaluateModernizeTool({ + transcript_path: transcriptPath, + tool_name: "powershell", + tool_input: { command }, + }, { scope: "assessment" }).hookSpecificOutput.permissionDecision, "allow"); + } + + const denied = evaluateModernizeTool({ + transcript_path: transcriptPath, + tool_name: "powershell", + tool_input: { + command: "node assess-cli.mjs prepare-run --workspace-path C:\\workspace --run-id 1 --language java --coverage full", + }, + }, { scope: "assessment" }).hookSpecificOutput; + assert.equal(denied.permissionDecision, "deny"); + assert.match(denied.permissionDecisionReason, /coverage-source/); + + const escalated = evaluateModernizeTool({ + transcript_path: transcriptPath, + tool_name: "powershell", + tool_input: { + command: "node assess-cli.mjs prepare-run --workspace-path C:\\workspace --run-id 1 --language java --coverage full --coverage-source explicit-user", + }, + }, { scope: "assessment" }).hookSpecificOutput; + assert.equal(escalated.permissionDecision, "deny"); + assert.match(escalated.permissionDecisionReason, /expected issue-only/); +}); + +test("assessment guard allows only the explicitly configured full coverage", (t) => { + const { root, transcriptPath } = assessmentTranscript({ analysisCoverage: "full" }); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + assert.equal(evaluateModernizeTool({ + transcript_path: transcriptPath, + tool_name: "powershell", + tool_input: { + command: "node assess-cli.mjs prepare-run --workspace-path C:\\workspace --run-id 1 --language java --coverage full --coverage-source explicit-user", + }, + }, { scope: "assessment" }).hookSpecificOutput.permissionDecision, "allow"); + + assert.equal(evaluateModernizeTool({ + transcript_path: transcriptPath, + tool_name: "powershell", + tool_input: { + command: "node assess-cli.mjs prepare-run --workspace-path C:\\workspace --run-id 1 --language java --coverage issue-only --coverage-source explicit-user", + }, + }, { scope: "assessment" }).hookSpecificOutput.permissionDecision, "deny"); +}); + +test("assessment guard leaves approved batch coverage to the request artifact", () => { + assert.equal(evaluateModernizeTool({ + prompt: "Process request.json in batch-headless mode.", + tool_name: "powershell", + tool_input: { + command: "node assess-cli.mjs prepare-run --workspace-path C:\\workspace --run-id 1 --language java --coverage full --attempt-scratch-root C:\\attempt", + }, + }, { scope: "assessment" }).hookSpecificOutput.permissionDecision, "allow"); +}); + +test("hook CLI accepts snake-case payloads on stdin", () => { + const result = spawnSync(process.execPath, [scriptPath], { + encoding: "utf8", + input: JSON.stringify({ + hook_event_name: "PreToolUse", + tool_name: "task", + tool_input: { + agent_type: "github-copilot-modernization:batch-coordinator", + prompt: "Process repositories from repos.json.", + }, + }), + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).hookSpecificOutput.permissionDecision, "deny"); +}); + +test("modernize wires the scoped delegation guards", () => { + const agent = renderAgentContent(fs.readFileSync(modernizeAgentPath, "utf8"), "plugin"); + assert.match(agent, /PreToolUse:/); + assert.match(agent, /guardModernizeDelegation\.mjs/); + assert.match(agent, /PostToolUse:/); + assert.match(agent, /guardModernizeDelegation\.mjs.*--post/); + assert.match(agent, /Authoritative batch-mode probe result.*supersedes the raw subagent response/); + assert.match(agent, /`batch-coordinator` cannot prepare or repair a Review/); + assert.match(fs.readFileSync(reviewAgentPath, "utf8"), /guardModernizeDelegation\.mjs.*--review/); + assert.match(fs.readFileSync(coordinatorAgentPath, "utf8"), /guardModernizeDelegation\.mjs.*--coordinator/); + assert.match(fs.readFileSync(batchAssessmentAgentPath, "utf8"), /guardModernizeDelegation\.mjs.*--assessment/); + const assessmentCoordinator = fs.readFileSync(assessmentCoordinatorAgentPath, "utf8"); + const coordinatorTools = assessmentCoordinator.match(/^tools:\r?\n((?: - [^\r\n]+\r?\n?)*)/m)?.[1] ?? ""; + assert.doesNotMatch(coordinatorTools, /^ - agent$/m); +}); diff --git a/plugins/github-copilot-modernization/agents/hook/scripts/sendTelemetry.ps1 b/plugins/github-copilot-modernization/com.github.copilot/hooks/scripts/sendTelemetry.ps1 similarity index 100% rename from plugins/github-copilot-modernization/agents/hook/scripts/sendTelemetry.ps1 rename to plugins/github-copilot-modernization/com.github.copilot/hooks/scripts/sendTelemetry.ps1 diff --git a/plugins/github-copilot-modernization/agents/hook/scripts/sendTelemetry.sh b/plugins/github-copilot-modernization/com.github.copilot/hooks/scripts/sendTelemetry.sh similarity index 100% rename from plugins/github-copilot-modernization/agents/hook/scripts/sendTelemetry.sh rename to plugins/github-copilot-modernization/com.github.copilot/hooks/scripts/sendTelemetry.sh diff --git a/plugins/github-copilot-modernization/agents/hook/scripts/sendTelemetry.test.js b/plugins/github-copilot-modernization/com.github.copilot/hooks/scripts/sendTelemetry.test.js similarity index 100% rename from plugins/github-copilot-modernization/agents/hook/scripts/sendTelemetry.test.js rename to plugins/github-copilot-modernization/com.github.copilot/hooks/scripts/sendTelemetry.test.js diff --git a/plugins/github-copilot-modernization/docs/USER_GUIDE.md b/plugins/github-copilot-modernization/docs/USER_GUIDE.md index 66dd460..aa8eaa2 100644 --- a/plugins/github-copilot-modernization/docs/USER_GUIDE.md +++ b/plugins/github-copilot-modernization/docs/USER_GUIDE.md @@ -8,7 +8,8 @@ GitHub Copilot modernization provides an autonomous workflow for modernizing app 1. [Getting Started](#getting-started) 2. [Understanding the Workflow](#understanding-the-workflow) -3. [Troubleshooting](#troubleshooting) +3. [Batch Assessment Private Preview](#batch-assessment-private-preview) +4. [Troubleshooting](#troubleshooting) ## Getting Started @@ -65,10 +66,13 @@ Update to the latest version: ### Phase 1: Assessment The assessment phase: -- Discovers Java applications in the specified path -- Analyzes dependencies, frameworks, and Java version -- Identifies migration opportunities and risks -- Saves results to `.github/modernize/assessment/` (report.json) +- Uses a plugin-owned local catalog and does not call MCP tools +- Supports Java, .NET, and JavaScript/TypeScript discovery and analysis; downstream automated planning/execution supports Java and .NET +- Uses the bundled Node 18+ runtime for AppCAT, npm-check-updates, normalized findings, and report generation +- Full coverage runs six fact documents; security runs one CVE task plus six CWE category tasks +- Executes those as separate batches, with a maximum of seven concurrent assessment subagents +- Writes a versioned HTML report under `.github/modernize/reports/` +- Writes the public assessment report to `.github/modernize/assessment/reports/report-/report.json`; internal normalized Planning data remains under `.github/modernize/.memory/runs//` ### Phase 2: Planning @@ -126,6 +130,58 @@ copilot> modernize my application The orchestrator will assess your app, identify all modernization opportunities, generate a comprehensive plan, and execute it. +## Batch Assessment Private Preview + +Batch Assessment can assess several local repositories through the normal `modernize` entry point. Create `.github/modernize/repos.json` under a launch directory: + +```json +{ + "repos": [ + { + "name": "orders", + "path": "C:\\source\\orders" + }, + { + "name": "billing", + "path": "C:\\source\\billing" + } + ] +} +``` + +Start `modernize` from the launch directory and make your normal request. Whenever the default file exists, the orchestrator first asks whether to process its repositories or only the current repository, even if the request already mentions Batch or the current repository: + +```text +Assess for cloud readiness using issue-only coverage. +``` + +Choose **Process repositories from repos.json** to enter Batch mode, or **Only process the current repository** to continue the classic Single workflow. The mode choice does not approve execution. Batch mode next presents a read-only Review; approve that separately through **Start batch**, or choose **Cancel** to stop without execution. + +Repositories run sequentially, each in a fresh internal Assessment invocation. Repository reports retain the standard Single Assessment locations. After every repository reaches a terminal state, Batch mode atomically publishes: + +```text +.github/modernize/assessment/reports-/ +├── index.html +├── aggregate-report.json +└── repos/ + └── / + ├── report.json + ├── report.html + └── facts/ +``` + +The final response shows `index.html` as the primary result. Internal audit artifacts remain under `.github/modernize/batches//`; users do not need to navigate there. Published repository reports are byte-for-byte snapshots of validated canonical artifacts. Classic Single Planning follows the validated `verification.json` receipt to its internal normalized Assessment data. + +The private preview has these limits: + +- Only `modernize` is user-invocable; probe, review, coordinator, phase, and specialist agents are internal. +- Assessment only; Batch Planning, Execution, upgrade, migration, and remediation are unavailable. +- Whole repositories only. Configurations using `include_paths` fail before batch state is created. +- Local execution only; no cloud delegation. +- No retry, cross-session resume, pause, or scheduling after lease takeover. Single Assessment also starts a new run rather than resuming an interrupted Assessment. +- A stale batch is retained only for diagnostics. Start a new Batch Assessment when another run is required. +- Completion comes only from request-bound validated report artifacts, not agent prose. + ## Troubleshooting ### Common Issues diff --git a/plugins/github-copilot-modernization/.mcp.json b/plugins/github-copilot-modernization/mcp.json similarity index 77% rename from plugins/github-copilot-modernization/.mcp.json rename to plugins/github-copilot-modernization/mcp.json index 91a6dba..55f9fbc 100644 --- a/plugins/github-copilot-modernization/.mcp.json +++ b/plugins/github-copilot-modernization/mcp.json @@ -1,16 +1,14 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", "mcpServers": { "appmod-mcp-server": { "type": "stdio", "command": "npx", "args": [ "-y", - "@microsoft/github-copilot-app-modernization-mcp-server@1.23.0", + "@microsoft/github-copilot-app-modernization-mcp-server@1.24.0", "--callerType", "github-copilot-modernization-plugin" - ], - "tools": [ - "*" ] } } diff --git a/plugins/github-copilot-modernization/plugin.json b/plugins/github-copilot-modernization/plugin.json index 192fce0..edb8c4b 100644 --- a/plugins/github-copilot-modernization/plugin.json +++ b/plugins/github-copilot-modernization/plugin.json @@ -1,7 +1,8 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "github-copilot-modernization", "description": "Autonomous application modernization with assess \u2192 plan \u2192 execute workflow", - "version": "1.23.0", + "version": "1.24.0", "author": { "name": "Microsoft", "email": "copilot-support@microsoft.com" @@ -15,10 +16,5 @@ "azure", "migration", "assessment" - ], - "agents": "agents/", - "skills": [ - "skills/" - ], - "mcpServers": ".mcp.json" + ] } diff --git a/plugins/github-copilot-modernization/skills/analyzing-architecture/SKILL.md b/plugins/github-copilot-modernization/skills/analyzing-architecture/SKILL.md index ef3b3f8..6408cb4 100644 --- a/plugins/github-copilot-modernization/skills/analyzing-architecture/SKILL.md +++ b/plugins/github-copilot-modernization/skills/analyzing-architecture/SKILL.md @@ -106,7 +106,7 @@ For how downstream agents consume these artifacts, load `references/consumption- --- -`target_idiom` is NOT produced here — lives in `guidelines/-to-/`. +`target_idiom` is NOT produced here — it lives in the top-level `skills/-to-/SKILL.md` guideline skill. ### Architecture index artifact — implementation guide @@ -152,7 +152,7 @@ Load `references/unit-decomposition.md` for schema and split-driver vocabulary. ## Workflow -1. **Load context** — source/target framework, existing KG, `guidelines/-to-/`, and any **user-declared seams** (cut points the user specified). +1. **Load context** — source/target framework, existing KG, `skills/-to-/SKILL.md`, and any **user-declared seams** (cut points the user specified). 1b. **Load extraction signals** — read `references/extraction-signals.md` and map discovered signals into the structured artifacts. 1c. **Produce global prose views** — alongside the structured per-unit artifacts, emit `project-structure.md` (functional domains, layers, project type), `tech-stack.md` (frameworks, deps, runtime versions, migration blockers), and `data-model.md` (entity inventory + key-entities summary) per their reference schemas. These global views are consumed by creating-implementation-plan, feature-inventory, and the spec-quality gate; the structured YAML artifacts do not replace them. 2. **Build `unit_graph.yaml`** (spine). Resolve `exported_signature` from public signatures only. Seed `shared_modules.yaml` same pass; flag god-class + reference-cliff candidates. @@ -199,7 +199,7 @@ Load `references/unit-decomposition.md` for schema and split-driver vocabulary. |---|---|---| | 1 | Dropped side-effect | `behavior.yaml::side_effects[must_preserve]` | | 2 | Dropped framework binding | `bindings.yaml::bindings[must_appear_in_target]` | -| 3 | Hallucinated target API | `guidelines/-to-/` (out of scope) | +| 3 | Hallucinated target API | `skills/-to-/SKILL.md` (out of scope) | | 4 | Broken caller (signature unsync) | `unit_graph.yaml::depends_on` + `exported_signature` | | 5 | Dead-code removal of reflection/DI class | `unit_graph.yaml::dynamic_entrypoints` | | 6 | Wire contract break | `wire_contracts.yaml::stability:frozen + target_contract` | @@ -230,7 +230,7 @@ Load `references/unit-decomposition.md` for schema and split-driver vocabulary. - Performance baseline — cutover phase - Standalone risk register — inline `notes` / `stability` - Architecture summary prose — implementation agents need source-anchored contracts, not prose-only summaries -- Idiom mapping — `guidelines/-to-/` +- Idiom mapping — `skills/-to-/SKILL.md` - Function-level call graph beyond unit boundaries — `exported_signature` suffices - **Cohesion metrics / co-access clusters (LCOM4/TCC)** — structural numbers did not change design decisions. God-class smells live on `shared_modules.yaml::split_candidate`. - Pure syntax migration (Py2→3, Java 8→17) diff --git a/plugins/github-copilot-modernization/skills/assessment/SKILL.md b/plugins/github-copilot-modernization/skills/assessment/SKILL.md index a8b236f..724a66f 100644 --- a/plugins/github-copilot-modernization/skills/assessment/SKILL.md +++ b/plugins/github-copilot-modernization/skills/assessment/SKILL.md @@ -1,197 +1,307 @@ --- name: assessment -description: Run application assessment for a single repository +description: Run a fully local application assessment for one Java, .NET, or JavaScript/TypeScript repository --- # Application Assessment -This skill performs application assessment for a single repository. It supports Java, .NET, and JavaScript/TypeScript projects. - -## Input Parameters - -- `workspace-path` (optional): Path to the project to assess. Defaults to the current directory (repository root) when not specified. All assessment outputs are written relative to this path (e.g. `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/report.json`). For a repository with multiple sub-projects, pass the sub-project directory path so that each sub-project's outputs are isolated. - -## When to Use This Skill - -Use this skill when you need to: - -- Assess a Java or .NET application for cloud readiness and migration issues -- Assess a JavaScript/TypeScript project for outdated dependencies and available updates -- Generate detailed assessment reports with issue analysis and recommendations -- Understand application dependencies, frameworks, and potential migration blockers - -## What This Skill Does - -This skill performs a simplified assessment workflow: - -1. **Check Project Type and Prerequisites**: - - **For Java projects**: Check MCP tool availability in this order: - 1. **Primary**: Check if 'appmod-run-assessment-action' MCP tool is available - - If available, use ONLY this tool. It handles everything (prerequisite checks, installation, and assessment execution) in a single call. - - Do NOT call any other assessment MCP tools when this tool is available. - 2. **Fallback**: If 'appmod-run-assessment-action' is NOT available, check if 'appmod-precheck-assessment' MCP tool is available - - If available, use the legacy workflow: call 'appmod-precheck-assessment' first, then follow its guidance for 'appmod-install-appcat' and 'appmod-run-assessment'. - 3. If neither tool is configured, return immediately with setup instructions. - - **For .NET projects**: Check if .NET SDK is available - - No MCP tools required for .NET assessment - - **For JavaScript/TypeScript projects**: Check if Node.js and npm are available - - No MCP tools required for JS/TS assessment - -2. **Run Assessment**: - - **For Java projects**: Trigger AppCAT analysis via Assessment MCP server - - **If 'appmod-run-assessment-action' is available (primary path)**: - - Call 'appmod-run-assessment-action' MCP tool only - - The MCP tool automatically saves the report to the versioned directory - - **If falling back to 'appmod-precheck-assessment' (legacy path)**: - - Call 'appmod-precheck-assessment' to check prerequisites - - Call 'appmod-install-appcat' to install AppCAT if needed - - Call 'appmod-run-assessment' to run the assessment - - **For .NET projects**: Install and run AppCAT directly - - Install: `dotnet tool update dotnet-appcat` - - Find all .csproj files under `{workspace-path}` - - Join project paths with semicolons: `projectPaths="project1.csproj;project2.csproj"` - - Run: `appcat analyze $projectPaths --source Solution --target Any --serializer APPMODJSON --code --privacyMode Restricted --non-interactive --report {workspace-path}\.github\modernize\appcat\result\report.json` - - **For JavaScript/TypeScript projects**: Install and run npm-check-updates - - Install: `npm install -g npm-check-updates@19.6.3 --prefix {tool-install-dir}` - - Run: `ncu --format group --packageFile {workspace-path}/package.json` - - Generate the `reportId` as a UTC timestamp formatted as `yyyyMMddHHmmss` (e.g. `2024-06-15T14:30:52Z` becomes `20240615143052`) - - Create the versioned directory: `mkdir -p {workspace-path}/.github/modernize/assessment/reports/report-{reportId}` - - Save the output to `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/js-assessment-report.md` - - Do NOT save a copy to the top-level assessment directory - - Analyzes code for cloud migration issues or dependency updates - - Generates structured assessment data - -3. **Save Report to Versioned Directory (All languages)**: - - **For Java projects (primary path — 'appmod-run-assessment-action')**: The MCP tool automatically saves the report to `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/report.json` — no manual saving needed - - **For Java projects (legacy fallback path — 'appmod-precheck-assessment')**: - 1. Find `report.json` under `{workspace-path}/.github/modernize/appcat/result/` - 2. Read the report and extract `metadata.analysisStartTime` - 3. Format the timestamp as `yyyyMMddHHmmss` to produce the `reportId` (e.g. `2024-06-15T14:30:52Z` becomes `20240615143052`) - 4. Create the versioned directory: `mkdir -p {workspace-path}/.github/modernize/assessment/reports/report-{reportId}` - 5. Move the report to `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/report.json` - - **For .NET projects**: - 1. Find `report.json` at `{workspace-path}/.github/modernize/appcat/result/report.json` - 2. Read the report and extract `metadata.analysisStartTime` - 3. Format the timestamp as `yyyyMMddHHmmss` to produce the `reportId` (e.g. `2024-06-15T14:30:52Z` becomes `20240615143052`) - 4. Create the versioned directory: `mkdir -p {workspace-path}/.github/modernize/assessment/reports/report-{reportId}` - 5. Move the report to `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/report.json` - - This versioned report should be included in the pull request - -## How to Use - -### Prerequisites - -**For Java projects**: -- **Primary**: MCP tool 'appmod-run-assessment-action' — preferred, handles everything in one call -- **Fallback**: If primary tool is not available, use 'appmod-precheck-assessment' → 'appmod-install-appcat' → 'appmod-run-assessment' workflow -- If neither tool is configured, the skill will return instructions for setup - -**For .NET projects**: -- .NET SDK must be installed -- No MCP tools required - appcat will be installed and run directly via .NET CLI -- The assessment will automatically install `dotnet-appcat` tool if not already present - -**For JavaScript/TypeScript projects**: -- Node.js and npm must be installed -- No MCP tools required - npm-check-updates will be installed and run directly via npm -- The assessment will automatically install `npm-check-updates` if not already present - -### Triggering Assessment - -Simply express the intent to assess the application. Example prompts: - -- "Assess the application" -- "Run assessment for this project" - -The assessment process automatically: -- Detects project language and framework within `{workspace-path}` -- **For Java**: Uses MCP tool to run AppCAT and automatically save report to versioned directory -- **For .NET**: Installs dotnet-appcat tool and runs analysis directly -- **For JavaScript/TypeScript**: Installs npm-check-updates and runs dependency analysis -- Executes comprehensive analysis -- **For Java (primary)**: Report is automatically saved to `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/report.json` -- **For Java (legacy fallback)**: Generates report at `{workspace-path}/.github/modernize/appcat/result/`, then moved to versioned directory -- **For .NET**: Generates report at `{workspace-path}/.github/modernize/appcat/result/report.json` -- **For JavaScript/TypeScript**: Generates report at `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/js-assessment-report.md` - -### Report Saving - -**For Java projects**: -1. **If using 'appmod-run-assessment-action' (primary path)**: The MCP tool automatically saves the report to `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/report.json` — no manual report moving is needed -2. **If using legacy fallback path ('appmod-precheck-assessment')**: - - Find `report.json` under `{workspace-path}/.github/modernize/appcat/result/` - - Read the report and extract `metadata.analysisStartTime`, format as `yyyyMMddHHmmss` to get `reportId` - - Move the report to `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/report.json` -3. Include this versioned report in the pull request - -**For .NET projects**: -1. Report is initially generated at `{workspace-path}/.github/modernize/appcat/result/report.json` -2. Read the report and extract `metadata.analysisStartTime`, format as `yyyyMMddHHmmss` to get `reportId` -3. Move the report to `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/report.json` -4. Include this versioned report in the pull request - -**For JavaScript/TypeScript projects**: -1. Generate the `reportId` as a UTC timestamp formatted as `yyyyMMddHHmmss` (e.g. `2024-06-15T14:30:52Z` becomes `20240615143052`) -2. Create the versioned directory: `mkdir -p {workspace-path}/.github/modernize/assessment/reports/report-{reportId}` -3. Save the ncu output to `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/js-assessment-report.md` -4. Include this versioned report in the pull request - -## Report Output Location - -Report location depends on project type: - -**For Java projects** (via MCP server): -- **Primary path ('appmod-run-assessment-action')**: Automatically saved to: `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/report.json` -- **Legacy fallback path ('appmod-precheck-assessment')**: Initially stored under `{workspace-path}/.github/modernize/appcat/result/`, then moved to versioned directory: `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/report.json` - -**For .NET projects** (direct execution): -- Initially generated at: `{workspace-path}/.github/modernize/appcat/result/report.json` -- Moved to versioned directory: `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/report.json` - -**For JavaScript/TypeScript projects** (direct execution): -- Saved to versioned directory: `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/js-assessment-report.md` - -## Success Criteria - -Assessment is complete when: -- ✅ **For Java**: MCP server is available (or clear instructions provided if not) -- ✅ **For .NET**: .NET SDK is available and dotnet-appcat tool is installed -- ✅ **For JavaScript/TypeScript**: Node.js and npm are available and npm-check-updates is installed -- ✅ AppCAT analysis executes without errors (Java/.NET) or ncu analysis executes without errors (JS/TS) -- ✅ **For Java and .NET**: Report generated at `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/report.json` -- ✅ **For JavaScript/TypeScript**: Report generated at `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/js-assessment-report.md` -- ✅ Report metadata includes assessment tool version, timestamp, and configuration - -## Troubleshooting - -**Prerequisites Not Met**: -- **For Java**: First check for 'appmod-run-assessment-action', then fall back to 'appmod-precheck-assessment' - - Return immediately with setup instructions if neither tool is available - - Do not attempt to run assessment without MCP -- **For .NET**: Verify .NET SDK is installed - - Check with `dotnet --version` command - - Provide installation instructions if .NET SDK is missing -- **For JavaScript/TypeScript**: Verify Node.js and npm are installed - - Check with `npm --version` command - - Provide installation instructions if npm is missing - -**Assessment Failures**: -- Unsupported project type (only Java, .NET, and JavaScript/TypeScript supported) -- **For Java**: MCP server communication errors -- **For .NET**: - - dotnet-appcat tool installation failure - - appcat command execution errors -- **For JavaScript/TypeScript**: - - npm-check-updates installation failure - - ncu command execution errors - - No package.json found at `{workspace-path}/package.json` -- Invalid project structure or build configuration - -**Report Generation Issues**: -- **For Java (primary)**: No report.json found under `{workspace-path}/.github/modernize/assessment/reports/report-*/report.json` after MCP execution -- **For Java (legacy fallback)**: No report.json found under `{workspace-path}/.github/modernize/appcat/result/` after MCP execution -- **For .NET**: Report not generated at `{workspace-path}/.github/modernize/appcat/result/report.json`, or `metadata.analysisStartTime` missing from report -- **For JavaScript/TypeScript**: Report not generated at `{workspace-path}/.github/modernize/assessment/reports/report-{reportId}/js-assessment-report.md` -- Report file is corrupted or invalid JSON (Java/.NET only) - -For any failure, provide clear error messages and troubleshooting steps. +Assess one repository using only plugin-shipped skills, scripts, AppCAT, npm-check-updates, and GitHub advisory access. Assessment must not call any MCP tool. The App Modernization MCP server remains available to other phases, but it is outside this skill's execution path. + +## Inputs + +- `workspace-path`: Absolute project root. Defaults to the current directory. +- `invocation-mode`: `standalone`, `coordinator`, or `batch-headless`. +- `attempt-request-path` (batch-headless only): Absolute v1 request artifact created by the batch control plane. +- `config` (optional): Explicit user overrides only. Never infer or fill unspecified fields. + - `domains`: `java-upgrade`, `cloud-readiness`, `security` + - `analysisCoverage`: `issue-only` or `full` + - `targetRuntime`, `targetComputeServices`, `enableContainerization`, `targetOS` + - `minimumCveSeverity`, `cveScanScope` + +For `standalone` and `coordinator`, `config.analysisCoverage` is the only authority for a non-default coverage. A missing field always resolves to coverage `issue-only` with source `default`; a present valid field resolves to that exact value with source `explicit-user`. Never infer coverage from the request wording, repository content, size, complexity, technologies, or expected findings. For `batch-headless`, use the approved `request.decisions.analysisCoverage` with source `approved-batch`. + +Defaults: + +- Java: domains `java-upgrade,cloud-readiness`; coverage `issue-only`; capability `openjdk25`; target OS `windows,linux`; minimum CVE severity `high`; CVE scan scope `direct`. +- .NET: domain `cloud-readiness`; coverage `issue-only`. +- JavaScript/TypeScript: local dependency assessment; automated Planning remains unsupported. + +In `batch-headless` mode, never call `ask_user`. Read workspace, scope, approval, config, attempt scratch, and result path only from the request artifact. Batch Assessment accepts only fully approved input; missing required information fails the attempt instead of selecting a default or starting a persisted `NeedsInput` exchange. + +## Hard Boundaries + +- Do not invoke any MCP tool during assessment. +- Do not discover or execute skills outside the plan produced by `assessment-catalog.mjs`. +- Do not modify application source or build manifests. +- Subagent text is not completion evidence; verify files and normalize results through `assess-cli.mjs`. + +## 1. Bootstrap And Detect Language + +The coordinator derives the source CLI from this skill's absolute loaded `SKILL.md` path and bootstraps the workspace runtime on demand at: + +```text +.github/modernize/.runtime/assessment/assess-cli.mjs +``` + +Bootstrap the supplied `workspace-path` directly from the loaded skill before using the workspace runtime: + +```bash +node /scripts/assess-cli.mjs bootstrap \ + --workspace-path +``` + +Never guess the loaded skill path or derive it from a plugin-root environment variable. + +Detect from the supplied root: + +- Java: `pom.xml`, `build.gradle`, `build.gradle.kts`, or Java source. +- .NET: `.sln`, `.slnx`, `.csproj`, or C# source. +- JavaScript/TypeScript: `package.json`. +- Mixed Java/.NET root: assess each detected project root independently. +- No supported indicator: stop with an actionable error. + +## 2. Prepare The Local Run + +Create a UTC `yyyyMMddHHmmss` run ID, then call: + +```bash +node .github/modernize/.runtime/assessment/assess-cli.mjs prepare-run \ + --workspace-path \ + --run-id \ + --language \ + --domains \ + --coverage \ + --coverage-source +``` + +The coverage value and source must be the pair resolved from the input contract above. In particular, `config: {}` requires `--coverage issue-only --coverage-source default`; `config: {"analysisCoverage":"full"}` requires `--coverage full --coverage-source explicit-user`. + +Treat its JSON output as the only assessment task plan. It prepares run state, removes stale canonical outputs, and returns paths for AppCAT, findings, reports, and independent subagent batches. + +For `batch-headless`, also pass attempt-scoped controls from the request: + +```bash + --attempt-scratch-root /scratch \ + --max-concurrency +``` + +These options isolate AI task outputs and cap each wave. Omitting them preserves the single-repository paths and 6/7 task ceilings. + +## 3. Run Deterministic Local Engines + +### Java And .NET AppCAT + +Run AppCAT when `cloud-readiness` or `java-upgrade` is selected: + +```bash +node .github/modernize/.runtime/assessment/assess-cli.mjs ensure-appcat \ + --language + +node .github/modernize/.runtime/assessment/assess-cli.mjs run-appcat \ + --language \ + --workspace-path \ + --run-dir \ + --mode issue-only +``` + +Pass only explicitly requested targets, capabilities, or target OS to `run-appcat`. Do not turn the public report metadata defaults into AppCAT execution filters; the canonical publisher supplies Java capability `openjdk25` and target OS `windows,linux` when no override was requested. Full coverage does not change AppCAT mode: it adds the six fact documents in the next section. + +Normalize the produced report: + +```bash +node .github/modernize/.runtime/assessment/assess-cli.mjs integrate-appcat \ + --report /report.json \ + --findings \ + --run-id +``` + +If AppCAT fails, continue only with explicitly selected batches that do not require it and return `partial`. + +### JavaScript/TypeScript Dependencies + +Run the pinned npm-check-updates release without modifying `package.json`: + +```bash +node .github/modernize/.runtime/assessment/assess-cli.mjs run-ncu \ + --package-json /package.json \ + --output-dir /javascript \ + --run-id \ + --findings +``` + +Record the generated JSON result through `record-result`. Return `planningSupported: false`. + +## 4. Execute Plugin-Owned AI Batches + +Use only the batches returned by `prepare-run`. Execute batches one at a time and execute every catalog task yourself, serially, in catalog order. Never invoke a subagent, general-purpose agent, coordinator, router, or phase agent. Load exactly the task's `skill-id`, pass its absolute `workspace-path`, plan-provided absolute `output-path`, and explicit task settings, then finish and normalize that result before loading the next skill. The returned `maxConcurrency` is a ceiling; serial execution is required in standalone, coordinator, and `batch-headless` modes. + +### Full-Coverage Facts: Exactly 6 + +Coverage `full` contains exactly these plugin-level skills: + +1. `architecture-diagram` +2. `dependency-map` +3. `api-service-contracts` +4. `data-architecture` +5. `configuration-inventory` +6. `business-workflows` + +Each skill execution receives `workspace-path` and its plan-provided output path. Each owns one Markdown file under `.github/modernize/assessment/engines/facts/`. Execute all six serially and verify all six files exist. Do not launch granular `fact-*` skills; they are not part of this implementation. + +### Security: Exactly 7 + +The local security batch contains: + +- `cve-known-vulnerabilities`; and +- six CWE category skills: + - `cwe-code-quality` + - `cwe-concurrency-synchronization` + - `cwe-credentials-secrets` + - `cwe-file-path-security` + - `cwe-injection-attacks` + - `cwe-memory-safety` + +All seven are independent top-level plugin skills under `skills//SKILL.md`; none is nested under `assessment`. + +Execute all seven serially. Save each complete skill result to its plan-provided JSON output path, then normalize every result: + +```bash +node .github/modernize/.runtime/assessment/assess-cli.mjs record-result \ + --skill \ + --input \ + --findings \ + --run-id \ + --run-dir +``` + +Every CWE rule must end as FOUND or NOT_FOUND. A missing/malformed result or PENDING rule makes security partial; never synthesize an empty success. + +### Concurrency + +There is no subagent scheduler. Facts and security tasks run serially in separate batches; AppCAT-only Assessment has no AI task batch. When both security and full coverage are selected, finish one batch before starting the next. The catalog retains a maximum concurrency value for request compatibility, but the phase agent always operates at effective concurrency 1. + +## 5. Generate And Verify Reports + +Generate the self-contained local report from normalized findings: + +```bash +node .github/modernize/.runtime/assessment/assess-cli.mjs generate-report \ + --memory-dir \ + --run-id \ + --output-dir \ + --project-root \ + --enrichment /dev/null +``` + +Generate the internal normalized Assessment after every deterministic engine and AI batch has finished: + +```bash +node .github/modernize/.runtime/assessment/assess-cli.mjs generate-normalized-assessment \ + --memory-dir \ + --run-id \ + --language \ + --solution-mapping .github/modernize/.runtime/assessment/solution-mapping.json +``` + +This writes `.github/modernize/.memory/runs//normalized-assessment.json`. It is an internal Planning and Batch validation sidecar, not a public assessment report. Do not show its path in the user-facing Assessment summary. + +For Java/.NET runs with an AppCAT report, publish the public canonical report after normalization so plugin-owned CVE/CWE findings can be merged into `security[]`: + +```bash +node .github/modernize/.runtime/assessment/assess-cli.mjs publish-appcat-report \ + --source /report.json \ + --memory-dir \ + --output-dir \ + --run-id \ + --language \ + --domains \ + --coverage +``` + +For JavaScript/TypeScript and security-only runs without AppCAT, synthesize the same public Unified report shape from complete assessment memory: + +```bash +node .github/modernize/.runtime/assessment/assess-cli.mjs generate-canonical-report \ + --memory-dir \ + --output-dir \ + --workspace-path \ + --run-id \ + --language \ + --domains \ + --coverage +``` + +Both paths create `.github/modernize/assessment/reports/report-/report.json` with the established public `producer + metadata + summary + projects + rules + security` structure. The internal normalized sidecar uses `schemaVersion: 1` and never reuses the public report's `version` field. + +For full coverage, archive and verify all six fact documents beside the canonical report: + +```bash +node .github/modernize/.runtime/assessment/assess-cli.mjs archive-facts \ + --workspace-path \ + --report \ + --coverage full \ + --facts-root /scratch/engines/facts +``` + +Omit `--facts-root` outside batch mode to preserve the canonical single-repository source path. + +Finally, validate the completed run and derive the return evidence from disk. For `batch-headless`, use the machine JSON presentation: + +```bash +node .github/modernize/.runtime/assessment/assess-cli.mjs verify-artifacts \ + --workspace-path \ + --run-id \ + --language \ + --domains \ + --coverage \ + --report \ + --normalized-assessment \ + --html \ + --appcat-report \ + --security-root +``` + +For `coordinator` or `standalone`, pass the same artifact roles and request the deterministic public-style presentation: + +```bash +node .github/modernize/.runtime/assessment/assess-cli.mjs verify-artifacts \ + --workspace-path \ + --run-id \ + --language \ + --domains \ + --coverage \ + --report \ + --normalized-assessment \ + --html \ + --appcat-report \ + --presentation user +``` + +Omit `--appcat-report` when no AppCAT domain was selected. Omit `--security-root` when security was not selected; outside batch mode its canonical default is used. This command is the only completion authority. Do not return `success` unless it exits zero and its receipt's `artifactValidation` is exactly `passed`. The user presentation writes that complete receipt to `verification.json` beside canonical `report.json` and includes the exact `Verification: passed` line plus its path. In coordinator mode, this must be the final tool call: immediately return its complete stdout verbatim with no wrapper, summary, field renaming, additional command, or recalculation. In standalone or batch-headless mode, copy paths, finding counts, top recommendation, and partial task IDs only from that receipt; do not infer or reconstruct them from prior command output or subagent text. A nonzero exit makes the run `partial` or `failed` with the verifier error and must never be converted into success. + +Completion requires: + +- public-compatible versioned `report.json` exists and parses for every language and domain combination; +- internal `normalized-assessment.json` exists, validates against its v1 schema, and matches the run request; +- HTML report exists; +- AppCAT report exists when an AppCAT domain was selected; +- all seven security outputs have terminal data when security was selected; +- all six fact Markdown files are archived when coverage is full. + +## Required Return + +Return: + +- status: `success`, `partial`, `cancelled`, or `failed`; +- the complete final verifier output without changing any field; +- `artifactValidation`: exactly `passed` for `success`; +- the verifier's nested `completionEvidence` object; +- detected language; +- selected domains and coverage; +- finding counts and top recommendations; +- canonical report, HTML, and verification paths; the normalized sidecar remains inside the verification receipt; +- six fact paths for full coverage; +- failed or missing local tasks; +- `planningSupported`: true for Java/.NET, false for JavaScript/TypeScript. + +Coordinator mode returns the verifier's deterministic natural-language summary and persisted `verification.json` evidence. Batch-headless returns machine JSON and does not show a standalone next-action menu. Standalone mode presents the report and stops; implementation fixes are outside this skill. \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/assessment/resources/solution-mapping.json b/plugins/github-copilot-modernization/skills/assessment/resources/solution-mapping.json new file mode 100644 index 0000000..7e45499 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/assessment/resources/solution-mapping.json @@ -0,0 +1,2393 @@ +{ + "$schema": "./solution-mapping-schema.json", + "solutions": [ + { + "solutionId": "bare/thirdparty-generic", + "name": "Get migration guidance from Copilot", + "type": "Chat", + "prompt": "Analyze this migration issue in the context of the affected code and application architecture. Provide a clear explanation of the underlying problem, the Azure-ready remediation strategy, and any relevant tradeoffs or prerequisites. Then outline a practical, step-by-step implementation plan, including the code, configuration, dependency, and validation changes needed to complete the migration safely.", + "tooltip": "No specific solution matched this issue. Chat with Copilot for tailored migration guidance." + }, + { + "solutionId": "scan-and-resolve-cwe-vulnerabilities", + "name": "Scan and resolve CWE vulnerabilities", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Security vulnerability detected. Ask Copilot to scan and resolve it." + }, + { + "solutionId": "scan-and-resolve-cve-vulnerabilities", + "name": "Resolve CVE issues by upgrading to secure, vulnerability-free versions", + "type": "Formula", + "effort": "MEDIUM", + "tooltip": "Scan and fix CVE vulnerabilities." + }, + { + "solutionId": "activemq-servicebus", + "name": "Migrate from Active Artemis to Azure Service Bus", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from ActiveMQ Artemis to Azure Service Bus for messaging." + }, + { + "solutionId": "java-ee-amqp-rabbitmq-servicebus", + "name": "Migrate from RabbitMQ(AMQP) to Azure Service Bus for Java EE/Jakarta EE", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from RabbitMQ with AMQP to Azure Service Bus for messaging in Java EE/Jakarta EE applications." + }, + { + "solutionId": "amqp-rabbitmq-servicebus", + "name": "Migrate from RabbitMQ(AMQP) to Azure Service Bus", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from RabbitMQ with AMQP to Azure Service Bus for messaging." + }, + { + "solutionId": "ibm-mq-jms-to-azure-service-bus", + "name": "Migrate IBM MQ to Azure Service Bus via JMS", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from IBM JMS to Azure Service Bus for messaging.", + "experimental": true + }, + { + "solutionId": "javax.email-send-to-azure-communication-service-email", + "name": "Migrate to Azure Communication Service", + "type": "Formula", + "effort": "LOW", + "tooltip": "Migrate from Javax Email to Azure Communication Service for sending emails." + }, + { + "solutionId": "jax-rpc-to-jax-ws", + "name": "Migrate from JAX-RPC to JAX-WS", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from JAX-RPC to JAX-WS for web services. JAX-RPC is deprecated and JAX-WS is the recommended alternative." + }, + { + "solutionId": "java-version-upgrade", + "name": "Upgrade Java Version", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Upgrade to the latest stable version of Java for improved security, performance, and compatibility." + }, + { + "solutionId": "deprecated-api-upgrade", + "name": "Upgrade Deprecated APIs", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Upgrade deprecated APIs to their recommended alternatives for improved security, performance, and compatibility." + }, + { + "solutionId": "spring-boot-upgrade", + "name": "Upgrade Spring Boot Version", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Upgrade to the latest stable version of Spring Boot for improved security, performance, and compatibility." + }, + { + "solutionId": "spring-framework-upgrade", + "name": "Upgrade Spring Framework Version", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Upgrade to the latest stable version of Spring Framework for improved security, performance, and compatibility." + }, + { + "solutionId": "jakarta-ee-upgrade", + "name": "Upgrade Jakarta EE Version", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Upgrade to the latest stable version of Jakarta EE for improved security, performance, and compatibility." + }, + { + "solutionId": "confluent-cloud-kafka", + "name": "Migrate from Kafka to Kafka on Confluent Cloud", + "type": "Formula", + "effort": "HIGH", + "variants": [ + "kafka-to-eventhubs" + ], + "tooltip": "Migrate from Kafka to Apache Kafka on Confluent Cloud with managed identity for secure, credential-free authentication." + }, + { + "solutionId": "kafka-to-eventhubs", + "name": "Migrate from Kafka to Azure Event Hubs for Apache Kafka", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Kafka to Azure Event Hubs for Apache Kafka with managed identity for secure, credential-free authentication." + }, + { + "solutionId": "other-cache-solutions-to-azure-managed-cache", + "name": "Migrate Other Cache Solutions to Azure Managed Redis", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from other cache solutions (like Apache Commons JCS, Ehcache, Hazelcast, Infinispan, or local Redis/session) to Azure Managed Redis." + }, + { + "solutionId": "log-to-console", + "name": "Migrate to Console Logging", + "type": "Formula", + "effort": "MEDIUM", + "tooltip": "Migrate from file-based logging to console logging to support cloud-native apps and integration with Azure Monitor." + }, + { + "solutionId": "mi-azure-sql", + "name": "Secure Azure SQL Database with Managed Identity", + "type": "Formula", + "effort": "MEDIUM", + "tooltip": "Secure Azure SQL Database with Managed Identity." + }, + { + "solutionId": "mi-cassandra", + "name": "Secure Azure Cosmos DB for Cassandra with Service Connector", + "type": "Formula", + "effort": "LOW", + "tooltip": "Secure Azure Cosmos DB for Cassandra with Service Connector for a fully managed, scalable database with Cassandra API support." + }, + { + "solutionId": "mi-mariadb", + "name": "Migrate to Azure Database for MariaDB (Spring)", + "type": "Formula", + "effort": "MEDIUM", + "tooltip": "Migrate from MariaDB to Azure Database for MariaDB with managed identity for secure, credential-free authentication." + }, + { + "solutionId": "mi-mongodb", + "name": "Secure Azure DocumentDB (with MongoDB Compatibility) with Microsoft Entra ID Authentication", + "type": "Formula", + "effort": "LOW", + "tooltip": "Secure Azure DocumentDB (with MongoDB Compatibility) with Microsoft Entra ID authentication." + }, + { + "solutionId": "mi-mysql", + "name": "Migrate to Azure Database for MySQL", + "type": "Formula", + "effort": "MEDIUM", + "tooltip": "Migrate from MySQL to Azure Database for MySQL with managed identity for secure, credential-free authentication." + }, + { + "solutionId": "mi-postgresql", + "name": "Secure Azure Database for PostgreSQL with Managed Identity", + "type": "Formula", + "effort": "MEDIUM", + "tooltip": "Secure Azure Database for PostgreSQL with Managed Identity." + }, + { + "solutionId": "AWS-secrets-manager-to-azure-key-vault", + "name": "Migrate from AWS Secrets Manager to Azure Key Vault", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from AWS Secrets Manager to Azure Key Vault to securely manage and access sensitive information in Azure." + }, + { + "solutionId": "certificate-management-to-azure-key-vault", + "name": "Migrate from KeyStore to Azure Key Vault", + "type": "Formula", + "effort": "LOW", + "tooltip": "Migrate from a local KeyStore to Azure Key Vault for secure storage and access to certificates and keys." + }, + { + "solutionId": "local-files-to-mounted-azure-storage", + "name": "Migrate to Azure Storage Account File Share mounts", + "type": "Formula", + "effort": "LOW", + "tooltip": "Migrate from local file system to Azure Storage Account File Share mounts for scalable and secure file storage." + }, + { + "solutionId": "on-premises-user-authentication-to-microsoft-entra-id", + "name": "Migrate from on-premises user authentication to Microsoft Entra ID", + "description": "TODO: need to further check if this aligns with the solution", + "type": "Formula", + "effort": "MEDIUM", + "tooltip": "Migrate from on-premises user authentication systems to Microsoft Entra ID for secure and scalable user management in Azure." + }, + { + "solutionId": "plaintext-credential-to-azure-keyvault", + "name": "Migrate from Plaintext Credentials to Azure Key Vault", + "type": "Formula", + "effort": "LOW", + "tooltip": "Migrate from plaintext credentials in the code to Azure Key Vault for storage and access to sensitive information." + }, + { + "solutionId": "s3-to-azure-blob-storage", + "name": "Migrate from AWS S3 to Azure Blob Storage", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from AWS S3 to Azure Blob Storage for scalable and secure object storage in Azure." + }, + { + "solutionId": "spring-jms-rabbitmq-servicebus", + "name": "Migrate from RabbitMQ(JMS) to Azure Service Bus", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from RabbitMQ with JMS to Azure Service Bus for a managed messaging service with JMS API support." + }, + { + "solutionId": "sqs-to-servicebus", + "name": "Migrate from AWS Simple Queue Service to Azure Service Bus", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from AWS Simple Queue Service to Azure Service Bus for a managed messaging service with advanced features." + }, + { + "solutionId": "oracle-to-postgresql", + "name": "Migrate from Oracle DB to PostgreSQL", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Oracle DB to PostgreSQL" + }, + { + "solutionId": "bare/redesign-java-gui-app", + "name": "Redesign Java GUI application to migrate it to Azure", + "type": "Chat", + "prompt": "The application uses desktop GUI framework which requires desktop runtime and is not cloud-native, consider containerizing for Azure deployment or redesigning as a web application.", + "tooltip": "Redesign the Java app's graphical user interface (GUI) using Java Swing or JavaFX to migrate it to Azure." + }, + { + "solutionId": "bare/apm-to-application-insights", + "name": "Migrate APM to application insights", + "type": "Chat", + "prompt": "The app uses an application performance monitoring (APM) platform. To migrating Java app to Azure, use Azure Monitor or Application Insights for built-in tracing and auto-instrumentation support in Azure services.", + "tooltip": "The app uses an application performance monitoring (APM) platform. Chat with Copilot to learn how to migrate APM to Azure Monitor or Application Insights." + }, + { + "solutionId": "bare/encoding-standards", + "name": "Check Encoding in the Code", + "type": "Chat", + "prompt": "The code uses UTF-8 by default. If it's not appropriate for your code, use a different character set.", + "tooltip": "The code uses UTF-8 by default. Chat with Copilot to learn how to check and update the encoding." + }, + { + "solutionId": "bare/local-resource-access", + "name": "Migrate the Local Resource to Azure", + "type": "Chat", + "prompt": "The application is using some resource or service from localhost. When you migrate the application to Azure, you also need to migrate the dependent resource or service to Azure.", + "tooltip": "The app uses some resource or service from localhost. Chat with Copilot to learn how to migrate the local resource to Azure." + }, + { + "solutionId": "bare/remote-communication", + "name": "Use Loosely coupled protocols in Cloud Environment", + "type": "Chat", + "prompt": "The app uses legacy protocols. Please use loosely coupled protocols like REST, gRPC, etc.", + "tooltip": "The app uses legacy protocols. Chat with Copilot to learn how to use loosely coupled protocols like REST, gRPC, etc." + }, + { + "solutionId": "bare/remote-communication/java-socket", + "name": "Use Java Socket Communication in Cloud Environment", + "type": "Chat", + "prompt": "The application uses Java socket communication, which depends on fixed IP addresses and ports, making it unsuitable for cloud environments where service endpoints are dynamic and scaling is required. Replace socket-based communication with cloud-friendly, loosely coupled alternatives such as RESTful APIs, gRPC, JMS messaging, Azure Service Bus, etc.", + "tooltip": "The app uses legacy protocols. Chat with Copilot to learn how to use loosely coupled protocols like REST, gRPC, etc." + }, + { + "solutionId": "bare/remote-communication/corba", + "name": "Check CORBA usage", + "type": "Chat", + "prompt": "The application uses CORBA which is tightly coupled and not suitable for cloud environments. Replace with REST APIs, gRPC, or Azure Service Bus for messaging. Use Azure API Management for API gateway capabilities.", + "tooltip": "The app uses CORBA for remote communication. Chat with Copilot to learn how to review and update it when migrating to Azure." + }, + { + "solutionId": "bare/remote-communication/hardcode-ip", + "name": "Check hardcoded IP address", + "type": "Chat", + "prompt": "The application uses hardcoded IP addresses. When migrating to Azure cloud, review and update any hardcoded IP addresses as needed, or migrate the dependent services accordingly.", + "tooltip": "The app uses hardcoded IP addresses. Chat with Copilot to learn how to review and update them when migrating to Azure." + }, + { + "solutionId": "bare/remote-communication/secure-protocols", + "name": "Use Secure Protocols", + "type": "Chat", + "prompt": "The application uses insecure protocols. When migrating to Azure cloud, review and update any insecure protocols to secure protocols such as HTTPS and SFTP (over HTTP and FTP).", + "tooltip": "The app uses insecure protocols. Chat with Copilot to learn how to switch to secure ones like HTTPS or SFTP." + }, + { + "solutionId": "bare/remote-communication/hardcoded-urls", + "name": "Check hardcoded URLs", + "type": "Chat", + "prompt": "The application uses hardcoded URLs. When migrating to Azure cloud, review and update any hardcoded URLs as needed, or migrate the dependent services accordingly.", + "tooltip": "The app uses hardcoded URLs. Chat with Copilot to learn how to review and update them when migrating to Azure." + }, + { + "solutionId": "bare/os-compatibility", + "name": "Redesign OS Specific Code", + "type": "Chat", + "prompt": "The app uses a Windows Dynamic-Link Library (DLL). Redesign the code to avoid using OS specific code.", + "tooltip": "The app uses a Windows Dynamic-Link Library (DLL). Chat with Copilot to learn how to redesign the code to avoid using OS specific code." + }, + { + "solutionId": "bare/java-native-code", + "name": "Build Native Process into Container Image", + "type": "Chat", + "prompt": "The application uses Java native libraries (JNI, JNA) which may not be compatible with cloud container environments. Identify these dependencies and either containerize them with matching base images or replace them with platform-independent libraries, cloud-native solutions, or Azure managed services.", + "tooltip": "The app uses Java native libraries (JNI, JNA). Chat with Copilot to learn how to build them into containers or find Azure alternatives." + }, + { + "solutionId": "bare/jakataee-to-azure", + "name": "Deploy JakartaEE App to Azure", + "type": "Chat", + "prompt": "The Application relies on Jakarta EE APIs. Azure provides support for Jakarta EE applications from different vendors, including Red Hat OpenShift, IBM WebSphere Liberty, and Oracle WebLogic Server.", + "tooltip": "The app uses Jakarta EE APIs. Chat with Copilot to learn how to deploy them to Azure using supported vendors." + }, + { + "solutionId": "bare/jakataee-to-azure/rmi", + "name": "Check Java Remote Method Invocation(RMI)", + "type": "Chat", + "prompt": "The application uses Java RMI which is tightly coupled and not cloud-ready, replace it with HTTP-based RESTful APIs for standard communication or Azure Service Bus for messaging scenarios.", + "tooltip": "The app uses Java RMI. Chat with Copilot to learn how to replace it with cloud-ready alternatives." + }, + { + "solutionId": "bare/jakataee-to-azure/jca", + "name": "Check Java Connector Architecture(JCA)", + "type": "Chat", + "prompt": "The application uses Java Connector Architecture(JCA) which is tightly coupled and not suitable for cloud scalability, replace with appropriate Azure managed services like Azure Service Bus, Azure Event Hub, etc.", + "tooltip": "The app uses Java Connector Architecture(JCA). Chat with Copilot to learn how to replace it with cloud-ready alternatives." + }, + { + "solutionId": "bare/configuration-management/environment-variables", + "name": "Configure System Environment Variables", + "type": "Chat", + "prompt": "The application uses environment variables or system properties. When migrating to Azure, they need to be passed according to the target hosting service's setup. If they contain sensitive information, it's better to store in KeyVault. If some configurations are shared, Azure App Configuration service may be an option to store them.", + "tooltip": "The app uses environment variables or system properties. Chat with Copilot to learn how to configure system environment variables when migrating to Azure." + }, + { + "solutionId": "bare/configuration-management/external-configuration", + "name": "Manage External Configuration", + "type": "Chat", + "prompt": "The app stores settings in external files other than web.config. When migrating to Azure, they need to be passed according to the target hosting services's setup. If they contain sensitive information, it's better to store in KeyVault. If some configurations are shared, Azure App Configuration service may be an option to store them.", + "tooltip": "The app stores settings in external files. Chat with Copilot to learn how to manage external configuration when migrating to Azure." + }, + { + "solutionId": "bare/configuration-management/windows-registry", + "name": "Manage Windows Registry Configuration", + "type": "Chat", + "prompt": "The application writes application settings into OS-specific storage such as Windows Registry. When migrating to Azure, these application settings should not be defined in such storage. If they contain sensitive information, it's better to store in KeyVault. If some configurations are shared, Azure App Configuration service may be an option to store them.", + "tooltip": "The app stores settings in OS-specific storage like the Windows Registry. Chat with Copilot to learn managing Windows Registry configuration when migrating to Azure." + }, + { + "solutionId": "quartz-scheduler-to-azure-functions", + "name": "Migrate from Quartz Scheduler to Azure Functions", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Quartz Scheduler to Azure Functions for serverless, event-driven task scheduling in Azure.", + "experimental": true + }, + { + "solutionId": "bare/spring-migration", + "name": "Deploy Spring Cloud App To Azure", + "type": "Chat", + "prompt": "The application has Spring Boot or Spring Cloud dependencies. Azure Container Apps offers managed components for Spring Cloud, so it may be an option for Spring Cloud migration. Special attention is required for some environment related settings, such as server.port, Config Server or Eureka bindings.", + "tooltip": "The app uses Spring Boot or Spring Cloud. Chat with Copilot to learn how to deploy Spring Cloud apps to Azure using Azure Container Apps." + }, + { + "solutionId": "bare/eap-migration/jboss-eap", + "name": "Deploy JBoss EAP to Azure", + "type": "Chat", + "prompt": "The app uses JBoss EAP related code, configs, dependencies, and/or environment settings. JBoss EAP is a Java EE application server available on Azure.", + "tooltip": "The app uses JBoss EAP related code, configs, dependencies, and/or environment settings. Chat with Copilot to learn how to deploy JBoss EAP apps to Azure using supported vendors." + }, + { + "solutionId": "bare/azure-service-connector", + "name": "Use Azure Service Connector", + "type": "Chat", + "prompt": "The app uses VMware Tanzu Application Service (TAS) service bindings. In Azure, use Azure Service Connect to link to Azure services.", + "tooltip": "The app uses VMware Tanzu Application Service (TAS) service bindings. Chat with Copilot to learn how to use Azure Service Connector to connect to Azure services." + }, + { + "solutionId": "bare/aws-region-configuration-to-azure", + "name": "Migrate from AWS Region Configuration to Azure Region Configuration", + "type": "Chat", + "prompt": "The app has AWS region settings. Identify the AWS service to migrate, find Azure alternatives, and check their region availability. Provide the links to Azure docs page for latest region availability.", + "tooltip": "The app has AWS region settings. Chat with Copilot to learn how to migrate from AWS region configuration to Azure region configuration." + }, + { + "solutionId": "bare/spring-cloud-vault-migration", + "name": "Migrate from Spring Cloud Vault to Azure Key Vault", + "type": "Chat", + "prompt": "The application integrates with Spring Cloud Vault. To migrate a Java application that uses Spring Cloud Vault to Azure, you should identify all secrets and the backing secret store, then migrate them to Azure Key Vault. Use the Azure Key Vault Spring Boot Starter for secret injection. You may need to rename some secrets and update references in the application code.", + "tooltip": "The app integrates with Spring Cloud Vault. Chat with Copilot to learn how to migrate from Spring Cloud Vault to Azure Key Vault." + }, + { + "solutionId": "bare/aws-credentials-to-azure", + "name": "Migrate from AWS Access Key ID/Secret to Azure Credentials", + "type": "Chat", + "prompt": "The application contains AWS credential configuration. We need to find out what AWS service we want to migrate, find the candidate alternatives on Azure, do the code changes according to the source service and target service. Secrets should be stored in Azure Key Vault. The best practice is to use DefaultAzureCredential to authenticate to Azure and access the target service.", + "tooltip": "The app has AWS credential configuration. Chat with Copilot to learn how to migrate from AWS access key ID/secret to Azure credentials." + }, + { + "solutionId": "bare/openliberty-migration/openliberty-database", + "name": "Migrate from Open Liberty Database to Azure Database Services", + "type": "Chat", + "prompt": "The application uses Open Liberty database configurations and datasources. When migrating to Azure, identify the specific database type (MySQL, PostgreSQL, SQL Server) and migrate to the appropriate Azure Database service. Use connection pooling optimized for cloud environments and configure Azure Key Vault to securely store connection strings. Implement DefaultAzureCredential for managed identity authentication to eliminate hard-coded credentials. Consider using Azure App Configuration for centralized connection management across environments.", + "tooltip": "The app uses Open Liberty database configurations and datasources. Chat with Copilot to learn how to migrate from Open Liberty database to Azure Database Services." + }, + { + "solutionId": "bare/openliberty-migration/openliberty-filesystem", + "name": "Migrate from Open Liberty Filesystem to Azure Storage", + "type": "Chat", + "prompt": "The application uses Open Liberty filesystem for data storage or configuration. When migrating to Azure, replace local filesystem dependencies with Azure Blob Storage or Azure Files depending on access patterns. For read-heavy shared configuration, consider Azure Blob Storage with CDN. For applications requiring file system mounting, use Azure Files with SMB protocol. Implement the Azure Storage SDK with DefaultAzureCredential for secure access, and store any access keys in Azure Key Vault. Consider data access patterns when selecting storage tier and replication options.", + "tooltip": "The app uses Open Liberty filesystem for data storage or configuration. Chat with Copilot to learn how to migrate from Open Liberty filesystem to Azure Storage." + }, + { + "solutionId": "bare/openliberty-migration/openliberty-jms", + "name": "Migrate from Open Liberty JMS to Azure Service Bus", + "type": "Chat", + "prompt": "The application uses Open Liberty Java Message Service (JMS) for messaging. When migrating to Azure, Azure Service Bus is the recommended alternative. Analyze current JMS usage patterns (queues, topics, message selectors) to map to Service Bus concepts. Use the JMS over AMQP provider with the Azure Service Bus SDK for Java. Implement DefaultAzureCredential for authentication, store connection strings in Azure Key Vault, and adjust client-side configurations for cloud reliability patterns like retry policies and circuit breakers.", + "tooltip": "The app uses Open Liberty Java Message Service (JMS) for messaging. Chat with Copilot to learn how to migrate from Open Liberty JMS to Azure Service Bus." + }, + { + "solutionId": "bare/openliberty-migration/openliberty-logging", + "name": "Migrate from Open Liberty Logging to Azure Monitor", + "type": "Chat", + "prompt": "The application uses Open Liberty logging configurations. When migrating to Azure, implement a cloud-native logging strategy using Azure Monitor and Application Insights. Configure the Application Insights Java agent for auto-instrumentation or use the Application Insights SDK for more customization. For structured logging, consider Log Analytics workspace integration. Update logging configurations to use console output (stdout/stderr) instead of files when deployed to Azure App Service or Azure Container Apps. Implement correlation IDs across services for distributed tracing and use Azure Monitor Workbooks for custom dashboards.", + "tooltip": "The app uses Open Liberty logging configurations. Chat with Copilot to learn how to migrate from Open Liberty logging to Azure Monitor." + }, + { + "solutionId": "bare/oraclejdk-to-openjdk/resource-management-apis", + "name": "Update Resource Management APIs for migration from Oracle JDK to OpenJDK", + "type": "Chat", + "prompt": "The application uses Resource Management APIs. When migrating to OpenJDK, OpenJDK does not support the resource management API for Java, review and update the resource management API usage to ensure compatibility with OpenJDK. Specifically, identify and replace the use of classes and methods from the `jdk.management.resource` package with alternative approaches for resource monitoring and management.", + "tooltip": "The app uses Resource Management APIs. Chat with Copilot to learn how to update Resource Management APIs for migration from Oracle JDK to OpenJDK." + }, + { + "solutionId": "bare/oraclejdk-to-openjdk/imageio", + "name": "Replace ImageIO usage for migration from Oracle JDK to OpenJDK", + "type": "Chat", + "prompt": "The application uses Oracle JDK JPEG image encoder/decoder usage. When migrating to OpenJDK, review and update the image encoder/decoder usage to ensure compatibility with OpenJDK. Specifically, identify and replace the use of classes and methods from the `com.sun.image.codec.jpeg` package with `javax.imageio.ImageIO`.", + "tooltip": "The application uses Oracle JDK JPEG image encoder/decoder usage. Chat with Copilot to learn how to replace ImageIO usage for migration from Oracle JDK to OpenJDK." + }, + { + "solutionId": "bare/database-migration/database-reliability", + "name": "Update database configurations for cloud readiness and resilience", + "type": "Chat", + "prompt": "The application uses database. When migrating to Azure, review and update the database configurations to ensure readiness for Azure cloud deployment. Specifically, identify any on-premise specific settings that are incompatible or suboptimal for Azure; recommend updates to support high availability, automatic failover, and geo-redundancy; ensure connection strings support retry policies, transient fault handling, and use managed identity authentication if possible; detect hardcoded paths, IPs, or dependencies that may need reconfiguration.", + "tooltip": "The app uses database. Chat with Copilot to learn how to update database configurations for cloud readiness and resilience." + }, + { + "solutionId": "bare/jakarta-auth-migration", + "name": "Migrate Jakarta EE Authentication to Microsoft Entra ID", + "type": "Chat", + "prompt": "The application uses Jakarta Authentication and Authorization APIs. When migrating to Azure, how should I modernize the authentication to integrate with Microsoft Entra ID? Please provide: 1. Code examples for replacing Jakarta Authentication with OAuth 2.0/OIDC. 2. Microsoft Entra ID configuration steps (App Registration, permissions) 3. Best practices for container-based authentication on Azure. 4. Authorization strategy (RBAC vs application claims). Include configuration samples and highlight key migration considerations.", + "tooltip": "The app uses Jakarta Authentication and Authorization APIs. Chat with Copilot to learn how to migrate Jakarta EE Authentication to Microsoft Entra ID." + }, + { + "solutionId": "bare/jakarta-websocket-migration", + "name": "Migrate Jakarta EE WebSocket", + "type": "Chat", + "prompt": "The application uses Jakarta WebSocket APIs. When migrating to Azure, please advise on: 1. Best Azure service for hosting WebSocket applications (self-hosted vs Azure Web PubSub)? 2. Code examples for migrating @ServerEndpoint to Azure-compatible patterns. 3. Required Azure configurations: session affinity, TLS, connection timeouts. 4. How to integrate Microsoft Entra ID authentication for WebSocket connections? 5. Load balancing and scalability considerations for real-time connections. Include code samples, configuration examples, and migration trade-offs.", + "tooltip": "The app uses Jakarta WebSocket APIs. Chat with Copilot to learn how to migrate Jakarta EE WebSocket." + }, + { + "solutionId": "bare/jakarta-jaxrs-migration", + "name": "Migrate Jakarta JAX-RS to Azure", + "type": "Chat", + "prompt": "My Java application uses Jakarta JAX-RS APIs (jakarta.ws.rs.* or javax.ws.rs.*) on a Jakarta EE/MicroProfile runtime. I need to migrate to Azure. Please advise on: 1. Deployment options - Azure App Service, AKS, or Container Apps for JAX-RS applications? 2. Configuration externalization - migrating to Azure App Configuration and Key Vault with code examples. 3. API security - securing JAX-RS endpoints with Microsoft Entra ID, OAuth 2.0/OIDC filters, and JWT validation in JAX-RS filters and interceptors. 4. Observability - integrating Azure Application Insights for telemetry and distributed tracing. 5. Production readiness - HTTPS configuration, Managed Identity, auto-scaling, and health checks. Include code examples, Azure configuration samples, and migration checklist.", + "tooltip": "The application uses Jakarta JAX-RS APIs for RESTful services. Chat with Copilot to learn how to migrate to Azure App Service, AKS, or Container Apps with proper security and monitoring." + }, + { + "solutionId": "bare/jakarta-nosql-migration", + "name": "Migrate Jakarta NoSQL to Azure", + "type": "Chat", + "prompt": "My application uses Jakarta NoSQL APIs (jakarta.nosql.*). I need to migrate to Azure. Please advise on: 1. Should I migrate to Azure Cosmos DB native SDKs? Which Cosmos DB API (NoSQL, MongoDB, Cassandra, Gremlin, Table) matches my data model? 2. How to update data access layer from Jakarta NoSQL to Cosmos DB SDK with code examples? 3. Configuration - connection strings, authentication (Managed Identity), and security. 4. Network security - VNet integration, private endpoints, and firewall rules. 5. Performance - throughput settings, consistency levels, and optimization. Include before/after code examples and Azure configuration.", + "tooltip": "The application uses Jakarta NoSQL APIs. Chat with Copilot to learn how to migrate to Azure Cosmos DB." + }, + { + "solutionId": "bare/jakarta-persistence-migration", + "name": "Migrate Jakarta JPA to Azure", + "type": "Chat", + "prompt": "My application uses Jakarta JPA APIs (jakarta.persistence.* or javax.persistence.*) with Hibernate/EclipseLink. I need to migrate to Azure. Please advise on: 1. Which Azure database - PostgreSQL, MySQL, or SQL Database? 2. Updating persistence.xml/properties for Azure connections, dialect, and connection pools. 3. Storing credentials in Azure Key Vault with Managed Identity examples. 4. Network security - VNet integration, private endpoints, and firewall rules. 5. Deployment on Azure App Service, AKS, or Container Apps. Include configuration examples and Spring Data JPA guidance.", + "tooltip": "The application uses Jakarta JPA APIs. Chat with Copilot to learn how to migrate to Azure database services." + }, + { + "solutionId": "bare/jakarta-data-migration", + "name": "Migrate Jakarta Data to Azure", + "type": "Chat", + "prompt": "My application uses Jakarta Data APIs (jakarta.data.*) for repository-based data access. I need to migrate to Azure. Please advise on: 1. For relational workloads: Azure PostgreSQL/MySQL/SQL Database; for NoSQL: Azure Cosmos DB - which fits my use case? 2. Ensuring Jakarta Data providers (Eclipse JNoSQL, Micronaut Data) work with Azure. 3. Updating repository configuration for Azure with connection URLs and credentials. 4. Network security - VNet integration, private endpoints, and firewall rules. 5. Using Azure Key Vault for credential management. 6. Deployment on Azure App Service, AKS, or Container Apps. Include configuration examples.", + "tooltip": "The application uses Jakarta Data APIs. Chat with Copilot to learn how to migrate to Azure databases." + }, + { + "solutionId": "bare/jboss-eap-to-azure-app-service", + "name": "Migrate JBoss EAP to Azure App Service", + "type": "Chat", + "prompt": "My application uses JBoss EAP and I need to migrate to Azure App Service. Please advise on: 1. Preparing JBoss EAP application for Azure App Service deployment. 2. Configuring JBoss EAP runtime (version, startup settings) and updating build files. 3. Managing configuration with Azure App Configuration and Key Vault. 4. Deployment options - Maven/Gradle plugins or CI/CD. 5. Setting up monitoring with Application Insights. Include configuration examples.", + "tooltip": "The application uses JBoss EAP. Chat with Copilot to learn how to migrate to JBoss EAP on Azure App Service." + }, + { + "solutionId": "bare/jboss-eap-to-aks", + "name": "Migrate JBoss EAP to Azure Kubernetes Service", + "type": "Chat", + "prompt": "My application uses JBoss EAP and I need to migrate to AKS. Please advise on two options: Option 1 - Lift-and-Shift: Using Red Hat JBoss EAP container images, Dockerfile examples, and Kubernetes manifests (Deployment, Service, ConfigMap). Option 2 - Refactor: Migrating to Spring Boot/Quarkus/Micronaut with code refactoring patterns. For both: include networking, scaling, Azure Key Vault integration, and monitoring. Help me choose the right approach with code examples.", + "tooltip": "The application uses JBoss EAP. Chat with Copilot to learn migration to AKS: lift-and-shift or refactor to cloud-native." + }, + { + "solutionId": "bare/jboss-eap-to-azure-container-apps", + "name": "Migrate JBoss EAP to Azure Container Apps", + "type": "Chat", + "prompt": "My application uses JBoss EAP and I need to migrate to Azure Container Apps. Please advise on two options: Option 1 - Lift-and-Shift: Using Red Hat JBoss EAP container images and Dockerfile examples. Option 2 - Refactor: Migrating to Spring Boot/Quarkus/Micronaut with refactoring patterns. For both: include Container Apps configuration (scaling, traffic splitting), Azure Key Vault, ingress, and Application Insights. Help me choose with code examples.", + "tooltip": "The application uses JBoss EAP. Chat with Copilot to learn migration to Container Apps: lift-and-shift or refactor." + }, + { + "solutionId": "bare/weblogic-to-azure-app-service", + "name": "Migrate WebLogic to JBoss EAP on Azure App Service", + "type": "Chat", + "prompt": "My application uses WebLogic Server and I need to migrate to JBoss EAP on Azure App Service. Please advise on: 1. Key differences between WebLogic and JBoss EAP. 2. Migrating weblogic.xml and descriptors to JBoss equivalents. 3. Updating build files to replace WebLogic dependencies. 4. Configuring JBoss EAP runtime on App Service. 5. Data sources, JNDI, and JMS setup. Include configuration examples and checklist.", + "tooltip": "The application uses WebLogic Server. Chat with Copilot to learn how to migrate to JBoss EAP on Azure App Service." + }, + { + "solutionId": "bare/weblogic-to-aks", + "name": "Migrate WebLogic to Azure Kubernetes Service", + "type": "Chat", + "prompt": "My application uses WebLogic Server and I need to migrate to AKS. Please advise on: 1. Migration approach - WebLogic on AKS or refactor to cloud-native? 2. Using Oracle WebLogic Kubernetes Operator. 3. Containerizing WebLogic with Dockerfile examples. 4. Kubernetes manifests for WebLogic domains and clusters. 5. Networking, secrets with Azure Key Vault, and monitoring. Include code examples and architecture guidance.", + "tooltip": "The application uses WebLogic Server. Chat with Copilot to learn how to migrate to Azure Kubernetes Service." + }, + { + "solutionId": "bare/weblogic-to-azure-container-apps", + "name": "Migrate WebLogic to Azure Container Apps", + "type": "Chat", + "prompt": "My application uses WebLogic Server and I need to migrate to Azure Container Apps. Please advise on two options: Option 1 - Lift-and-Shift: WebLogic container images and Dockerfile examples. Option 2 - Refactor: Migrating to Spring Boot/Quarkus/Micronaut. For both: include Container Apps configuration, Azure Key Vault, autoscaling, and monitoring. Help me choose with code examples.", + "tooltip": "The application uses WebLogic Server. Chat with Copilot to learn migration to Container Apps: lift-and-shift or refactor." + }, + { + "solutionId": "bare/websphere-to-azure-app-service", + "name": "Migrate WebSphere to JBoss EAP on Azure App Service", + "type": "Chat", + "prompt": "My application uses WebSphere and I need to migrate to JBoss EAP on Azure App Service. Please advise on: 1. Key differences between WebSphere and JBoss EAP. 2. Migrating WebSphere descriptors (ibm-web-ext.xml, ibm-application-bnd.xml) to JBoss equivalents. 3. Replacing WebSphere dependencies (com.ibm.websphere.*) in build files. 4. Configuring JBoss EAP runtime on App Service. 5. Data sources, JNDI, and messaging setup. Include configuration examples and checklist.", + "tooltip": "The application uses WebSphere. Chat with Copilot to learn how to migrate to JBoss EAP on Azure App Service." + }, + { + "solutionId": "bare/websphere-to-aks", + "name": "Migrate WebSphere to Azure Kubernetes Service", + "type": "Chat", + "prompt": "My application uses WebSphere and I need to migrate to AKS. Please advise on: 1. Migration approach - WebSphere Liberty on AKS or refactor to cloud-native? 2. Using IBM WebSphere Liberty Operator for Kubernetes. 3. Containerizing WebSphere applications with Dockerfile examples. 4. Kubernetes manifests for WebSphere Liberty deployment. 5. Networking, secrets with Azure Key Vault, and monitoring. Include code examples and architecture guidance.", + "tooltip": "The application uses WebSphere. Chat with Copilot to learn how to migrate to Azure Kubernetes Service." + }, + { + "solutionId": "bare/websphere-to-azure-container-apps", + "name": "Migrate WebSphere to Azure Container Apps", + "type": "Chat", + "prompt": "My application uses WebSphere and I need to migrate to Azure Container Apps. Please advise on two options: Option 1 - Lift-and-Shift: IBM WebSphere Liberty container images and Dockerfile examples. Option 2 - Refactor: Migrating to Spring Boot/Quarkus/Micronaut. For both: include Container Apps configuration, Azure Key Vault, autoscaling, and monitoring. Help me choose with code examples.", + "tooltip": "The application uses WebSphere. Chat with Copilot to learn migration to Container Apps: lift-and-shift or refactor." + }, + { + "solutionId": "bare/appserver-api-migration-to-standard-java", + "name": "Migrate Proprietary App Server APIs to Standard Java/Jakarta EE", + "type": "Chat", + "prompt": "The application uses proprietary application server APIs (WebLogic, WebSphere, JBoss EAP, or JBoss Seam) that must be migrated to standard Java/Jakarta EE equivalents. This is an application server portability migration — not a simple JDK deprecated API fix — and typically involves significant code changes across imports, annotations, deployment descriptors, and build dependencies.\n\nPlease analyze the detected issues and provide file-level migration guidance based on these patterns:\n\n1. **CommonJ Timer/Work Manager (WebLogic or WebSphere)**: Replace `commonj.timers.*` with `java.util.concurrent.ScheduledExecutorService`; replace `commonj.work.*` with `java.util.concurrent.ExecutorService` or Jakarta Concurrency `ManagedExecutorService` (`jakarta.enterprise.concurrent`).\n\n2. **Vendor-specific JMS (WebLogic/WebSphere JMS)**: Replace `weblogic.jms.*` or `com.ibm.websphere.jms.*` with standard Jakarta JMS (`jakarta.jms.*`). Update connection factory lookups to use standard JNDI; remove vendor-specific extensions for destinations, connection pooling, and message handling.\n\n3. **WebLogic Servlet/Lifecycle**: Replace `weblogic.application.ApplicationLifecycleListener` with standard `jakarta.servlet.ServletContextListener` or `@WebListener`. Replace WebLogic-specific servlet classes with standard Servlet API equivalents.\n\n4. **WebLogic WebServices**: Migrate from `weblogic.wsee.*` proprietary annotations and descriptors to standard JAX-WS (`jakarta.xml.ws.*`) or JAX-RS (`jakarta.ws.rs.*`). Remove WebLogic-specific web service deployment descriptors.\n\n5. **WebLogic Webapp Descriptors**: Replace `weblogic.xml` and vendor-specific deployment descriptors with standard `web.xml` or annotation-based configuration.\n\n6. **JBoss EAP Cross-Version Migration**: Replace deprecated JBoss-internal classes (logging, transactions, classloading) with standard Java/Jakarta EE equivalents or updated JBoss APIs.\n\n7. **JBoss Seam → CDI**: Replace Seam annotations (`@Name`, `@In`, `@Out`, `@Factory`) with CDI equivalents (`@Named`, `@Inject`, `@Produces`). Refactor Seam interceptors, page flows, and bijection to CDI interceptors, decorators, and standard scopes.\n\n8. **CDI Deprecated API**: Update deprecated CDI methods (e.g., `Bean#isNullable()`, `BeanManager.fireEvent()`) to current Jakarta CDI replacements.\n\n9. **JBoss Deprecated Dependencies**: Replace deprecated JBoss-specific dependencies with their standard Java/Jakarta EE or community-maintained equivalents.\n\nGeneral approach: (a) Scan for vendor-specific package imports to build an inventory. (b) Map each proprietary class to its standard equivalent. (c) Refactor incrementally per module — update imports, class references, method signatures and descriptors. (d) Remove vendor SDK dependencies from pom.xml/build.gradle and add standard Jakarta EE API dependencies. (e) Validate with integration tests, especially messaging, lifecycle hooks, and web service endpoints.", + "tooltip": "The app uses proprietary app server APIs (WebLogic, WebSphere, JBoss). Chat with Copilot to learn how to migrate to standard Java/Jakarta EE equivalents." + }, + { + "solutionId": "eclipse-project-to-maven-project", + "name": "Migrate from Eclipse Project to Maven Project", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate current project from eclipse project to maven project" + }, + { + "solutionId": "ant-project-to-maven-project", + "name": "Migrate from Ant Project to Maven Project", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate current project from Ant project to Maven project" + }, + { + "solutionId": "containerization-copilot-agent", + "name": "Containerize Java Application for Container Readiness", + "type": "Formula", + "effort": "HIGH", + "tooltip": "The app does not have a Dockerfile and/or is not container-ready. Use Agent Mode with Copilot to create and execute a containerization plan." + }, + { + "solutionId": "google-cloud-pub-sub-to-azure-service-bus", + "name": "Migrate from Google Pub/Sub to Azure Service Bus", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Google Pub/Sub to Azure Service Bus for reliable and secure messaging in Azure.", + "experimental": true + }, + { + "solutionId": "google-gcr-to-azure-acr", + "name": "Migrate from Google GCR to Azure ACR", + "type": "Chat", + "prompt": "The application uses Google Container Registry (GCR) for container image storage. To migrate to Azure, use Azure Container Registry (ACR) as the alternative container registry service. Set up an ACR instance, configure authentication using Azure Active Directory and DefaultAzureCredential, and update deployment pipelines to push/pull images from ACR. Consider using Azure Container Apps or Azure Kubernetes Service (AKS) for hosting containerized applications.", + "tooltip": "Migrate from Google GCR to Azure ACR for reliable and secure container registry in Azure." + }, + { + "solutionId": "spring-cloud-config-to-azure-app-configuration", + "name": "Migrate from Spring Cloud Config to Azure App Configuration", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Spring Cloud Config to Azure App Configuration for scalable and secure configuration management in Azure.", + "experimental": true + }, + { + "solutionId": "sybase-ase-to-azure-postgresql", + "name": "Migrate from Sybase ASE to Azure PostgreSQL", + "type": "Formula", + "effort": "HIGH", + "variants": [ + "sybase-ase-to-azure-sql-database" + ], + "tooltip": "Migrate from Sybase ASE to Azure PostgreSQL for scalable and secure database management in Azure.", + "experimental": true + }, + { + "solutionId": "sybase-ase-to-azure-sql-database", + "name": "Migrate from Sybase ASE to Azure SQL Database", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Sybase ASE to Azure SQL Database for scalable and secure database management in Azure." + }, + { + "solutionId": "google-firestore-to-azure-cosmos-db", + "name": "Migrate from Google Firestore to Azure Cosmos DB", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Google Firestore to Azure Cosmos DB for scalable and secure NoSQL database management in Azure.", + "experimental": true + }, + { + "solutionId": "google-cloud-bigtable-to-azure-cosmos-db", + "name": "Migrate from Google Cloud Bigtable to Azure Cosmos DB", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Google Cloud Bigtable to Azure Cosmos DB for scalable and secure NoSQL database management in Azure.", + "experimental": true + }, + { + "solutionId": "google-cloud-spanner-to-azure-postgresql", + "name": "Migrate from Google Cloud Spanner to Azure PostgreSQL", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Google Cloud Spanner to Azure PostgreSQL for scalable and secure database management in Azure.", + "experimental": true + }, + { + "solutionId": "apache-pulsar-to-azure-event-hubs", + "name": "Migrate from Apache Pulsar to Azure Event Hubs", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Apache Pulsar to Azure Event Hubs for scalable and secure event streaming in Azure.", + "experimental": true + }, + { + "solutionId": "ibm-db2-to-azure-postgresql", + "name": "Migrate from IBM DB2 to Azure PostgreSQL", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from IBM DB2 to Azure PostgreSQL for scalable and secure database management in Azure.", + "experimental": true + }, + { + "solutionId": "firebird-to-azure-postgresql", + "name": "Migrate from Firebird to Azure PostgreSQL", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Firebird to Azure PostgreSQL for scalable and secure database management in Azure.", + "experimental": true + }, + { + "solutionId": "sqlite-to-azure-postgresql", + "name": "Migrate from SQLite to Azure PostgreSQL", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from SQLite to Azure PostgreSQL for scalable and secure database management in Azure.", + "experimental": true + }, + { + "solutionId": "google-cloud-functions-to-azure-functions", + "name": "Migrate from Google Cloud Functions to Azure Functions", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Google Cloud Functions to Azure Functions for scalable and secure serverless compute in Azure.", + "experimental": true + }, + { + "solutionId": "aws-lambda-to-azure-functions", + "name": "Migrate from AWS Lambda to Azure Functions", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from AWS Lambda to Azure Functions for scalable and secure serverless compute in Azure.", + "experimental": true + }, + { + "solutionId": "spring-batch-to-azure-durable-functions", + "name": "Migrate from Spring Batch to Azure Durable Functions", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Spring Batch to Azure Durable Functions for scalable and secure serverless compute in Azure.", + "experimental": true + }, + { + "solutionId": "google-cloud-storage-to-azure-blob-storage", + "name": "Migrate from Google Cloud Storage to Azure Blob Storage", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Google Cloud Storage to Azure Blob Storage for scalable and secure object storage in Azure.", + "experimental": true + }, + { + "solutionId": "amazon-sns-to-azure-service-bus", + "name": "Migrate from Amazon SNS to Azure Service Bus", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Amazon SNS to Azure Service Bus for scalable and secure messaging in Azure.", + "experimental": true + }, + { + "solutionId": "tibco-ems-jms-to-azure-service-bus", + "name": "Migrate from TIBCO EMS JMS to Azure Service Bus", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from TIBCO EMS JMS to Azure Service Bus for scalable and secure messaging in Azure.", + "experimental": true + }, + { + "solutionId": "solace-pubsub-to-azure-service-bus", + "name": "Migrate from Solace PubSub+ to Azure Service Bus", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Solace PubSub+ to Azure Service Bus for scalable and secure messaging in Azure.", + "experimental": true + }, + { + "solutionId": "amazon-kinesis-to-azure-event-hubs", + "name": "Migrate from Amazon Kinesis to Azure Event Hubs", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Migrate from Amazon Kinesis to Azure Event Hubs for scalable and secure event streaming in Azure.", + "experimental": true + }, + { + "solutionId": "azure-legacy-java-sdk-upgrade", + "name": "Upgrade from Legacy Azure SDKs for Java to the latest", + "type": "Formula", + "effort": "HIGH", + "tooltip": "Upgrade to the latest stable version of Azure SDKs for Java that follow current Azure SDK guidelines." + }, + { + "solutionId": "bare/weak-cryptography", + "name": "Replace Weak Cryptographic Algorithms", + "type": "Chat", + "prompt": "The application uses weak or broken cryptographic algorithms (such as MD5, SHA-1, DES, RC4, ECB mode, or Blowfish) that do not meet EU Cyber Resilience Act requirements. Replace weak hash algorithms with SHA-256 or SHA-3. Replace broken encryption (DES, RC4, Blowfish, ECB mode) with AES-GCM or ChaCha20-Poly1305. For password hashing, use bcrypt, Argon2, scrypt, or PBKDF2 instead of plain message digests.", + "tooltip": "The app uses weak cryptographic algorithms. Chat with Copilot to learn how to upgrade to modern, secure alternatives." + }, + { + "solutionId": "bare/insecure-tls", + "name": "Fix Insecure TLS/SSL Configuration", + "type": "Chat", + "prompt": "The application uses insecure TLS/SSL configurations, such as deprecated protocol versions (SSLv3, TLS 1.0, TLS 1.1), disabled certificate validation, disabled hostname verification, or weak cipher suites. Upgrade to TLS 1.2 or TLS 1.3, remove trust-all certificate patterns, ensure proper hostname verification, and use only strong cipher suites (AEAD modes like GCM or ChaCha20-Poly1305). For Spring Boot, set server.ssl.enabled-protocols=TLSv1.2,TLSv1.3.", + "tooltip": "The app has insecure TLS/SSL settings. Chat with Copilot to learn how to upgrade to secure TLS configurations." + }, + { + "solutionId": "bare/hardcoded-credentials", + "name": "Remove Hardcoded Credentials", + "type": "Chat", + "prompt": "The application contains hardcoded credentials (passwords, API keys, secrets, cryptographic keys, or default passwords) in source code or configuration files, violating EU Cyber Resilience Act secure-by-default requirements. Move all secrets to Azure Key Vault or a secrets management service. Use environment variables or externalized configuration for sensitive values. Use managed identities for service-to-service authentication. Ensure configuration files with secrets are excluded from version control.", + "tooltip": "The app has hardcoded credentials. Chat with Copilot to learn how to externalize secrets using Azure Key Vault or environment variables." + }, + { + "solutionId": "bare/insecure-random", + "name": "Use Cryptographically Secure Random Number Generation", + "type": "Chat", + "prompt": "The application uses insecure random number generators (java.util.Random, Math.random(), or ThreadLocalRandom) which are predictable and not suitable for security-sensitive operations such as token generation, session IDs, nonces, or encryption keys. Replace with java.security.SecureRandom for all security-relevant random number generation. SecureRandom provides a cryptographically strong random number generator (CSPRNG) backed by the OS entropy source.", + "tooltip": "The app uses insecure random number generators. Chat with Copilot to learn how to switch to SecureRandom for security-sensitive operations." + }, + { + "solutionId": "bare/aws-bedrock-to-azure-ai", + "name": "Migrate from AWS Bedrock to Azure OpenAI Service", + "type": "Chat", + "prompt": "The application uses AWS Bedrock SDK for generative AI capabilities. Consider migrating to Azure OpenAI Service or Azure AI Foundry. Replace AWS Bedrock SDK dependencies with the Azure OpenAI client library (com.azure:azure-ai-openai). Update application code to replace AWS Bedrock API calls with Azure OpenAI equivalents. Replace AWS IAM-based authentication with Azure AD managed identity or API key authentication using DefaultAzureCredential. Update configuration to replace AWS Bedrock settings (endpoint, model IDs, region) with Azure OpenAI configurations (endpoint, deployment name, API version). If using streaming APIs, refactor from AWS reactive streams pattern to Azure OpenAI's iterative streaming model.", + "tooltip": "The app uses AWS Bedrock for AI services. Chat with Copilot to learn how to migrate to Azure OpenAI Service or Azure AI Foundry." + } + ], + "rules": [ + { + "ruleId": "apm-00001", + "sourceCategory": "apm-newrelic", + "solution": "bare/apm-to-application-insights" + }, + { + "ruleId": "apm-00002", + "sourceCategory": "apm-elastic", + "solution": "bare/apm-to-application-insights" + }, + { + "ruleId": "apm-00003", + "sourceCategory": "apm-dynatrace", + "solution": "bare/apm-to-application-insights" + }, + { + "ruleId": "auth-00000", + "sourceCategory": "saml", + "solution": "on-premises-user-authentication-to-microsoft-entra-id" + }, + { + "ruleId": "auth-01000", + "sourceCategory": "opensaml", + "solution": "on-premises-user-authentication-to-microsoft-entra-id" + }, + { + "ruleId": "auth-02000", + "sourceCategory": "spring-security", + "solution": "on-premises-user-authentication-to-microsoft-entra-id" + }, + { + "ruleId": "auth-03000", + "sourceCategory": "oauth2", + "solution": "on-premises-user-authentication-to-microsoft-entra-id" + }, + { + "ruleId": "auth-04000", + "sourceCategory": "openid", + "solution": "on-premises-user-authentication-to-microsoft-entra-id" + }, + { + "ruleId": "webform-auth-00000", + "sourceCategory": "webform-auth", + "solution": "on-premises-user-authentication-to-microsoft-entra-id" + }, + { + "ruleId": "java-ldap-to-msft-entra-id-01000", + "solution": "on-premises-user-authentication-to-microsoft-entra-id" + }, + { + "ruleId": "azure-aws-config-credential-01000", + "sourceCategory": "aws-credentials", + "solution": "bare/aws-credentials-to-azure" + }, + { + "ruleId": "azure-aws-config-region-02000", + "sourceCategory": "aws-region-configuration", + "solution": "bare/aws-region-configuration-to-azure" + }, + { + "ruleId": "azure-aws-config-s3-03000", + "sourceCategory": "aws-s3", + "solution": "s3-to-azure-blob-storage" + }, + { + "ruleId": "azure-aws-config-s3-03001", + "sourceCategory": "aws-s3", + "solution": "s3-to-azure-blob-storage" + }, + { + "ruleId": "azure-aws-config-s3-03002", + "sourceCategory": "aws-s3", + "solution": "s3-to-azure-blob-storage" + }, + { + "ruleId": "azure-aws-config-secret-manager-05000", + "sourceCategory": "aws-secrets-manager", + "solution": "AWS-secrets-manager-to-azure-key-vault" + }, + { + "ruleId": "azure-aws-config-sqs-04000", + "sourceCategory": "aws-sqs", + "solution": "sqs-to-servicebus" + }, + { + "ruleId": "azure-aws-config-sqs-04001", + "sourceCategory": "aws-sqs", + "solution": "sqs-to-servicebus" + }, + { + "ruleId": "azure-aws-config-sqs-04002", + "sourceCategory": "aws-sqs", + "solution": "sqs-to-servicebus" + }, + { + "ruleId": "azure-aws-config-sqs-04003", + "sourceCategory": "aws-sqs", + "solution": "sqs-to-servicebus" + }, + { + "ruleId": "azure-aws-config-sqs-04004", + "sourceCategory": "aws-sqs", + "solution": "sqs-to-servicebus" + }, + { + "ruleId": "azure-cache-redis-01000", + "sourceCategory": "redis", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "azure-database-config-mongodb-02000", + "sourceCategory": "mongodb", + "solution": "mi-mongodb" + }, + { + "ruleId": "azure-database-microsoft-cassandra-04000", + "sourceCategory": "cassandra", + "solution": "mi-cassandra" + }, + { + "ruleId": "azure-database-microsoft-mariadb-06000", + "sourceCategory": "mariadb", + "solution": "mi-mariadb" + }, + { + "ruleId": "azure-database-microsoft-mongodb-05000", + "sourceCategory": "mongodb", + "solution": "mi-mongodb" + }, + { + "ruleId": "azure-database-microsoft-sql-03000", + "sourceCategory": "microsoft-sql", + "solution": "mi-azure-sql" + }, + { + "ruleId": "azure-database-mysql-01000", + "sourceCategory": "mysql", + "solution": "mi-mysql" + }, + { + "ruleId": "azure-database-postgresql-02000", + "sourceCategory": "postgresql", + "solution": "mi-postgresql" + }, + { + "ruleId": "azure-java-version-01000", + "solution": "java-version-upgrade" + }, + { + "ruleId": "azure-java-version-02000", + "solution": "java-version-upgrade" + }, + { + "ruleId": "azure-keystore-certificates-01000", + "solution": "certificate-management-to-azure-key-vault" + }, + { + "ruleId": "azure-keystore-certificates-02000", + "solution": "certificate-management-to-azure-key-vault" + }, + { + "ruleId": "dockerfile-00000", + "solution": "containerization-copilot-agent" + }, + { + "ruleId": "dockerfile-00010", + "solution": "containerization-copilot-agent" + }, + { + "ruleId": "dockerfile-00020", + "solution": "containerization-copilot-agent" + }, + { + "ruleId": "dockerfile-00030", + "solution": "containerization-copilot-agent" + }, + { + "ruleId": "azure-message-queue-activemq-01000", + "sourceCategory": "activemq-artemis", + "solution": "activemq-servicebus" + }, + { + "ruleId": "azure-message-queue-amqp-02000", + "sourceCategory": "spring-amqp-rabbitmq", + "solution": "amqp-rabbitmq-servicebus" + }, + { + "ruleId": "azure-message-queue-config-artemis-01000", + "sourceCategory": "activemq-artemis", + "solution": "activemq-servicebus" + }, + { + "ruleId": "azure-message-queue-config-kafka-01000", + "sourceCategory": "kafka", + "solution": "confluent-cloud-kafka" + }, + { + "ruleId": "azure-message-queue-config-rabbitmq-01000", + "sourceCategory": "spring-amqp-rabbitmq", + "solution": "amqp-rabbitmq-servicebus" + }, + { + "ruleId": "azure-message-queue-rabbitmq-01000", + "sourceCategory": "spring-amqp-rabbitmq", + "solution": "amqp-rabbitmq-servicebus" + }, + { + "ruleId": "azure-message-queue-java-ee-rabbitmq-amqp-01000", + "sourceCategory": "java-ee-amqp-rabbitmq", + "solution": "java-ee-amqp-rabbitmq-servicebus" + }, + { + "ruleId": "azure-message-queue-spring-jms-rabbitmq-01000", + "sourceCategory": "spring-jms-rabbitmq", + "solution": "spring-jms-rabbitmq-servicebus" + }, + { + "ruleId": "azure-message-queue-ibm-jms-01000", + "sourceCategory": "jms-ibm-mq", + "solution": "ibm-mq-jms-to-azure-service-bus" + }, + { + "ruleId": "azure-password-01000", + "solution": "plaintext-credential-to-azure-keyvault" + }, + { + "ruleId": "azure-system-config-01000", + "sourceCategory": "environment-variables", + "solution": "bare/configuration-management/environment-variables" + }, + { + "ruleId": "external-config-00000", + "sourceCategory": "external-configuration", + "solution": "bare/configuration-management/external-configuration" + }, + { + "ruleId": "windows-registry-00000", + "sourceCategory": "windows-registry", + "solution": "bare/configuration-management/windows-registry" + }, + { + "ruleId": "azure-tas-binding-01000", + "sourceCategory": "tanzu-application-service", + "solution": "bare/azure-service-connector" + }, + { + "ruleId": "clustering-00000", + "sourceCategory": "http-session", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "eap-to-azure-appservice-datasource-driver-01000", + "sourceCategory": "jboss-eap", + "solution": "bare/eap-migration/jboss-eap" + }, + { + "ruleId": "eap-to-azure-appservice-pom-001", + "sourceCategory": "jboss-eap", + "solution": "bare/eap-migration/jboss-eap" + }, + { + "ruleId": "embedded-cache-01000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-02000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-03000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-04000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-05000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-06000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-07000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-08000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-09000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-10000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-11000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-12000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-13000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-14000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-15000", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "embedded-cache-16000", + "sourceCategory": "redis", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "hardcoded-ip-address", + "sourceCategory": "hardcode-ip", + "solution": "bare/remote-communication/hardcode-ip" + }, + { + "ruleId": "unsecure-network-protocol-00000", + "sourceCategory": "secure-protocols", + "solution": "bare/remote-communication/secure-protocols" + }, + { + "ruleId": "hardcoded-urls-00001", + "sourceCategory": "hardcoded-urls", + "solution": "bare/remote-communication/hardcoded-urls" + }, + { + "ruleId": "hardcoded-urls-00002", + "sourceCategory": "hardcoded-urls", + "solution": "bare/remote-communication/hardcoded-urls" + }, + { + "ruleId": "java-corba-00000", + "sourceCategory": "corba", + "solution": "bare/remote-communication/corba" + }, + { + "ruleId": "java-removals-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-removals-00010", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-rmi-00000", + "sourceCategory": "rmi", + "solution": "bare/jakataee-to-azure/rmi" + }, + { + "ruleId": "java-rmi-00001", + "sourceCategory": "rmi", + "solution": "bare/jakataee-to-azure/rmi" + }, + { + "ruleId": "java-rpc-00000", + "solution": "jax-rpc-to-jax-ws" + }, + { + "ruleId": "jca-00000", + "sourceCategory": "jca", + "solution": "bare/jakataee-to-azure/jca" + }, + { + "ruleId": "jni-native-code-00000", + "sourceCategory": "jni-native-code", + "solution": "bare/java-native-code" + }, + { + "ruleId": "jni-native-code-00001", + "sourceCategory": "jni-native-code", + "solution": "bare/java-native-code" + }, + { + "ruleId": "azure-file-system-02000", + "sourceCategory": "local-file-system", + "solution": "local-files-to-mounted-azure-storage" + }, + { + "ruleId": "azure-file-system-03000", + "sourceCategory": "local-file-system", + "solution": "local-files-to-mounted-azure-storage" + }, + { + "ruleId": "local-storage-00001", + "sourceCategory": "local-file-system", + "solution": "local-files-to-mounted-azure-storage" + }, + { + "ruleId": "local-storage-00002", + "sourceCategory": "local-file-system", + "solution": "local-files-to-mounted-azure-storage" + }, + { + "ruleId": "local-storage-00003", + "sourceCategory": "local-file-system", + "solution": "local-files-to-mounted-azure-storage" + }, + { + "ruleId": "local-storage-00004", + "sourceCategory": "local-file-system", + "solution": "local-files-to-mounted-azure-storage" + }, + { + "ruleId": "local-storage-00005", + "sourceCategory": "local-file-system", + "solution": "local-files-to-mounted-azure-storage" + }, + { + "ruleId": "local-storage-00006", + "sourceCategory": "local-file-system", + "solution": "local-files-to-mounted-azure-storage" + }, + { + "ruleId": "localhost-http-00001", + "sourceCategory": "localhost", + "solution": "bare/local-resource-access" + }, + { + "ruleId": "localhost-jdbc-00002", + "sourceCategory": "localhost", + "solution": "bare/local-resource-access" + }, + { + "ruleId": "localhost-ws-00003", + "sourceCategory": "localhost", + "solution": "bare/local-resource-access" + }, + { + "ruleId": "localhost-00004", + "sourceCategory": "localhost", + "solution": "bare/local-resource-access" + }, + { + "ruleId": "logging-0000", + "solution": "log-to-console" + }, + { + "ruleId": "logging-0001", + "solution": "log-to-console" + }, + { + "ruleId": "logging-0002", + "solution": "log-to-console" + }, + { + "ruleId": "logging-0004", + "sourceCategory": "splunk", + "solution": "log-to-console" + }, + { + "ruleId": "logging-0005", + "sourceCategory": "zipkin", + "solution": "bare/apm-to-application-insights" + }, + { + "ruleId": "lombok-incompatibility-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "mail-00000", + "sourceCategory": "java-mail", + "solution": "javax.email-send-to-azure-communication-service-email" + }, + { + "ruleId": "os-specific-00002", + "solution": "bare/os-compatibility" + }, + { + "ruleId": "removed-packages-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "removed-packages-00010", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "session-00001", + "sourceCategory": "http-session", + "solution": "other-cache-solutions-to-azure-managed-cache" + }, + { + "ruleId": "socket-communication-00000", + "sourceCategory": "java-socket", + "solution": "bare/remote-communication/java-socket" + }, + { + "ruleId": "socket-communication-00001", + "sourceCategory": "java-socket", + "solution": "bare/remote-communication/java-socket" + }, + { + "ruleId": "spring-boot-to-azure-config-server-01000", + "sourceCategory": "spring-cloud", + "solution": "spring-cloud-config-to-azure-app-configuration" + }, + { + "ruleId": "spring-boot-to-azure-eureka-01000", + "sourceCategory": "spring-cloud", + "solution": "bare/spring-migration" + }, + { + "ruleId": "spring-boot-to-azure-eureka-02000", + "sourceCategory": "spring-cloud", + "solution": "bare/spring-migration" + }, + { + "ruleId": "spring-boot-to-azure-eureka-03000", + "sourceCategory": "spring-cloud", + "solution": "bare/spring-migration" + }, + { + "ruleId": "spring-boot-to-azure-key-vault-01000", + "sourceCategory": "spring-cloud-vault", + "solution": "bare/spring-cloud-vault-migration" + }, + { + "ruleId": "spring-boot-to-azure-openfeign-01000", + "sourceCategory": "spring-cloud", + "solution": "bare/spring-migration" + }, + { + "ruleId": "spring-boot-to-azure-port-01000", + "sourceCategory": "spring-cloud", + "solution": "bare/spring-migration" + }, + { + "ruleId": "spring-boot-to-azure-restricted-config-01000", + "sourceCategory": "spring-cloud", + "solution": "bare/spring-migration" + }, + { + "ruleId": "spring-boot-to-azure-spring-boot-version-01000", + "sourceCategory": "spring-boot", + "solution": "spring-boot-upgrade" + }, + { + "ruleId": "spring-boot-to-azure-spring-cloud-version-01000", + "sourceCategory": "spring-cloud", + "solution": "spring-boot-upgrade" + }, + { + "ruleId": "spring-boot-to-azure-spring-cloud-version-02000", + "sourceCategory": "spring-cloud", + "solution": "spring-boot-upgrade" + }, + { + "ruleId": "spring-framework-version-01000", + "sourceCategory": "spring-framework", + "solution": "spring-framework-upgrade" + }, + { + "ruleId": "jakarta-ee-version-01000", + "sourceCategory": "java-ee/jakarta-ee", + "solution": "jakarta-ee-upgrade" + }, + { + "ruleId": "utf-8-by-default-00000", + "solution": "bare/encoding-standards" + }, + { + "ruleId": "utf-8-by-default-00010", + "solution": "bare/encoding-standards" + }, + { + "ruleId": "utf-8-by-default-00020", + "solution": "bare/encoding-standards" + }, + { + "ruleId": "utf-8-by-default-00030", + "solution": "bare/encoding-standards" + }, + { + "ruleId": "web-10000", + "sourceCategory": "javax-swing", + "solution": "bare/redesign-java-gui-app" + }, + { + "ruleId": "web-11000", + "sourceCategory": "javafx", + "solution": "bare/redesign-java-gui-app" + }, + { + "ruleId": "oracle2openjdk-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "oracle2openjdk-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "oracle2openjdk-00002", + "solution": "bare/oraclejdk-to-openjdk/resource-management-apis" + }, + { + "ruleId": "oracle2openjdk-00003", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "oracle2openjdk-00004", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "oracle2openjdk-00005", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "oracle2openjdk-00006", + "solution": "bare/oraclejdk-to-openjdk/imageio" + }, + { + "ruleId": "java-8-deprecate-apt-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-callback-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-corba-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-javafx-builder-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-log-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-odbc-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-pack-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-pack-00002", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-security-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-security-00002", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-security-manager-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-security-manager-00002", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-stream-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-8-deprecate-thread-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-10-deprecate-dom-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-10-deprecate-javafx-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-10-deprecate-runtime-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-10-deprecate-security-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-10-deprecate-security-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-10-deprecate-security-00002", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-10-deprecate-security-00003", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-10-deprecate-security-00004", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-awt-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-corba-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-javaee-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-javaee-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-pack-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-peer-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-property-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-property-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-security-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-security-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-stream-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-thread-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-unsafe-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-11-deprecate-unsafe-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-agent-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-dom-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-javafx-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-log-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-pack-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-peer-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-property-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-property-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-reflect-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-reflect-00010", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-security-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-security-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-security-00002", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-tracing-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-unsafe-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-unsafe-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-9-deprecate-url-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-removals-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-removals-00010", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-12-deprecate-finalize-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-12-deprecate-finalize-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-12-deprecate-security-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-13-deprecate-runtime-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-13-deprecate-security-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-14-deprecate-pack-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-14-deprecate-property-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-14-deprecate-property-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-14-deprecate-security-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-14-deprecate-thread-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-15-deprecate-property-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-15-deprecate-signer-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-15-deprecate-ssl-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-15-deprecate-ssl-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-16-deprecate-security-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-16-deprecate-thread-group-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-17-deprecate-applet-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-17-deprecate-security-manager-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-17-deprecate-security-manager-00010", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-17-deprecate-security-manager-00020", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-17-deprecate-security-manager-00030", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-17-deprecate-security-manager-00040", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-17-deprecate-security-manager-00050", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-17-deprecate-security-manager-00060", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-17-deprecate-security-manager-00070", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-17-deprecate-socket-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-17-deprecate-unsafe-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "lombok-incompatibility-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "removed-packages-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "removed-packages-00010", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-18-deprecate-finalize-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-18-deprecate-finalize-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-18-deprecate-property-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-18-deprecate-runtime-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-18-deprecate-security-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-18-deprecate-socket-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-18-deprecate-thread-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-18-deprecate-unsafe-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-19-deprecate-locale-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-19-deprecate-param-spec-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-19-deprecate-param-spec-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-19-deprecate-class-00010", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-19-deprecate-thread-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-19-deprecate-thread-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-19-deprecate-thread-00002", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-20-deprecate-jmx-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-20-deprecate-net-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-20-deprecate-thread-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-20-deprecate-thread-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-20-deprecate-thread-00002", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-21-deprecate-dynamic-agents-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-21-deprecate-file-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-21-deprecate-file-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-21-deprecate-jmx-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-21-deprecate-jmx-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-21-deprecate-property-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-21-deprecate-property-00001", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-21-deprecate-signer-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "java-21-deprecate-thread-00000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "openliberty-database-00001", + "sourceCategory": "openliberty-database", + "solution": "bare/openliberty-migration/openliberty-database" + }, + { + "ruleId": "openliberty-database-00002", + "sourceCategory": "openliberty-database", + "solution": "bare/openliberty-migration/openliberty-database" + }, + { + "ruleId": "openliberty-database-00003", + "sourceCategory": "openliberty-database", + "solution": "bare/openliberty-migration/openliberty-database" + }, + { + "ruleId": "openliberty-database-00004", + "sourceCategory": "openliberty-database", + "solution": "bare/openliberty-migration/openliberty-database" + }, + { + "ruleId": "openliberty-database-00005", + "sourceCategory": "openliberty-database", + "solution": "bare/openliberty-migration/openliberty-database" + }, + { + "ruleId": "openliberty-database-00006", + "sourceCategory": "openliberty-database", + "solution": "bare/openliberty-migration/openliberty-database" + }, + { + "ruleId": "openliberty-database-00007", + "sourceCategory": "openliberty-database", + "solution": "bare/openliberty-migration/openliberty-database" + }, + { + "ruleId": "openliberty-database-00008", + "sourceCategory": "openliberty-database", + "solution": "bare/openliberty-migration/openliberty-database" + }, + { + "ruleId": "openliberty-database-00009", + "sourceCategory": "openliberty-database", + "solution": "bare/openliberty-migration/openliberty-database" + }, + { + "ruleId": "openliberty-filesystem-00001", + "sourceCategory": "openliberty-filesystem", + "solution": "bare/openliberty-migration/openliberty-filesystem" + }, + { + "ruleId": "openliberty-filesystem-00002", + "sourceCategory": "openliberty-filesystem", + "solution": "bare/openliberty-migration/openliberty-filesystem" + }, + { + "ruleId": "openliberty-filesystem-00003", + "sourceCategory": "openliberty-filesystem", + "solution": "bare/openliberty-migration/openliberty-filesystem" + }, + { + "ruleId": "openliberty-filesystem-00004", + "sourceCategory": "openliberty-filesystem", + "solution": "bare/openliberty-migration/openliberty-filesystem" + }, + { + "ruleId": "openliberty-jms-00001", + "sourceCategory": "openliberty-jms", + "solution": "bare/openliberty-migration/openliberty-jms" + }, + { + "ruleId": "openliberty-jms-00002", + "sourceCategory": "openliberty-jms", + "solution": "bare/openliberty-migration/openliberty-jms" + }, + { + "ruleId": "openliberty-jms-00003", + "sourceCategory": "openliberty-jms", + "solution": "bare/openliberty-migration/openliberty-jms" + }, + { + "ruleId": "openliberty-jms-00004", + "sourceCategory": "openliberty-jms", + "solution": "bare/openliberty-migration/openliberty-jms" + }, + { + "ruleId": "openliberty-jms-00005", + "sourceCategory": "openliberty-jms", + "solution": "bare/openliberty-migration/openliberty-jms" + }, + { + "ruleId": "openliberty-jms-00006", + "sourceCategory": "openliberty-jms", + "solution": "bare/openliberty-migration/openliberty-jms" + }, + { + "ruleId": "openliberty-logging-00001", + "sourceCategory": "openliberty-logging", + "solution": "bare/openliberty-migration/openliberty-logging" + }, + { + "ruleId": "openliberty-logging-00002", + "sourceCategory": "openliberty-logging", + "solution": "bare/openliberty-migration/openliberty-logging" + }, + { + "ruleId": "azure-database-microsoft-oracle-07000", + "sourceCategory": "oracle", + "solution": "oracle-to-postgresql" + }, + { + "ruleId": "database-reliability-01000", + "solution": "bare/database-migration/database-reliability" + }, + { + "ruleId": "eclipse-00002", + "sourceCategory": "eclipse", + "solution": "eclipse-project-to-maven-project" + }, + { + "ruleId": "ant-build-tool-00001", + "sourceCategory": "ant", + "solution": "ant-project-to-maven-project" + }, + { + "ruleId": "google-pubsub-to-azure-service-bus-01000", + "sourceCategory": "google-pubsub", + "solution": "google-cloud-pub-sub-to-azure-service-bus" + }, + { + "ruleId": "google-gcr-to-azure-acr-01000", + "sourceCategory": "google-gcr", + "solution": "google-gcr-to-azure-acr" + }, + { + "ruleId": "sybase-ase-to-azure-database-01000", + "sourceCategory": "sybase-ase", + "solution": "sybase-ase-to-azure-postgresql" + }, + { + "ruleId": "google-firestore-to-azure-cosmosdb-01000", + "sourceCategory": "google-firestore", + "solution": "google-firestore-to-azure-cosmos-db" + }, + { + "ruleId": "google-cloud-bigtable-to-azure-cosmosdb-01000", + "sourceCategory": "google-cloud-bigtable", + "solution": "google-cloud-bigtable-to-azure-cosmos-db" + }, + { + "ruleId": "google-cloud-spanner-to-azure-postgresql-01000", + "sourceCategory": "google-cloud-spanner", + "solution": "google-cloud-spanner-to-azure-postgresql" + }, + { + "ruleId": "apache-pulsar-to-azure-eventhubs-01000", + "sourceCategory": "apache-pulsar", + "solution": "apache-pulsar-to-azure-event-hubs" + }, + { + "ruleId": "ibm-db2-to-azure-postgresql-01000", + "sourceCategory": "ibm-db2", + "solution": "ibm-db2-to-azure-postgresql" + }, + { + "ruleId": "firebird-to-azure-postgresql-01000", + "sourceCategory": "firebird", + "solution": "firebird-to-azure-postgresql" + }, + { + "ruleId": "sqlite-to-azure-postgresql-01000", + "sourceCategory": "sqlite", + "solution": "sqlite-to-azure-postgresql" + }, + { + "ruleId": "google-cloud-functions-to-azure-functions-01000", + "sourceCategory": "google-cloud-functions", + "solution": "google-cloud-functions-to-azure-functions" + }, + { + "ruleId": "aws-lambda-to-azure-functions-01000", + "sourceCategory": "aws-lambda", + "solution": "aws-lambda-to-azure-functions" + }, + { + "ruleId": "quartz-scheduler-to-azure-functions-01000", + "sourceCategory": "quartz-scheduler", + "solution": "quartz-scheduler-to-azure-functions" + }, + { + "ruleId": "spring-batch-to-azure-durable-functions-01000", + "sourceCategory": "spring-batch", + "solution": "spring-batch-to-azure-durable-functions" + }, + { + "ruleId": "google-cloud-storage-to-azure-blob-storage-01000", + "sourceCategory": "google-cloud-storage", + "solution": "google-cloud-storage-to-azure-blob-storage" + }, + { + "ruleId": "amazon-sns-to-azure-servicebus-01000", + "sourceCategory": "amazon-sns", + "solution": "amazon-sns-to-azure-service-bus" + }, + { + "ruleId": "tibco-ems-jms-to-azure-servicebus-jms-01000", + "sourceCategory": "tibco-ems-jms", + "solution": "tibco-ems-jms-to-azure-service-bus" + }, + { + "ruleId": "solace-pubsubplus-to-azure-servicebus-01000", + "sourceCategory": "solace-pubsubplus", + "solution": "solace-pubsub-to-azure-service-bus" + }, + { + "ruleId": "amazon-kinesis-to-azure-eventhubs-01000", + "sourceCategory": "amazon-kinesis", + "solution": "amazon-kinesis-to-azure-event-hubs" + }, + { + "ruleId": "jakarta-auth-00001", + "sourceCategory": "jakarta-auth", + "solution": "bare/jakarta-auth-migration" + }, + { + "ruleId": "jakarta-database-00001", + "sourceCategory": "jakarta-nosql", + "solution": "bare/jakarta-nosql-migration" + }, + { + "ruleId": "jakarta-database-00002", + "sourceCategory": "jakarta-persistence", + "solution": "bare/jakarta-persistence-migration" + }, + { + "ruleId": "jakarta-database-00003", + "sourceCategory": "jakarta-data", + "solution": "bare/jakarta-data-migration" + }, + { + "ruleId": "jakarta-service-00001", + "sourceCategory": "jakarta-websocket", + "solution": "bare/jakarta-websocket-migration" + }, + { + "ruleId": "jakarta-service-00002", + "sourceCategory": "jakarta-jaxrs", + "solution": "bare/jakarta-jaxrs-migration" + }, + { + "ruleId": "websphere-to-azure-app-service", + "sourceCategory": "websphere-to-azure-app-service", + "solution": "bare/websphere-to-azure-app-service" + }, + { + "ruleId": "websphere-to-aks", + "sourceCategory": "websphere-to-aks", + "solution": "bare/websphere-to-aks" + }, + { + "ruleId": "websphere-to-azure-container-apps", + "sourceCategory": "websphere-to-azure-container-apps", + "solution": "bare/websphere-to-azure-container-apps" + }, + { + "ruleId": "weblogic-to-azure-app-service", + "sourceCategory": "weblogic-to-azure-app-service", + "solution": "bare/weblogic-to-azure-app-service" + }, + { + "ruleId": "weblogic-to-aks", + "sourceCategory": "weblogic-to-aks", + "solution": "bare/weblogic-to-aks" + }, + { + "ruleId": "weblogic-to-azure-container-apps", + "sourceCategory": "weblogic-to-azure-container-apps", + "solution": "bare/weblogic-to-azure-container-apps" + }, + { + "ruleId": "jboss-eap-to-azure-app-service", + "sourceCategory": "jboss-eap-to-azure-app-service", + "solution": "bare/jboss-eap-to-azure-app-service" + }, + { + "ruleId": "jboss-eap-to-aks", + "sourceCategory": "jboss-eap-to-aks", + "solution": "bare/jboss-eap-to-aks" + }, + { + "ruleId": "jboss-eap-to-azure-container-apps", + "sourceCategory": "jboss-eap-to-azure-container-apps", + "solution": "bare/jboss-eap-to-azure-container-apps" + }, + { + "ruleId": "jakarta-cdi-00002", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "jakarta-cdi-00003", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "jboss-dependencies-00006", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "jboss-eap5-7-java-03000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "jboss-eap5-7-java-08000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "jboss-eap4and5to6and7-java-01000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "jboss-eap5and6to7-java-08000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "base64-01000", + "solution": "deprecated-api-upgrade" + }, + { + "ruleId": "seam-java-00010", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "seam-java-00040", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "seam-java-00070", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "seam-java-00030", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "seam-java-00080", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "commonj-weblogic-02000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "commonj-weblogic-03000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "commonj-weblogic-05000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "commonj-weblogic-06000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "commonj-weblogic-07000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "weblogic-jms-eap7-01000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "weblogic-portability-lifecycle-01000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "weblogic-portability-servlet-01000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "weblogic-webservices-eap7-01000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "weblogic-webapp-eap7-07000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "commonj-websphere-02000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "commonj-websphere-03000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "commonj-websphere-05000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "commonj-websphere-06000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "commonj-websphere-07000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "websphere-jms-eap7-01000", + "solution": "bare/appserver-api-migration-to-standard-java" + }, + { + "ruleId": "azure-java-sdk-legacy-migration-01000", + "solution": "azure-legacy-java-sdk-upgrade" + }, + { + "ruleId": "cra-weak-crypto-md5-01000", + "solution": "bare/weak-cryptography" + }, + { + "ruleId": "cra-weak-crypto-sha1-02000", + "solution": "bare/weak-cryptography" + }, + { + "ruleId": "cra-weak-crypto-des-03000", + "solution": "bare/weak-cryptography" + }, + { + "ruleId": "cra-weak-crypto-rc4-04000", + "solution": "bare/weak-cryptography" + }, + { + "ruleId": "cra-weak-crypto-ecb-05000", + "solution": "bare/weak-cryptography" + }, + { + "ruleId": "cra-weak-crypto-blowfish-06000", + "solution": "bare/weak-cryptography" + }, + { + "ruleId": "cra-weak-crypto-password-hash-07000", + "solution": "bare/weak-cryptography" + }, + { + "ruleId": "cra-insecure-tls-protocol-01000", + "solution": "bare/insecure-tls" + }, + { + "ruleId": "cra-insecure-tls-config-02000", + "solution": "bare/insecure-tls" + }, + { + "ruleId": "cra-insecure-tls-trust-all-03000", + "solution": "bare/insecure-tls" + }, + { + "ruleId": "cra-insecure-tls-hostname-verify-04000", + "solution": "bare/insecure-tls" + }, + { + "ruleId": "cra-insecure-tls-cipher-suite-05000", + "solution": "bare/insecure-tls" + }, + { + "ruleId": "cra-hardcoded-credential-password-01000", + "solution": "bare/hardcoded-credentials" + }, + { + "ruleId": "cra-hardcoded-credential-apikey-02000", + "solution": "bare/hardcoded-credentials" + }, + { + "ruleId": "cra-hardcoded-credential-config-03000", + "solution": "bare/hardcoded-credentials" + }, + { + "ruleId": "cra-hardcoded-credential-default-pwd-04000", + "solution": "bare/hardcoded-credentials" + }, + { + "ruleId": "cra-hardcoded-credential-crypto-key-05000", + "solution": "bare/hardcoded-credentials" + }, + { + "ruleId": "cra-insecure-random-01000", + "solution": "bare/insecure-random" + }, + { + "ruleId": "cra-insecure-random-math-02000", + "solution": "bare/insecure-random" + }, + { + "ruleId": "cra-insecure-random-threadlocal-03000", + "solution": "bare/insecure-random" + }, + { + "ruleId": "aws-bedrock-to-azure-ai-06000", + "sourceCategory": "aws-bedrock", + "solution": "bare/aws-bedrock-to-azure-ai" + }, + { + "ruleId": "aws-bedrock-to-azure-ai-06001", + "sourceCategory": "aws-bedrock", + "solution": "bare/aws-bedrock-to-azure-ai" + }, + { + "ruleId": "aws-bedrock-to-azure-ai-06002", + "sourceCategory": "aws-bedrock", + "solution": "bare/aws-bedrock-to-azure-ai" + } + ] +} diff --git a/plugins/github-copilot-modernization/skills/assessment/scripts/assess-cli.mjs b/plugins/github-copilot-modernization/skills/assessment/scripts/assess-cli.mjs new file mode 100644 index 0000000..ea68ff1 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/assessment/scripts/assess-cli.mjs @@ -0,0 +1,1191 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const DEFAULT_MAX_PATCHES = 20; +const MINIMUM_HTML_BYTES = 10_000; +const SEVERITIES = ["critical", "high", "medium", "low", "info"]; +const COVERAGE_SOURCES = new Set(["default", "explicit-user", "approved-batch"]); +const SCALAR_PATTERN = /^(?[A-Za-z0-9_-]+):\s*(?.*)$/; +const BLOCK_INDICATOR_PATTERN = /^[|>][+-]?$/; + +function splitInlineSequence(value) { + const items = []; + let current = ""; + let quote = null; + let nestedDepth = 0; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (quote) { + current += character; + if (character === "\\" && quote === '"' && index + 1 < value.length) { + current += value[index + 1]; + index += 1; + } else if (character === quote) { + quote = null; + } + continue; + } + if (character === '"' || character === "'") { + quote = character; + current += character; + continue; + } + if (character === "[" || character === "{") { + nestedDepth += 1; + current += character; + continue; + } + if (character === "]" || character === "}") { + nestedDepth -= 1; + current += character; + continue; + } + if (character === "," && nestedDepth === 0) { + items.push(current.trim()); + current = ""; + continue; + } + current += character; + } + + if (current.trim() || value.trim()) { + items.push(current.trim()); + } + return items; +} + +function coerceScalar(value) { + const scalar = value.trim(); + if (scalar === "" || scalar === "~" || scalar.toLowerCase() === "null") { + return null; + } + if (scalar.toLowerCase() === "true") { + return true; + } + if (scalar.toLowerCase() === "false") { + return false; + } + if ( + (scalar.startsWith('"') && scalar.endsWith('"')) || + (scalar.startsWith("'") && scalar.endsWith("'")) + ) { + if (scalar.startsWith('"')) { + try { + return JSON.parse(scalar); + } catch { + return scalar.slice(1, -1); + } + } + return scalar.slice(1, -1); + } + if (scalar.startsWith("[") && scalar.endsWith("]")) { + const inner = scalar.slice(1, -1).trim(); + if (!inner) { + return []; + } + return splitInlineSequence(inner).map((item) => coerceScalar(item)); + } + if (scalar === "{}") { + return {}; + } + if (scalar.startsWith("{") && scalar.endsWith("}")) { + try { + return JSON.parse(scalar.replaceAll("'", '"')); + } catch { + return scalar; + } + } + if (/^-?\d+$/.test(scalar)) { + return Number.parseInt(scalar, 10); + } + if (/^-?(?:\d+\.\d*|\d*\.\d+)$/.test(scalar)) { + return Number.parseFloat(scalar); + } + return scalar; +} + +function tokenizeYaml(text) { + const rawLines = text.split(/\r?\n/); + const tokens = []; + + for (let index = 0; index < rawLines.length; index += 1) { + const rawLine = rawLines[index]; + const trimmed = rawLine.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + + let line = rawLine; + if (line.includes("#") && !line.includes('"') && !line.includes("'")) { + line = line.split("#", 1)[0].trimEnd(); + } + const indent = line.length - line.trimStart().length; + const content = line.trim(); + const scalarMatch = SCALAR_PATTERN.exec(content); + + if ( + scalarMatch && + BLOCK_INDICATOR_PATTERN.test(scalarMatch.groups.value.trim()) + ) { + const indicator = scalarMatch.groups.value.trim(); + const blockLines = []; + let blockIndent = null; + let nextIndex = index + 1; + + for (; nextIndex < rawLines.length; nextIndex += 1) { + const innerLine = rawLines[nextIndex]; + if (!innerLine.trim()) { + blockLines.push(""); + continue; + } + const innerIndent = innerLine.length - innerLine.trimStart().length; + if (innerIndent <= indent) { + break; + } + blockIndent ??= innerIndent; + blockLines.push(innerLine.slice(blockIndent)); + } + + while (blockLines.at(-1) === "") { + blockLines.pop(); + } + let blockValue = blockLines.join("\n"); + if (indicator.includes("+")) { + blockValue += "\n"; + } + tokens.push({ + indent, + content: `${scalarMatch.groups.key}: ${JSON.stringify(blockValue)}`, + }); + index = nextIndex - 1; + continue; + } + + tokens.push({ indent, content }); + } + + return tokens; +} + +export function parseYaml(text) { + const tokens = tokenizeYaml(text); + let position = 0; + + function parseBlock(indent) { + if (position >= tokens.length) { + return null; + } + if (tokens[position].content.startsWith("- ")) { + return parseSequence(indent); + } + return parseMapping(indent); + } + + function parseMapping(indent) { + const result = {}; + while (position < tokens.length) { + const token = tokens[position]; + if (token.indent < indent) { + break; + } + if (token.indent > indent) { + position += 1; + continue; + } + + const match = SCALAR_PATTERN.exec(token.content); + if (!match) { + position += 1; + continue; + } + const key = match.groups.key; + const value = match.groups.value.trim(); + position += 1; + if (value === "") { + if (position < tokens.length && tokens[position].indent > indent) { + result[key] = parseBlock(tokens[position].indent); + } else { + result[key] = null; + } + } else { + result[key] = coerceScalar(value); + } + } + return result; + } + + function parseSequence(indent) { + const result = []; + while (position < tokens.length) { + const token = tokens[position]; + if (token.indent < indent || !token.content.startsWith("- ")) { + break; + } + if (token.indent > indent) { + position += 1; + continue; + } + + const body = token.content.slice(2).trim(); + position += 1; + if (/^[A-Za-z0-9_-]+:(?:\s|$)/.test(body) && !body.startsWith('"')) { + tokens.splice(position, 0, { indent: indent + 2, content: body }); + result.push(parseMapping(indent + 2)); + } else if (body === "") { + if (position < tokens.length && tokens[position].indent > indent) { + result.push(parseBlock(tokens[position].indent)); + } else { + result.push(null); + } + } else { + result.push(coerceScalar(body)); + } + } + return result; + } + + return tokens.length === 0 ? {} : parseBlock(tokens[0].indent); +} + +function readYaml(filePath, warnings) { + if (!fs.existsSync(filePath)) { + return null; + } + try { + return parseYaml(fs.readFileSync(filePath, "utf8")); + } catch (error) { + warnings.push(`[warn] failed to parse ${filePath}: ${error.message}`); + return null; + } +} + +function readVersionedYaml(filePath, warnings) { + const document = readYaml(filePath, warnings); + if (document && typeof document === "object" && document.version !== undefined && document.version !== 1) { + warnings.push(`[warn] unsupported schema version in ${filePath}: ${document.version}`); + } + return document; +} + +function normalizeArray(value) { + return Array.isArray(value) ? value : []; +} + +function filterPatches(allPatches, intent, maxPatches) { + const active = allPatches.filter((patch) => patch?.state === "active"); + const relevant = active.filter((patch) => { + if (!intent || intent === "unknown") { + return true; + } + const intents = normalizeArray(patch?.applies_to?.intents).map((item) => + String(item).toLowerCase(), + ); + return intents.length === 0 || intents.includes(intent.toLowerCase()) || intents.includes("full"); + }); + + relevant.sort((left, right) => { + const leftTimestamp = String( + left?.last_reinforced_at ?? left?.captured_at ?? "", + ); + const rightTimestamp = String( + right?.last_reinforced_at ?? right?.captured_at ?? "", + ); + return rightTimestamp.localeCompare(leftTimestamp); + }); + + return { + loaded: relevant.slice(0, maxPatches), + totalActive: active.length, + droppedOverflow: Math.max(0, relevant.length - maxPatches), + }; +} + +function oneLine(value, limit = 80) { + if (!value) { + return ""; + } + const flattened = String(value).split(/\s+/).join(" ").trim(); + if (flattened.length <= limit) { + return flattened; + } + return `${flattened.slice(0, limit - 1).trimEnd()}\u2026`; +} + +function groupPatchesByIntent(patches) { + const groups = new Map(); + for (const patch of patches) { + const intents = normalizeArray(patch?.applies_to?.intents); + const primaryIntent = String(intents[0] ?? "unscoped"); + const group = groups.get(primaryIntent) ?? []; + group.push(patch); + groups.set(primaryIntent, group); + } + return groups; +} + +function composeGreeting({ + findingsCount, + suppressionCount, + lastIntentFocus, + loadedPatches, + droppedOverflow, + retiredCount, + firstRun, +}) { + if (firstRun) { + return "First assessment in this repo. I'll set up .memory/ as we go."; + } + + const lines = []; + let base = `Loaded ${findingsCount} known findings`; + if (suppressionCount > 0) { + base += ` + ${suppressionCount} active suppression rules`; + } + if (loadedPatches.length > 0) { + base += ` + ${loadedPatches.length} active behavioral patches`; + } + base += "."; + if (lastIntentFocus) { + base += ` Last time you focused on ${lastIntentFocus}.`; + } + lines.push(base); + + if (loadedPatches.length > 0) { + lines.push("", "Active behavioral patches (hard constraints for this run):"); + const groups = groupPatchesByIntent(loadedPatches); + for (const intent of [...groups.keys()].sort()) { + const patches = groups.get(intent); + const summaries = patches + .slice(0, 3) + .map((patch) => `${patch?.id ?? "bp-?"} \"${oneLine(patch?.actual, 60)}\"`) + .join(", "); + const more = patches.length > 3 ? ` (+${patches.length - 3} more)` : ""; + lines.push(` - ${intent}: ${summaries}${more}`); + } + if (droppedOverflow > 0) { + lines.push( + ` (${droppedOverflow} additional active patches not loaded - exceeded max_loaded_per_run cap)`, + ); + } + if (retiredCount > 0) { + lines.push(` (${retiredCount} retired patches in archive - not loaded)`); + } + } + + return lines.join("\n"); +} + +function composePatchesPayload(patches) { + return { + patches: patches.map((patch) => ({ + id: patch?.id ?? null, + scope: patch?.scope ?? null, + source: patch?.source ?? null, + prior: patch?.prior ?? null, + actual: patch?.actual ?? null, + applies_to: patch?.applies_to ?? {}, + reinforce_count: patch?.reinforce_count ?? 1, + last_reinforced_at: patch?.last_reinforced_at ?? null, + })), + }; +} + +function formatUtcTimestamp(now) { + return now.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +export function loadMemory({ + memoryDir, + intent = "unknown", + maxPatches, + now = new Date(), +}) { + const resolvedMemoryDir = path.resolve(memoryDir); + const warnings = []; + const firstRunDirectory = !fs.existsSync(resolvedMemoryDir); + const findingsData = readVersionedYaml(path.join(resolvedMemoryDir, "findings.yaml"), warnings) ?? {}; + const suppressionsData = + readVersionedYaml(path.join(resolvedMemoryDir, "suppressions.yaml"), warnings) ?? {}; + const preferences = + readVersionedYaml(path.join(resolvedMemoryDir, "preferences.yaml"), warnings) ?? {}; + const lastIntent = + readVersionedYaml(path.join(resolvedMemoryDir, "last-intent.yaml"), warnings) ?? {}; + const biasData = + readVersionedYaml(path.join(resolvedMemoryDir, "bias-patches.yaml"), warnings) ?? {}; + + const findings = normalizeArray(findingsData.findings); + const suppressions = normalizeArray(suppressionsData.rules); + const allPatches = normalizeArray(biasData.patches); + const retiredCount = allPatches.filter((patch) => + ["retired", "superseded"].includes(patch?.state), + ).length; + const preferenceCap = preferences?.behavior?.bias_patches?.max_loaded_per_run; + const resolvedCap = Number.isInteger(maxPatches) + ? maxPatches + : Number.isInteger(preferenceCap) + ? preferenceCap + : DEFAULT_MAX_PATCHES; + const { loaded, totalActive, droppedOverflow } = filterPatches( + allPatches, + intent, + resolvedCap, + ); + const focus = lastIntent?.user_concern + ? String(lastIntent.user_concern).replaceAll("-", " ") + : null; + const greeting = composeGreeting({ + findingsCount: findings.length, + suppressionCount: suppressions.length, + lastIntentFocus: focus, + loadedPatches: loaded, + droppedOverflow, + retiredCount, + firstRun: firstRunDirectory && allPatches.length === 0 && findings.length === 0, + }); + const payload = composePatchesPayload(loaded); + const receipt = [ + `loaded@${formatUtcTimestamp(now)}`, + `findings=${findings.length}`, + `patches=${loaded.length}/${totalActive}`, + `suppressions=${suppressions.length}`, + ].join(" "); + + return { + greeting, + payload, + receipt, + warnings, + output: [ + "=== GREETING ===", + greeting, + "", + "=== ACTIVE PATCHES ===", + JSON.stringify(payload, null, 2), + "", + "=== RECEIPT TOKEN ===", + receipt, + ].join("\n"), + }; +} + +function parseCommandLine(argv) { + const [command, ...tokens] = argv; + const options = {}; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token.startsWith("--")) { + throw new Error(`Unexpected argument: ${token}`); + } + const name = token.slice(2); + const value = tokens[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`Missing value for --${name}`); + } + options[name] = value; + index += 1; + } + return { command, options }; +} + +function requireOption(options, name) { + if (!options[name]) { + throw new Error(`--${name} is required`); + } + return options[name]; +} + +function splitOption(value) { + return value + ? value.split(",").map((item) => item.trim()).filter(Boolean) + : undefined; +} + +function positiveIntegerOption(options, name) { + if (options[name] === undefined) { + return undefined; + } + const parsed = Number(options[name]); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`--${name} must be a positive integer`); + } + return parsed; +} + +function booleanOption(options, name) { + if (options[name] === undefined) return undefined; + if (options[name] === "true") return true; + if (options[name] === "false") return false; + throw new Error(`--${name} must be true or false`); +} + +function assessmentConfigOptions(options) { + return Object.fromEntries(Object.entries({ + targetRuntime: options["target-runtime"], + targetComputeServices: splitOption(options["target-compute-services"]), + enableContainerization: booleanOption(options, "enable-containerization"), + targetOS: splitOption(options["target-os"]), + minimumCveSeverity: options["minimum-cve-severity"], + cveScanScope: options["cve-scan-scope"], + }).filter(([, value]) => value !== undefined)); +} + +function coverageSourceOption(options) { + if (options["coverage-source"] !== undefined) { + return options["coverage-source"]; + } + if (options["attempt-scratch-root"] !== undefined) { + return "approved-batch"; + } + return "default"; +} + +function sameStringSet(actual, expected) { + if (!Array.isArray(actual) || !Array.isArray(expected)) return false; + const normalize = (values) => [...new Set(values.map(String))].sort(); + return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected)); +} + +function requireArtifact(workspaceRoot, artifactPath, label) { + if (!artifactPath) throw new Error(`${label} artifact path is required`); + const resolvedArtifactPath = path.isAbsolute(artifactPath) + ? path.resolve(artifactPath) + : path.resolve(workspaceRoot, artifactPath); + const stat = fs.statSync(resolvedArtifactPath, { throwIfNoEntry: false }); + if (!stat?.isFile()) { + throw new Error(`${label} artifact does not exist: ${resolvedArtifactPath}`); + } + const canonicalWorkspace = fs.realpathSync.native(workspaceRoot); + const canonicalArtifact = fs.realpathSync.native(resolvedArtifactPath); + const relativePath = path.relative(canonicalWorkspace, canonicalArtifact); + if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) { + throw new Error(`${label} artifact escapes the workspace: ${resolvedArtifactPath}`); + } + return canonicalArtifact; +} + +function readJsonArtifact(artifactPath, label) { + try { + return JSON.parse(fs.readFileSync(artifactPath, "utf8")); + } catch (error) { + throw new Error(`${label} artifact is not valid JSON: ${error.message}`); + } +} + +function verifiedCoverageSource(workspaceRoot, runId, analysisCoverage) { + const intentPath = requireArtifact( + workspaceRoot, + path.join(".github", "modernize", ".memory", "runs", runId, "intent.yaml"), + "assessment intent", + ); + const intent = parseYaml(fs.readFileSync(intentPath, "utf8")); + if (intent?.analysis_coverage !== analysisCoverage) { + throw new Error("assessment intent coverage does not match the completed run"); + } + const coverageSource = intent?.coverage_source; + if (!COVERAGE_SOURCES.has(coverageSource)) { + throw new Error(`unsupported assessment coverage source: ${coverageSource ?? "missing"}`); + } + if (analysisCoverage === "full" && coverageSource === "default") { + throw new Error("full assessment coverage cannot have a default source"); + } + return coverageSource; +} + +function parseJsonArtifact(content, label) { + try { + return JSON.parse(content); + } catch (error) { + throw new Error(`${label} artifact is not valid JSON: ${error.message}`); + } +} + +function findingCounts(findings, tracked) { + const bySeverity = Object.fromEntries(SEVERITIES.map((severity) => [severity, 0])); + const byState = {}; + for (const finding of findings) { + const severity = String(finding?.severity ?? "info").toLowerCase(); + bySeverity[SEVERITIES.includes(severity) ? severity : "info"] += 1; + const state = String(finding?.state ?? "new").toLowerCase(); + byState[state] = (byState[state] ?? 0) + 1; + } + return { total: findings.length, tracked, bySeverity, byState }; +} + +function validateSecurityResult(document, skillId) { + let values; + let partial = false; + if (skillId === "cve-known-vulnerabilities" && Array.isArray(document)) { + values = document; + } else { + const status = String(document?.status ?? "").toLowerCase(); + if (status === "not_applicable") return false; + partial = status === "partial"; + if (partial) { + if (!Array.isArray(document?.result?.evidence) || document.result.evidence.length === 0) { + throw new Error(`partial security artifact ${skillId} has no failure evidence`); + } + if (!Array.isArray(document?.result?.values)) { + throw new Error(`partial security artifact ${skillId} has no values array`); + } + } else if (!["success", "succeeded", "completed", "ok"].includes(status)) { + throw new Error(`security artifact ${skillId} has no terminal status`); + } else if (!Array.isArray(document?.result?.values) || document.result.values.length === 0) { + throw new Error(`security artifact ${skillId} has no terminal rule evidence`); + } + values = document.result.values; + } + for (const [index, value] of values.entries()) { + if (!["FOUND", "NOT_FOUND"].includes(String(value?.status ?? "").toUpperCase())) { + throw new Error(`security artifact ${skillId} entry ${index} is not FOUND or NOT_FOUND`); + } + } + return partial; +} + +export async function verifyAssessmentArtifacts({ + workspacePath, + runId, + language, + domains = [], + analysisCoverage = "issue-only", + reportPath, + normalizedAssessmentPath, + htmlPath, + appcatReportPath, + securityRoot, +} = {}) { + const workspaceRoot = path.resolve(workspacePath ?? ""); + if (!fs.statSync(workspaceRoot, { throwIfNoEntry: false })?.isDirectory()) { + throw new Error(`workspace does not exist: ${workspaceRoot}`); + } + if (!runId) throw new Error("runId is required"); + const normalizedLanguage = String(language ?? "").toLowerCase(); + if (!["java", "dotnet", "javascript", "typescript"].includes(normalizedLanguage)) { + throw new Error(`unsupported assessment language: ${language}`); + } + if (!["issue-only", "full"].includes(analysisCoverage)) { + throw new Error(`unsupported assessment coverage: ${analysisCoverage}`); + } + + const artifacts = { + report: requireArtifact(workspaceRoot, reportPath, "report"), + normalizedAssessment: requireArtifact( + workspaceRoot, + normalizedAssessmentPath, + "normalized assessment", + ), + html: requireArtifact(workspaceRoot, htmlPath, "HTML report"), + }; + const coverageSource = verifiedCoverageSource(workspaceRoot, runId, analysisCoverage); + const normalizedAssessment = readJsonArtifact( + artifacts.normalizedAssessment, + "normalized assessment", + ); + if (normalizedAssessment?.schemaVersion !== 1 + || normalizedAssessment.kind !== "github-copilot-modernization/normalized-assessment" + || !normalizedAssessment.metadata + || !Array.isArray(normalizedAssessment.categories) + || !Array.isArray(normalizedAssessment.findings) + || !Array.isArray(normalizedAssessment.security)) { + throw new Error("normalized assessment artifact does not match its v1 contract"); + } + if (normalizedAssessment.metadata.runId !== runId + || normalizedAssessment.metadata.status !== "completed") { + throw new Error("normalized assessment artifact does not match the completed run"); + } + if (String(normalizedAssessment.metadata.language ?? "").toLowerCase() !== normalizedLanguage) { + throw new Error("normalized assessment language does not match the completed run"); + } + if (!sameStringSet(normalizedAssessment.metadata.domains, domains)) { + throw new Error("normalized assessment domains do not match the completed run"); + } + if (normalizedAssessment.metadata.totalFindings !== normalizedAssessment.findings.length + || !Number.isInteger(normalizedAssessment.metadata.totalTrackedFindings) + || normalizedAssessment.metadata.totalTrackedFindings < normalizedAssessment.findings.length) { + throw new Error("normalized assessment finding counts are inconsistent"); + } + const canonicalReport = readJsonArtifact(artifacts.report, "canonical report"); + if (canonicalReport?.version !== "1.0.0" + || typeof canonicalReport.producer !== "string" + || !canonicalReport.producer + || !canonicalReport.metadata + || !canonicalReport.summary + || !Array.isArray(canonicalReport.projects) + || !canonicalReport.rules + || typeof canonicalReport.rules !== "object" + || Array.isArray(canonicalReport.rules) + || !Array.isArray(canonicalReport.security)) { + throw new Error("canonical report artifact does not match the public report v1.0.0 contract"); + } + if (String(canonicalReport.metadata.status ?? "").toLowerCase() !== "completed" + || !sameStringSet(canonicalReport.metadata.domains, domains) + || canonicalReport.metadata.mode !== analysisCoverage) { + throw new Error("canonical report artifact does not match the completed run"); + } + for (const field of ["id", "name", "minimumCveSeverity", "cveScanScope"]) { + if (!canonicalReport.metadata[field]) { + throw new Error(`canonical report metadata is missing ${field}`); + } + } + if (normalizedLanguage === "java" + && (!Array.isArray(canonicalReport.metadata.capabilities) + || canonicalReport.metadata.capabilities.length === 0 + || !Array.isArray(canonicalReport.metadata.os) + || canonicalReport.metadata.os.length === 0)) { + throw new Error("canonical report target metadata is incomplete"); + } + + const html = fs.readFileSync(artifacts.html, "utf8"); + if (Buffer.byteLength(html) <= MINIMUM_HTML_BYTES || /\{\{[A-Z0-9_]+\}\}/.test(html)) { + throw new Error("HTML report artifact is incomplete"); + } + const payloadMatch = html.match(/${"x".repeat(10_000)}`, + "utf8", + ); + fs.writeFileSync(appcatPath, '{"rules":[]}\n', "utf8"); + writeAssessmentIntent(workspacePath, runId); + + const result = spawnSync( + process.execPath, + [ + scriptPath, + "verify-artifacts", + "--workspace-path", workspacePath, + "--run-id", runId, + "--language", "java", + "--domains", domains.join(","), + "--coverage", "issue-only", + "--report", reportPath, + "--normalized-assessment", normalizedAssessmentPath, + "--html", htmlPath, + "--appcat-report", appcatPath, + "--outcome", outcomePath, + ], + { encoding: "utf8" }, + ); + + assert.equal(result.status, 0, result.stderr); + const receipt = JSON.parse(result.stdout); + assert.equal(receipt.status, "success"); + assert.equal(receipt.artifactValidation, "passed"); + assert.equal(receipt.completionEvidence.artifactValidation, "passed"); + assert.equal(receipt.completionEvidence.runId, runId); + assert.equal(receipt.language, "java"); + assert.deepEqual(receipt.domains, domains); + assert.equal(receipt.analysisCoverage, "issue-only"); + assert.equal(receipt.coverageSource, "default"); + assert.equal(receipt.completionEvidence.coverageSource, "default"); + assert.equal(receipt.planningSupported, true); + assert.deepEqual(receipt.findingCounts, { + total: 1, + tracked: 1, + bySeverity: { critical: 0, high: 1, medium: 0, low: 0, info: 0 }, + byState: { new: 1 }, + }); + assert.deepEqual(receipt.completionEvidence.findingCounts, receipt.findingCounts); + assert.equal(receipt.artifacts.report, fs.realpathSync.native(reportPath)); + assert.equal( + receipt.artifacts.normalizedAssessment, + fs.realpathSync.native(normalizedAssessmentPath), + ); + assert.equal(receipt.artifacts.html, fs.realpathSync.native(htmlPath)); + assert.equal(receipt.artifacts.appcat, fs.realpathSync.native(appcatPath)); + assert.deepEqual(JSON.parse(fs.readFileSync(outcomePath, "utf8")), { + status: "completed", + artifacts: receipt.artifacts, + evidence: { + artifactValidation: "passed", + planningSupported: true, + language: "java", + domains, + analysisCoverage: "issue-only", + coverageSource: "default", + findingCounts: receipt.findingCounts, + topRecommendation: receipt.topRecommendation, + partialTasks: [], + }, + needsInput: null, + error: null, + }); + + writeAssessmentIntent(workspacePath, runId, "issue-only", "inferred"); + await assert.rejects( + verifyAssessmentArtifacts({ + workspacePath, + runId, + language: "java", + domains, + analysisCoverage: "issue-only", + reportPath, + normalizedAssessmentPath, + htmlPath, + appcatReportPath: appcatPath, + }), + /unsupported assessment coverage source/, + ); +}); + +test("verify-artifacts preserves the release report and keeps normalized data internal", () => { + const workspacePath = createTemporaryDirectory(); + const runId = "20260831-120010"; + const domains = ["java-upgrade", "cloud-readiness"]; + const reportDirectory = path.join( + workspacePath, + ".github", + "modernize", + "assessment", + "reports", + `report-${runId}`, + ); + const reportPath = path.join(reportDirectory, "report.json"); + const normalizedAssessmentPath = path.join( + workspacePath, + ".github", + "modernize", + ".memory", + "runs", + runId, + "normalized-assessment.json", + ); + const htmlPath = path.join(workspacePath, ".github", "modernize", "reports", `${runId}-assess.html`); + const appcatPath = path.join(workspacePath, ".github", "modernize", ".memory", "runs", runId, "appcat", "report.json"); + const canonicalReport = { + version: "1.0.0", + producer: "Java AppCAT CLI", + metadata: { + id: runId, + name: `Report_${runId}`, + status: "completed", + domains, + mode: "issue-only", + capabilities: ["openjdk25"], + os: ["windows", "linux"], + minimumCveSeverity: "high", + cveScanScope: "direct", + }, + summary: { totalProjects: 1, totalIssues: 1, totalIncidents: 2, totalEffort: 8 }, + projects: [{ + path: ".", + issues: 1, + storyPoints: 8, + properties: { + appName: "orders", + jdkVersion: "17", + frameworks: ["Spring Boot"], + languages: ["Java"], + tools: ["Maven"], + }, + incidents: [], + }], + rules: {}, + security: [], + }; + const normalizedAssessment = { + schemaVersion: 1, + kind: "github-copilot-modernization/normalized-assessment", + metadata: { + runId, + language: "java", + status: "completed", + domains, + totalFindings: 1, + totalTrackedFindings: 1, + }, + categories: [], + findings: [{ id: "finding-1", severity: "high", state: "new" }], + security: [], + }; + const htmlPayload = { + meta: { run_id: runId }, + selected_groups: domains, + counts: { total: 1 }, + top_recommendation: { summary: "Address finding-1" }, + }; + fs.mkdirSync(reportDirectory, { recursive: true }); + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.mkdirSync(path.dirname(appcatPath), { recursive: true }); + fs.writeFileSync(reportPath, `${JSON.stringify(canonicalReport)}\n`, "utf8"); + fs.mkdirSync(path.dirname(normalizedAssessmentPath), { recursive: true }); + fs.writeFileSync(normalizedAssessmentPath, `${JSON.stringify(normalizedAssessment)}\n`, "utf8"); + fs.writeFileSync( + htmlPath, + `${"x".repeat(10_000)}`, + "utf8", + ); + fs.writeFileSync(appcatPath, '{"version":"1.0.0"}\n', "utf8"); + writeAssessmentIntent(workspacePath, runId); + + const result = spawnSync( + process.execPath, + [ + scriptPath, + "verify-artifacts", + "--workspace-path", workspacePath, + "--run-id", runId, + "--language", "java", + "--domains", domains.join(","), + "--coverage", "issue-only", + "--report", reportPath, + "--normalized-assessment", normalizedAssessmentPath, + "--html", htmlPath, + "--appcat-report", appcatPath, + "--presentation", "user", + ], + { encoding: "utf8" }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^\*\*Assessment complete\.\*\*/); + assert.match(result.stdout, /orders/); + assert.match(result.stdout, /1 issue type across 2 incidents/); + assert.doesNotMatch(result.stdout, /normalized-assessment\.json/); + assert.match(result.stdout, /verification\.json/); + assert.doesNotMatch(result.stdout, /Proceed to planning\?/); + assert.doesNotMatch(result.stdout, /assessment-verification/); + assert.doesNotMatch(result.stdout, /"completionEvidence"/); + const verificationPath = path.join(reportDirectory, "verification.json"); + const receipt = JSON.parse(fs.readFileSync(verificationPath, "utf8")); + assert.equal(receipt.artifactValidation, "passed"); + assert.equal(receipt.completionEvidence.artifactValidation, "passed"); + assert.equal(receipt.artifacts.report, fs.realpathSync.native(reportPath)); + assert.equal(receipt.artifacts.normalizedAssessment, fs.realpathSync.native(normalizedAssessmentPath)); + assert.equal(receipt.artifacts.verification, fs.realpathSync.native(verificationPath)); + + const relativeResult = spawnSync( + process.execPath, + [ + scriptPath, + "verify-artifacts", + "--workspace-path", workspacePath, + "--run-id", runId, + "--language", "java", + "--domains", domains.join(","), + "--coverage", "issue-only", + "--report", path.relative(workspacePath, reportPath), + "--normalized-assessment", path.relative(workspacePath, normalizedAssessmentPath), + "--html", path.relative(workspacePath, htmlPath), + "--appcat-report", path.relative(workspacePath, appcatPath), + "--presentation", "user", + ], + { encoding: "utf8" }, + ); + + assert.equal(relativeResult.status, 0, relativeResult.stderr); + assert.match(relativeResult.stdout, /^\*\*Assessment complete\.\*\*/); +}); + +test("verify-artifacts rejects malformed, stale, and incomplete report sets", async (t) => { + const workspacePath = createTemporaryDirectory(); + const runId = "20260831-120002"; + const domains = ["cloud-readiness"]; + const reportPath = path.join(workspacePath, "assessment", "report.json"); + const normalizedAssessmentPath = path.join(workspacePath, "runs", runId, "normalized-assessment.json"); + const htmlPath = path.join(workspacePath, "reports", "report.html"); + const appcatReportPath = path.join(workspacePath, "runs", runId, "appcat", "report.json"); + const report = canonicalReport(runId, domains); + const normalized = normalizedAssessment(runId, domains); + const htmlPayload = { + meta: { run_id: runId }, + selected_groups: domains, + counts: { total: 0 }, + top_recommendation: { kind: "no-findings", summary: "No findings" }, + }; + const options = { + workspacePath, + runId, + language: "java", + domains, + analysisCoverage: "issue-only", + reportPath, + normalizedAssessmentPath, + htmlPath, + appcatReportPath, + }; + const writeReport = (value = report) => { + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, `${JSON.stringify(value)}\n`, "utf8"); + writeAssessmentIntent(workspacePath, runId); + }; + const writeNormalizedAssessment = (value = normalized) => { + fs.mkdirSync(path.dirname(normalizedAssessmentPath), { recursive: true }); + fs.writeFileSync(normalizedAssessmentPath, `${JSON.stringify(value)}\n`, "utf8"); + }; + const writeHtml = () => { + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync( + htmlPath, + `${"x".repeat(10_000)}`, + "utf8", + ); + }; + + await t.test("malformed normalized assessment JSON", async () => { + writeReport(); + writeNormalizedAssessment(); + writeHtml(); + fs.writeFileSync(normalizedAssessmentPath, "{", "utf8"); + await assert.rejects( + verifyAssessmentArtifacts(options), + /normalized assessment artifact is not valid JSON/, + ); + }); + + await t.test("unsupported canonical report version", async () => { + writeReport({ ...report, version: "1.1.0" }); + writeNormalizedAssessment(); + writeHtml(); + await assert.rejects( + verifyAssessmentArtifacts(options), + /public report v1\.0\.0 contract/, + ); + }); + + await t.test("mismatched run identity", async () => { + writeReport(); + writeNormalizedAssessment({ + ...normalized, + metadata: { ...normalized.metadata, runId: "different-run" }, + }); + writeHtml(); + fs.mkdirSync(path.dirname(appcatReportPath), { recursive: true }); + fs.writeFileSync(appcatReportPath, "{}\n", "utf8"); + await assert.rejects(verifyAssessmentArtifacts(options), /does not match the completed run/); + }); + + await t.test("missing HTML", async () => { + writeReport(); + writeNormalizedAssessment(); + fs.rmSync(htmlPath, { force: true }); + await assert.rejects(verifyAssessmentArtifacts(options), /HTML report artifact does not exist/); + }); + + await t.test("missing AppCAT", async () => { + writeReport(); + writeNormalizedAssessment(); + writeHtml(); + fs.rmSync(appcatReportPath, { force: true }); + await assert.rejects(verifyAssessmentArtifacts(options), /AppCAT report artifact does not exist/); + }); +}); + +test("verify-artifacts rejects non-terminal CVE evidence", async () => { + const workspacePath = createTemporaryDirectory(); + const runId = "20260831-120003"; + const reportPath = path.join(workspacePath, "assessment", "report.json"); + const normalizedAssessmentPath = path.join(workspacePath, "runs", runId, "normalized-assessment.json"); + const htmlPath = path.join(workspacePath, "reports", "report.html"); + const securityRoot = path.join(workspacePath, "security"); + const domains = ["security"]; + const report = canonicalReport(runId, domains); + const normalized = normalizedAssessment(runId, domains); + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.mkdirSync(path.dirname(normalizedAssessmentPath), { recursive: true }); + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.mkdirSync(securityRoot, { recursive: true }); + fs.writeFileSync(reportPath, `${JSON.stringify(report)}\n`, "utf8"); + fs.writeFileSync(normalizedAssessmentPath, `${JSON.stringify(normalized)}\n`, "utf8"); + fs.writeFileSync( + htmlPath, + `${"x".repeat(10_000)}`, + "utf8", + ); + writeAssessmentIntent(workspacePath, runId); + fs.writeFileSync( + path.join(securityRoot, "cve-known-vulnerabilities.json"), + '[{"status":"PENDING"}]\n', + "utf8", + ); + for (const skillId of [ + "cwe-code-quality", + "cwe-concurrency-synchronization", + "cwe-credentials-secrets", + "cwe-file-path-security", + "cwe-injection-attacks", + "cwe-memory-safety", + ]) { + fs.writeFileSync( + path.join(securityRoot, `${skillId}.json`), + '{"status":"success","result":{"values":[{"status":"NOT_FOUND"}]}}\n', + "utf8", + ); + } + + await assert.rejects( + verifyAssessmentArtifacts({ + workspacePath, + runId, + language: "java", + domains, + analysisCoverage: "issue-only", + reportPath, + normalizedAssessmentPath, + htmlPath, + securityRoot, + }), + /entry 0 is not FOUND or NOT_FOUND/, + ); +}); + +test("loadMemory warns on unsupported persisted schema versions", () => { + const memoryDir = createTemporaryDirectory(); + writeFile(memoryDir, "findings.yaml", "version: 2\nfindings: []\n"); + + const result = loadMemory({ memoryDir }); + + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0], /unsupported schema version/); +}); + +test("parseYaml preserves empty collection types", () => { + assert.deepEqual(parseYaml("mapping: {}\nsequence: []\n"), { + mapping: {}, + sequence: [], + }); +}); \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/assessment/scripts/assess-report.mjs b/plugins/github-copilot-modernization/skills/assessment/scripts/assess-report.mjs new file mode 100644 index 0000000..11babfb --- /dev/null +++ b/plugins/github-copilot-modernization/skills/assessment/scripts/assess-report.mjs @@ -0,0 +1,524 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parseYaml } from "./assess-cli.mjs"; + +const ALL_GROUPS = [ + "security-cve", + "security-cwe", + "architecture", + "infrastructure", + "configuration", + "application-facts", +]; +const SEVERITIES = ["critical", "high", "medium", "low", "info"]; +const KNOWN_ENRICHMENT_KEYS = new Set([ + "version", + "intent_slug", + "generated_at", + "generated_by", + "model_hint", + "briefing", + "headlines", + "change_narrative", + "themes", + "findings", + "risks", + "cost_estimate", + "next_steps", +]); +const RISK_SEVERITIES = new Set(SEVERITIES); +const RAW_ENRICHMENT_SENTINELS = new Set(["", "-", "none", "null", "nul", "/dev/null"]); +const OTHER_THEME_ID = "other"; +const TEMPLATE_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "templates", "report.html"); + +function readYaml(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + return parseYaml(fs.readFileSync(filePath, "utf8")); +} + +function resolveEnrichmentPath(memoryDir, runId, enrichmentPath) { + if (enrichmentPath !== undefined && enrichmentPath !== null) { + if (RAW_ENRICHMENT_SENTINELS.has(String(enrichmentPath).trim().toLowerCase())) { + return null; + } + return path.resolve(enrichmentPath); + } + const defaultPath = path.resolve(memoryDir, "runs", runId, "enrichment.yaml"); + return fs.existsSync(defaultPath) ? defaultPath : null; +} + +function cleanReferenceList(value, findingIds, context, warnings, ownId = null) { + if (!Array.isArray(value)) { + return []; + } + return value.filter((findingId) => { + if (findingIds.has(findingId) && findingId !== ownId) { + return true; + } + warnings.push(`${context}: ${JSON.stringify(findingId)} not in findings.yaml; dropped`); + return false; + }); +} + +function sanitizeEnrichment(data, findingIds, warnings) { + for (const key of Object.keys(data)) { + if (!KNOWN_ENRICHMENT_KEYS.has(key)) { + warnings.push(`unknown top-level enrichment key ${JSON.stringify(key)}; dropped`); + delete data[key]; + } + } + + if (Array.isArray(data.headlines)) { + data.headlines = data.headlines.filter((headline) => headline && typeof headline === "object"); + for (const headline of data.headlines) { + const jump = headline.jump_to; + if (jump && typeof jump === "object" && String(jump.kind || "").toLowerCase() === "finding" && !findingIds.has(jump.id)) { + warnings.push(`headlines: jump_to.finding id ${JSON.stringify(jump.id)} not in findings.yaml; clearing`); + headline.jump_to = null; + } + } + } + + const validThemeIds = new Set(); + if (Array.isArray(data.themes)) { + const assigned = new Map(); + const themes = []; + for (const theme of data.themes) { + if (!theme || typeof theme !== "object" || typeof theme.id !== "string" || !theme.id) { + warnings.push("themes: entry without a string id; dropped"); + continue; + } + const kept = []; + for (const findingId of Array.isArray(theme.finding_ids) ? theme.finding_ids : []) { + if (!findingIds.has(findingId)) { + warnings.push(`themes[${theme.id}]: finding id ${JSON.stringify(findingId)} not in findings.yaml; dropped`); + } else if (assigned.has(findingId) && assigned.get(findingId) !== theme.id) { + warnings.push(`themes[${theme.id}]: finding id ${JSON.stringify(findingId)} already in theme ${JSON.stringify(assigned.get(findingId))}; dropped`); + } else { + assigned.set(findingId, theme.id); + kept.push(findingId); + } + } + theme.finding_ids = kept; + themes.push(theme); + validThemeIds.add(theme.id); + } + const unassigned = [...findingIds].filter((findingId) => !assigned.has(findingId)); + if (unassigned.length) { + themes.push({ + id: OTHER_THEME_ID, + label: "Other findings", + summary: "Findings the AI narrative did not assign to a theme.", + finding_ids: unassigned, + auto_bucketed: true, + }); + validThemeIds.add(OTHER_THEME_ID); + } + data.themes = themes; + } + + for (const headline of Array.isArray(data.headlines) ? data.headlines : []) { + const jump = headline.jump_to; + if (jump && typeof jump === "object" && String(jump.kind || "").toLowerCase() === "theme" && !validThemeIds.has(jump.id)) { + warnings.push(`headlines: jump_to.theme id ${JSON.stringify(jump.id)} is not a theme in this enrichment; clearing`); + headline.jump_to = null; + } + } + + if (Array.isArray(data.findings)) { + const seen = new Set(); + data.findings = data.findings.filter((entry) => { + if (!entry || typeof entry !== "object") { + return false; + } + if (!findingIds.has(entry.id)) { + warnings.push(`findings[]: enrichment for ${JSON.stringify(entry.id)} has no matching finding; dropped`); + return false; + } + if (seen.has(entry.id)) { + warnings.push(`findings[]: duplicate enrichment for ${JSON.stringify(entry.id)}; keeping first`); + return false; + } + seen.add(entry.id); + if (Array.isArray(entry.related_findings)) { + entry.related_findings = entry.related_findings + .filter((relation) => relation && typeof relation === "object") + .map((relation) => ({ + ...relation, + finding_ids: cleanReferenceList( + relation.finding_ids, + findingIds, + `findings[${entry.id}].related_findings`, + warnings, + entry.id, + ), + })) + .filter((relation) => relation.finding_ids.length); + } + return true; + }); + } + + const highlights = data.change_narrative?.highlights; + if (Array.isArray(highlights)) { + data.change_narrative.highlights = highlights + .filter((highlight) => highlight && typeof highlight === "object") + .map((highlight) => ({ + ...highlight, + finding_ids: cleanReferenceList(highlight.finding_ids, findingIds, "change_narrative.highlights", warnings), + })) + .filter((highlight) => highlight.finding_ids.length); + } + + if (Array.isArray(data.risks)) { + const seen = new Set(); + data.risks = data.risks.filter((risk) => { + if (!risk || typeof risk !== "object" || typeof risk.id !== "string" || !risk.id) { + warnings.push("risks: entry without a string id; dropped"); + return false; + } + if (seen.has(risk.id)) { + warnings.push(`risks: duplicate id ${JSON.stringify(risk.id)}; keeping first`); + return false; + } + seen.add(risk.id); + const severity = String(risk.severity || "").toLowerCase(); + if (!RISK_SEVERITIES.has(severity)) { + warnings.push(`risks[${risk.id}]: invalid severity; defaulting to 'medium'`); + risk.severity = "medium"; + } else { + risk.severity = severity; + } + if (typeof risk.title !== "string" || !risk.title || typeof risk.body !== "string" || !risk.body) { + warnings.push(`risks[${risk.id}]: missing title or body; dropped`); + return false; + } + risk.finding_ids = cleanReferenceList(risk.finding_ids, findingIds, `risks[${risk.id}].finding_ids`, warnings); + return true; + }); + } else if (data.risks !== undefined) { + warnings.push("risks: not a list; dropped"); + delete data.risks; + } + + if (data.cost_estimate !== undefined && (!data.cost_estimate || typeof data.cost_estimate !== "object" || Array.isArray(data.cost_estimate))) { + warnings.push("cost_estimate: not a mapping; dropped"); + delete data.cost_estimate; + } + + if (Array.isArray(data.next_steps)) { + data.next_steps = data.next_steps.filter((step, index) => { + const valid = step && typeof step === "object" && typeof step.title === "string" && step.title; + if (!valid) { + warnings.push(`next_steps[${index}]: missing mapping or title; dropped`); + } + return valid; + }); + } else if (data.next_steps !== undefined) { + warnings.push("next_steps: not a list; dropped"); + delete data.next_steps; + } + + return [...validThemeIds]; +} + +function deriveReportMode(enrichment) { + if (!enrichment || typeof enrichment !== "object") { + return "raw"; + } + const hasBriefing = enrichment.briefing && typeof enrichment.briefing === "object"; + const hasThemes = Array.isArray(enrichment.themes) && enrichment.themes.some((theme) => Array.isArray(theme?.finding_ids) && theme.finding_ids.length); + const hasFindings = Array.isArray(enrichment.findings) && enrichment.findings.length > 0; + if (hasBriefing && hasThemes && hasFindings) { + return "ai-narrated"; + } + return hasBriefing || hasThemes || hasFindings ? "partial" : "raw"; +} + +export function loadEnrichment({ memoryDir, runId, enrichmentPath } = {}) { + const warnings = []; + const findingsData = readYaml(path.resolve(memoryDir, "findings.yaml")) || {}; + const findings = Array.isArray(findingsData.findings) ? findingsData.findings : []; + const findingIds = new Set(findings.filter((finding) => finding && typeof finding === "object" && finding.id).map((finding) => finding.id)); + const resolvedPath = resolveEnrichmentPath(memoryDir, runId, enrichmentPath); + if (!resolvedPath) { + return { enrichment: null, warnings, mode: "raw", path: null, findingIds, themeIds: [] }; + } + if (!fs.existsSync(resolvedPath)) { + warnings.push(`enrichment file not found at ${resolvedPath}`); + return { enrichment: null, warnings, mode: "raw", path: resolvedPath, findingIds, themeIds: [] }; + } + let enrichment; + try { + enrichment = readYaml(resolvedPath); + } catch (error) { + warnings.push(`failed to parse enrichment file at ${resolvedPath}: ${error.message}`); + return { enrichment: null, warnings, mode: "raw", path: resolvedPath, findingIds, themeIds: [] }; + } + if (!enrichment || typeof enrichment !== "object" || Array.isArray(enrichment)) { + warnings.push(`enrichment file at ${resolvedPath} did not parse to a mapping`); + return { enrichment: null, warnings, mode: "raw", path: resolvedPath, findingIds, themeIds: [] }; + } + if (enrichment.version !== 1) { + warnings.push(`enrichment version ${JSON.stringify(enrichment.version)} is not supported by this generator; ignoring`); + return { enrichment: null, warnings, mode: "raw", path: resolvedPath, findingIds, themeIds: [] }; + } + const themeIds = sanitizeEnrichment(enrichment, findingIds, warnings); + return { + enrichment, + warnings, + mode: deriveReportMode(enrichment), + path: resolvedPath, + findingIds, + themeIds, + }; +} + +export function validateEnrichment({ memoryDir, runId, enrichmentPath, allowRaw = false } = {}) { + const failures = []; + const findingsData = readYaml(path.resolve(memoryDir, "findings.yaml")) || {}; + const findings = Array.isArray(findingsData.findings) ? findingsData.findings : []; + const loaded = loadEnrichment({ memoryDir, runId, enrichmentPath }); + const { enrichment, mode, warnings } = loaded; + + if (!findings.length && allowRaw && mode === "raw") { + return { ok: true, mode, failures, warnings }; + } + if (!findings.length) { + failures.push("findings.yaml has no findings; nothing to enrich."); + } + if (!enrichment) { + failures.push("loader returned no enrichment (unsupported version, unreadable file, or empty content)."); + } else { + const paragraph = enrichment.briefing?.paragraph; + if (typeof paragraph !== "string" || !paragraph.trim()) { + failures.push("briefing.paragraph is missing or empty."); + } else if (paragraph.trim().length < 200) { + failures.push(`briefing.paragraph is too short (${paragraph.trim().length} chars, minimum 200).`); + } + + if (!Array.isArray(enrichment.headlines)) { + failures.push("headlines block is missing."); + } else { + if (enrichment.headlines.length < 3 || enrichment.headlines.length > 5) { + failures.push(`${enrichment.headlines.length} headlines; expected 3-5.`); + } + enrichment.headlines.forEach((headline, index) => { + if (!headline?.kind) failures.push(`headlines[${index}]: missing kind.`); + if (!headline?.title) failures.push(`headlines[${index}]: missing title.`); + }); + } + + if (!Array.isArray(enrichment.themes) || !enrichment.themes.length) { + failures.push("themes block is missing or empty."); + } else { + const namedThemes = enrichment.themes.filter((theme) => theme?.id !== OTHER_THEME_ID && typeof theme?.label === "string" && theme.label.trim()); + if (!namedThemes.length) { + failures.push("themes must include at least one named theme with a label."); + } + const otherCount = enrichment.themes.find((theme) => theme?.id === OTHER_THEME_ID)?.finding_ids?.length || 0; + const totalAssigned = enrichment.themes.reduce((total, theme) => total + (Array.isArray(theme?.finding_ids) ? theme.finding_ids.length : 0), 0); + if (totalAssigned && otherCount / totalAssigned > 0.5) { + failures.push(`${otherCount}/${totalAssigned} findings landed in the auto-bucketed 'other' theme; maximum is 50%.`); + } + } + + const highFindings = findings.filter((finding) => ["critical", "high"].includes(String(finding?.severity || "").toLowerCase())); + const coveredIds = new Set((Array.isArray(enrichment.findings) ? enrichment.findings : []) + .filter((entry) => typeof entry?.why_it_matters === "string" && entry.why_it_matters.trim()) + .map((entry) => entry.id)); + if (highFindings.length) { + const covered = highFindings.filter((finding) => coveredIds.has(finding.id)).length; + if (covered / highFindings.length < 0.6) { + failures.push(`only ${covered}/${highFindings.length} high+critical findings have why_it_matters; minimum is 60%.`); + } + } + + if (!Array.isArray(enrichment.next_steps) || !enrichment.next_steps.length) { + failures.push("next_steps block is missing or empty."); + } else { + if (enrichment.next_steps.length < 1 || enrichment.next_steps.length > 3) { + failures.push(`${enrichment.next_steps.length} next_steps entries; expected 1-3.`); + } + enrichment.next_steps.forEach((step, index) => { + if (!step?.kind) failures.push(`next_steps[${index}]: missing kind.`); + if (!step?.title) failures.push(`next_steps[${index}]: missing title.`); + }); + } + } + if (mode !== "ai-narrated" && !(allowRaw && mode === "raw" && !findings.length)) { + failures.unshift(`predicted report mode is '${mode}'; expected 'ai-narrated'.`); + } + return { ok: failures.length === 0, mode, failures, warnings }; +} + +function intentSlug(intent) { + const concern = intent?.user_concern; + if (typeof concern !== "string" || !concern) { + return "assess"; + } + return concern.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "assess"; +} + +function bucketSeverity(findings) { + const counts = Object.fromEntries(SEVERITIES.map((severity) => [severity, 0])); + for (const finding of findings) { + const severity = String(finding?.severity || "info").toLowerCase(); + counts[SEVERITIES.includes(severity) ? severity : "info"] += 1; + } + return counts; +} + +function bucketState(findings) { + const counts = {}; + for (const finding of findings) { + const state = String(finding?.state || "new").toLowerCase(); + counts[state] = (counts[state] || 0) + 1; + } + return counts; +} + +function deriveTopRecommendation(findings, intent) { + const rank = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; + const newFindings = findings.filter((finding) => String(finding?.state || "new").toLowerCase() === "new"); + const candidates = [...(newFindings.length ? newFindings : findings)].sort((left, right) => + (rank[String(left?.severity || "info").toLowerCase()] ?? 5) - (rank[String(right?.severity || "info").toLowerCase()] ?? 5)); + if (!candidates.length) { + return { kind: "no-findings", summary: `No outstanding findings for focus '${intent?.user_concern || "overview"}'.`, next_action: null, prefilled_prompt: null }; + } + const top = candidates[0]; + const skill = String(top.skill || "").toLowerCase(); + const title = top.title || "(untitled)"; + if (skill.startsWith("cve-") || title.toLowerCase().includes("cve")) { + return { kind: "security", summary: `Address top vulnerability: ${title}`, next_action: "create-modernization-plan", prefilled_prompt: `Fix ${title} and any related CVEs in dependencies` }; + } + if (skill.startsWith("cwe-")) { + return { kind: "code-quality", summary: `Remediate top code-weakness: ${title}`, next_action: "create-modernization-plan", prefilled_prompt: `Refactor to eliminate ${title}` }; + } + if (skill.startsWith("fact-")) { + return { kind: "readiness", summary: `Investigate: ${title}`, next_action: "create-modernization-plan", prefilled_prompt: "Create a modernization plan addressing the top readiness gaps" }; + } + return { kind: "generic", summary: title, next_action: "create-modernization-plan", prefilled_prompt: "Create a modernization plan from the assessment's high-priority findings" }; +} + +function buildPayload({ memoryDir, runId, projectRoot, enrichmentPath }) { + const runDir = path.resolve(memoryDir, "runs", runId); + const findingsData = readYaml(path.resolve(memoryDir, "findings.yaml")) || {}; + const suppressionsData = readYaml(path.resolve(memoryDir, "suppressions.yaml")) || {}; + const preferences = readYaml(path.resolve(memoryDir, "preferences.yaml")) || {}; + const intent = readYaml(path.resolve(runDir, "intent.yaml")) || readYaml(path.resolve(memoryDir, "last-intent.yaml")) || {}; + const selectedSkills = readYaml(path.resolve(runDir, "selected-skills.yaml")) || {}; + const findings = Array.isArray(findingsData.findings) ? findingsData.findings : []; + const suppressions = Array.isArray(suppressionsData.rules) ? suppressionsData.rules : []; + const selectedGroups = Array.isArray(intent.selected_groups) ? intent.selected_groups : []; + const loaded = loadEnrichment({ memoryDir, runId, enrichmentPath }); + const runDate = String(runId || "").slice(0, 10); + return { + payload: { + meta: { + run_id: runId, + generated_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + project_root: projectRoot ? path.resolve(projectRoot) : null, + intent_slug: intentSlug(intent), + report_mode: loaded.mode, + enrichment_path: loaded.path, + enrichment_warnings: loaded.warnings, + }, + intent, + selected_groups: selectedGroups, + skipped_groups: ALL_GROUPS.filter((group) => !selectedGroups.includes(group)), + selected_skills: selectedSkills, + counts: { + total: findings.length, + new_this_run: findings.filter((finding) => String(finding?.first_seen || "").startsWith(runDate)).length, + by_severity: bucketSeverity(findings), + by_state: bucketState(findings), + suppression_rules: suppressions.length, + }, + findings, + suppressions, + preferences, + enrichment: loaded.enrichment, + top_recommendation: deriveTopRecommendation(findings, intent), + }, + loaded, + }; +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderHtml(payload) { + const counts = payload.counts; + let projectLabel = payload.meta.project_root || "(unknown project)"; + if (projectLabel.length > 80) { + projectLabel = `…${projectLabel.slice(-79)}`; + } + const json = JSON.stringify(payload).replace(/<\/script/gi, "<\\/script"); + const replacements = { + RUN_ID: escapeHtml(payload.meta.run_id), + GENERATED_AT: escapeHtml(payload.meta.generated_at), + PROJECT_LABEL: escapeHtml(projectLabel), + INTENT_SLUG: escapeHtml(payload.meta.intent_slug), + REPORT_MODE: escapeHtml(payload.meta.report_mode), + TOTAL_FINDINGS: String(counts.total), + NEW_FINDINGS: String(counts.new_this_run), + CRITICAL_COUNT: String(counts.by_severity.critical), + HIGH_COUNT: String(counts.by_severity.high), + MEDIUM_COUNT: String(counts.by_severity.medium), + LOW_COUNT: String(counts.by_severity.low), + TOP_RECOMMENDATION: escapeHtml(payload.top_recommendation?.summary || "—"), + REPORT_DATA_JSON: json, + }; + let html = fs.readFileSync(TEMPLATE_PATH, "utf8"); + for (const [name, value] of Object.entries(replacements)) { + html = html.replaceAll(`{{${name}}}`, value); + } + return html; +} + +function atomicWrite(filePath, content) { + const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(temporaryPath, content, "utf8"); + try { + fs.renameSync(temporaryPath, filePath); + } catch (error) { + if (process.platform === "win32" && fs.existsSync(filePath)) { + fs.rmSync(filePath); + fs.renameSync(temporaryPath, filePath); + } else { + throw error; + } + } +} + +export function generateHtmlReport({ memoryDir, runId, outputDir, projectRoot, enrichmentPath } = {}) { + const { payload, loaded } = buildPayload({ memoryDir, runId, projectRoot, enrichmentPath }); + const html = renderHtml(payload); + const resolvedOutputDir = path.resolve(outputDir); + fs.mkdirSync(resolvedOutputDir, { recursive: true }); + const versionedPath = path.join(resolvedOutputDir, `${runId}-${payload.meta.intent_slug}.html`); + const latestPath = path.join(resolvedOutputDir, "latest.html"); + atomicWrite(versionedPath, html); + atomicWrite(latestPath, html); + return { + ok: true, + mode: loaded.mode, + failures: [], + warnings: loaded.warnings, + versionedPath, + latestPath, + reportPath: versionedPath, + payload, + }; +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/assessment/scripts/assess-report.test.mjs b/plugins/github-copilot-modernization/skills/assessment/scripts/assess-report.test.mjs new file mode 100644 index 0000000..e98bf31 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/assessment/scripts/assess-report.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { generateHtmlReport, loadEnrichment, validateEnrichment } from "./assess-report.mjs"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(scriptsDir, "..", "..", ".."); +const fixtureDir = path.join(repositoryRoot, "tests", "assessment", "fixtures", "acme-orders", ".memory"); +const runId = "2026-05-20T14-22-11Z"; + +function copyFixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "assess-report-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const memoryDir = path.join(root, ".memory"); + fs.cpSync(fixtureDir, memoryDir, { recursive: true }); + return { root, memoryDir }; +} + +test("fixture enrichment validates as ai-narrated", (t) => { + const { memoryDir } = copyFixture(t); + const loaded = loadEnrichment({ memoryDir, runId }); + const result = validateEnrichment({ memoryDir, runId }); + + assert.equal(loaded.mode, "ai-narrated"); + assert.equal(loaded.enrichment.version, 1); + assert.equal(result.ok, true, result.failures.join("\n")); + assert.equal(result.mode, "ai-narrated"); + assert.deepEqual(result.failures, []); +}); + +test("generated fixture report embeds a complete payload", (t) => { + const { root, memoryDir } = copyFixture(t); + const outputDir = path.join(root, "reports"); + const result = generateHtmlReport({ memoryDir, runId, outputDir, projectRoot: root }); + const html = fs.readFileSync(result.versionedPath, "utf8"); + + assert.equal(result.mode, "ai-narrated"); + assert.ok(html.length > 10_000); + assert.ok(fs.existsSync(result.latestPath)); + assert.match(html, / + + + + diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/SKILL.md b/plugins/github-copilot-modernization/skills/batch-modernization/SKILL.md new file mode 100644 index 0000000..3b05ce3 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/SKILL.md @@ -0,0 +1,147 @@ +--- +name: batch-modernization +description: Deterministic local control-plane utilities for parsing, preflighting, validating, and tracking multi-repository modernization batches. +user-invocable: false +--- + +# Batch Modernization Control Plane + +This skill owns deterministic batch configuration and state operations. It does not assess source code, generate plans, execute migrations, or invoke MCP tools. + +Batch Assessment provides a config-aware multi-repository workflow. `modernize` is the only public entry. Every new request uses the read-only default-config probe first. When `.github/modernize/repos.json` exists, the first question always asks Batch or Single, regardless of scope wording; when absent, explicit scope applies and otherwise classic Single remains the default. Configuration discovery never selects Batch or approves execution. Planning, Execution, retry, and takeover scheduling remain disabled. Use only the scripts under `scripts/`; never edit batch state files manually. + +## Hard Boundaries + +- Deterministic scripts do not invoke agents, MCP tools, Assessment, Planning, or Execution. The internal coordinator owns phase-agent dispatch. +- Do not modify application source or existing Git worktrees. +- Do not stash, switch branches, fetch, rewrite origins, discard changes, push, or create commits. +- Keep credentials out of command-line arguments and persisted files. +- Treat unknown schema versions, malformed inputs, path escapes, wrong owner tokens, and invalid evidence as blocking errors. +- A takeover lease is read-only. It cannot mutate batch state or schedule work until worker fencing exists. +- Batch Assessment starts only attempt 1 for a pending execution unit and permits only one active phase invocation across the batch. + +## Inputs + +- `config-path`: Absolute or workspace-relative path to `repos.json`. +- `launch-root`: Absolute directory that owns `.github/modernize/batches/` and the default `repos/` clone root. +- `allowed-roots`: Canonical paths already authorized by the host. Supply each separately to workspace inspection. +- `batch-root`: `.github/modernize/batches/` under the launch root. +- `batch-runtime`: For Review, derive `scripts/prepare-review.mjs` from the absolute loaded `batch-modernization` skill path. Review materializes a batch-private runtime under `/runtime` and returns its exact digest-bound `batchAttemptScriptPath`; use only that path afterward. Do not depend on a plugin-root environment variable across resumed host processes. + +Read [references/repos-json-compatibility.md](references/repos-json-compatibility.md) before resolving configuration and [references/phase-contract.md](references/phase-contract.md) before creating state or validating a phase result. + +## 0. Probe The Default Configuration + +For every new top-level request, the internal `batch-mode-probe` checks the fixed default path without reading the file, loading this skill, installing runtime, or creating artifacts. `found` requires `modernize` to ask **Process repositories from repos.json** or **Only process the current repository**. Only the first choice permits Batch Review; neither choice is execution approval. + +## 1. Resolve Configuration + +```powershell +node /scripts/resolve-repos.mjs ` + --config ` + --launch-root ` + --output /resolved-repos.json +``` + +The resolver accepts v1/v2, assigns stable repository and execution-unit IDs, preserves safe unknown metadata, strips URL secrets, and rejects invalid names, path collisions, unsafe include paths, branches, and app references. Do not proceed after a nonzero exit. + +## 2. Clone Missing URL Repositories + +Clone only repositories approved in the Review. Pass the original URL through the process environment, never an argument or persisted intermediate file: + +```powershell +$env:BATCH_CLONE_URL = +node /scripts/inspect-workspaces.mjs clone ` + --target ` + --allowed-root ` + --branch +Remove-Item Env:BATCH_CLONE_URL +``` + +The script strips HTTP credentials/query/fragment before invoking Git, clones into a unique temporary sibling, and publishes with an atomic rename. Failure removes the temporary directory and leaves the target absent. + +## 3. Inspect Workspaces + +```powershell +node /scripts/inspect-workspaces.mjs inspect ` + --resolved /resolved-repos.json ` + --allowed-root ` + --allowed-root ` + --output /inspected-repos.json +``` + +Inspection resolves real paths, rejects symlink/junction escapes, detects supported project languages, and checks Git origin, branch, and dirty state without modifying the repository. `needs_attention` requires a batch-level decision; `blocked` cannot run. + +## 4. Initialize Batch State + +Initialize Batch Assessment from the post-clone inspected repositories and the digest-bound approved Review. The input file contains only `userRequest`: + +```powershell +node initialize-assessment ` + --batch-root ` + --resolved ` + --review ` + --review-sha256 ` + --review-markdown-sha256 ` + --input +``` + +The initializer verifies Review identity and artifact digests, then derives the batch ID, execution-unit selection, attention approvals, and Assessment decisions from `review.json`. Blocked, substituted, or unapproved items fail closed. Preserve the digest-bound pre-clone `inspected-repos.json`; write reinspection output to a separate file. + +## 5. Open A Lease Session + +```powershell +$session = node open-session ` + --batch-root ` + --invocation-id ` + --execution-unit-id | ConvertFrom-Json +``` + +`open-session` starts the first unit and returns its `requestPath` plus a random `leaseSessionId`. The private local worker keeps the raw owner token only in memory. The token is never returned, logged, written to disk, placed in a terminal environment, or sent to a phase agent. Use the session ID only for coordinator lifecycle commands and never include it in the phase prompt. + +## 6. Takeover Is Read-Only + +A stale lease may be inspected or explicitly taken over for read-only inspection, but `schedulingAllowed` remains false. Do not start a new attempt after takeover. + +## 7. Validate Attempt Results + +`validate-result.mjs` checks schema, identity, status/payload consistency, secret safety, canonical artifact containment, artifact existence, and Assessment evidence. Any failure returns `protocol_error`; never convert it to success based on agent prose. + +## 8. Batch Assessment Attempt Lifecycle + +`open-session` starts the first pending unit. Start each later unit through the same lease session: + +```powershell +node session-start ` + --lease-session-id ` + --execution-unit-id +``` + +The command writes immutable `request.json`, persists Running before dispatch, and returns the request path. Pass only that path to `batch-assessment`. + +The phase agent publishes exactly one identity-bound result without an owner token: + +```powershell +node publish ` + --request ` + --outcome +``` + +After the phase invocation returns, commit the result regardless of its natural-language return: + +```powershell +node session-commit ` + --lease-session-id ` + --request +``` + +Missing, malformed, mismatched, or unsupported evidence becomes `protocol_error`. Once all units are terminal, write the aggregate summary and release the lease: + +```powershell +node session-finalize-assessment ` + --lease-session-id +``` + +## Completion + +Batch Assessment completion means an explicitly approved set of execution units can run sequential attempts, preserve the verified canonical repository reports, and atomically publish a user-facing `assessment/reports-/` tree containing `index.html`, `aggregate-report.json`, and digest-identical per-repository snapshots. Internal batch state remains under `batches//`. It does not authorize Planning or Execution, retry terminal units, or schedule work after takeover. Single-repository defaults and artifacts remain unchanged. \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/batch-modernization.test.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/batch-modernization.test.mjs new file mode 100644 index 0000000..cfa0ea7 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/batch-modernization.test.mjs @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const skillRoot = path.dirname(fileURLToPath(import.meta.url)); +const pluginRoot = path.resolve(skillRoot, "..", ".."); +const thisFile = fileURLToPath(import.meta.url); +const require = createRequire(import.meta.url); +const { renderAgentContent } = require(path.join(pluginRoot, "scripts", "render-agent-platform.js")); + +function walkFiles(root) { + return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(root, entry.name); + return entry.isDirectory() ? walkFiles(entryPath) : [entryPath]; + }); +} + +test("Batch Assessment retains the complete deterministic control-plane surface", () => { + const expected = [ + "SKILL.md", + "references/phase-contract.md", + "references/repos-json-compatibility.md", + "schemas/aggregate-report.v1.json", + "schemas/assessment-finalization.v1.json", + "schemas/attempt-validation.v1.json", + "schemas/normalized-assessment.v1.json", + "schemas/attempt-request.schema.json", + "schemas/attempt-result.schema.json", + "schemas/batch-state.schema.json", + "schemas/event.schema.json", + "schemas/execution-unit.schema.json", + "schemas/needs-input.schema.json", + "schemas/resolved-repos.schema.json", + "scripts/batch-state.mjs", + "scripts/batch-attempt.mjs", + "scripts/batch-assessment-report.mjs", + "scripts/inspect-workspaces.mjs", + "scripts/probe-default-config.mjs", + "scripts/prepare-review.mjs", + "scripts/resolve-repos.mjs", + "scripts/schema-validator.mjs", + "scripts/validate-result.mjs", + ]; + for (const relativePath of expected) { + assert.equal(fs.existsSync(path.join(skillRoot, ...relativePath.split("/"))), true, relativePath); + } + const skill = fs.readFileSync(path.join(skillRoot, "SKILL.md"), "utf8"); + assert.match(skill, /^name: batch-modernization$/m); + assert.match(skill, /^user-invocable: false$/m); +}); + +test("deterministic batch utilities contain no business-agent or AppMod tool dependency", () => { + const files = walkFiles(skillRoot).filter((filePath) => + filePath !== thisFile && [".js", ".json", ".md", ".mjs"].includes(path.extname(filePath))); + const violations = files.flatMap((filePath) => { + const content = fs.readFileSync(filePath, "utf8"); + return /appmod-|mcpServers|batch-coordinator\.agent|batch-assessment\.agent/i.test(content) + ? [path.relative(skillRoot, filePath)] + : []; + }); + assert.deepEqual(violations, []); +}); + +function frontmatterTools(content) { + const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ""; + const tools = frontmatter.match(/^tools:\r?\n((?: - [^\r\n]+\r?\n?)*)/m)?.[1] ?? ""; + return [...tools.matchAll(/^ - ([^\r\n]+)$/gm)].map((match) => match[1]); +} + +test("Batch Assessment routes default config through a top-level mode choice", () => { + const modernizePath = path.join(pluginRoot, "agents", "modernize.agent.md"); + const probePath = path.join(pluginRoot, "agents", "batch-mode-probe.agent.md"); + const reviewPath = path.join(pluginRoot, "agents", "batch-review.agent.md"); + const coordinatorPath = path.join(pluginRoot, "agents", "batch-coordinator.agent.md"); + const assessmentPath = path.join(pluginRoot, "agents", "batch-assessment.agent.md"); + const modernize = renderAgentContent(fs.readFileSync(modernizePath, "utf8"), "plugin"); + const probe = fs.readFileSync(probePath, "utf8"); + const review = fs.readFileSync(reviewPath, "utf8"); + const coordinator = fs.readFileSync(coordinatorPath, "utf8"); + const assessment = fs.readFileSync(assessmentPath, "utf8"); + + assert.match(modernize, /Workspace Mode Selection And Batch Assessment/); + assert.match(modernize, /`modernize` is the default conversational entry point/); + assert.match(modernize, /Dedicated workflows may select `modernize-java-assessment`, `modernize-azure-java`, `modernize-azure-dotnet`, `modernize-java-upgrade`, `modernize-java-security`, `modernize-deployment`, `modernize-rearchitecture`, or `modernize-websphere-standalone` directly/); + assert.match(modernize, /All other agents remain internal implementation details/); + assert.match(modernize, /internal agent type `github-copilot-modernization:batch-mode-probe`/); + assert.match(modernize, /probe is mandatory even when the original request says current repository, single repository, multiple repositories, Batch, or `repos\.json`/); + assert.match(modernize, /`status: absent`.*classic Single mode/s); + assert.match(modernize, /`status: found`.*invoke `#vscode\/askQuestions` in VS Code, or `#ask_user`/s); + assert.match(modernize, /enum values exactly \*\*Process repositories from repos\.json\*\* and \*\*Only process the current repository\*\*/); + assert.match(modernize, /first user-visible question for the request/); + assert.match(modernize, /even if the original request explicitly mentioned Batch or the current repository/); + assert.match(modernize, /Headless execution must stop here rather than choosing silently/); + assert.match(modernize, /Headless never bypasses the mandatory workspace-mode probe or a found-config Batch\/Single choice/); + assert.match(modernize, /never bypasses Batch Review or the separate exact \*\*Start batch\*\* approval/); + assert.match(modernize, /Single choice immediately resumes the original request through the unchanged classic Single routes/); + assert.match(modernize, /must not invoke any batch Review, coordinator, or phase agent/); + assert.match(modernize, /explicit scope wording in the original request cannot override this choice/); + assert.match(modernize, /Batch choice selects Batch mode but does not approve execution/); + assert.match(modernize, /default `\.github\/modernize\/repos\.json` must trigger the mode question for every new request/); + assert.match(modernize, /existence never silently selects Batch and never starts execution/); + assert.match(modernize, /Batch mode \+ any other action/); + assert.match(modernize, /stop without tools or delegation/); + assert.match(modernize, /immediate next action after a valid Review is to invoke the top-level question tool/); + assert.match(modernize, /enum values are exactly \*\*Start batch\*\* and \*\*Cancel\*\*/); + assert.match(modernize, /Do not emit text asking the user to reply, choose, or confirm/); + assert.match(modernize, /If and only if the top-level host exposes neither `vscode\/askQuestions` nor `ask_user`/); + assert.match(modernize, /\{"mode":"explicit-follow-up","value":"Process repositories from repos\.json","configPath":""\}/); + assert.match(modernize, /\{"mode":"structured","value":"Start batch","accepted":true\}/); + assert.match(modernize, /\{"mode":"explicit-follow-up","value":"Start batch","entireUserTurn":"Start batch","immediatelyAfterReview":true\}/); + assert.match(modernize, /current host does not expose a question tool inside a nested agent invocation/); + assert.match(modernize, /Do not reconstruct or require the Review Markdown inside the coordinator prompt/); + assert.match(modernize, /immediate next tool action delegates exactly once to `batch-coordinator`/); + assert.match(modernize, /Never use background mode/); + assert.match(modernize, /Do not invoke either internal agent outside this sequence/); + assert.match(modernize, /For classic Single mode only, before delegating to `assessment-coordinator`/); + assert.match(modernize, /does not apply to the Batch Assessment sequence/); + assert.deepEqual(frontmatterTools(modernize), ["agent", "vscode/askQuestions", "ask_user"]); + assert.match(probe, /^user-invocable: false$/m); + assert.match(review, /^user-invocable: false$/m); + assert.match(coordinator, /^user-invocable: false$/m); + assert.match(assessment, /^user-invocable: false$/m); + assert.match(coordinator, /agents:\s*\n\s+- batch-assessment/); + + const reviewTools = frontmatterTools(review); + const probeTools = frontmatterTools(probe); + assert.deepEqual(probeTools, ["execute/runInTerminal"]); + assert.match(probe, /immediate next and only tool action/); + assert.match(probe, /must not depend on a plugin-root environment variable/); + assert.match(probe, /\.github\/modernize\/repos\.json/); + assert.match(probe, /ConvertTo-Json -Compress/); + assert.match(probe, /Never use a literal ``/); + assert.match(probe, /never retry/); + assert.match(probe, /Do not read or parse `repos\.json`/); + assert.match(modernize, /forbidden to invoke `batch-mode-probe` for that exact choice turn/); + const coordinatorTools = new Set(frontmatterTools(coordinator)); + const assessmentTools = frontmatterTools(assessment); + assert.deepEqual(reviewTools, ["skill", "execute/runInTerminal"]); + assert.equal(reviewTools.includes("ask_user"), false); + assert.equal(reviewTools.includes("agent"), false); + assert.equal(coordinatorTools.has("ask_user"), false); + assert.equal(assessmentTools.includes("ask_user"), false); + assert.equal(assessmentTools.includes("agent"), false); + assert.equal(assessmentTools.every((tool) => coordinatorTools.has(tool)), true); + assert.match(review, /Never call, request, or imitate `ask_user`/); + assert.match(review, /Never initialize batch state, acquire a lease/); + assert.match(review, /immediate next and only tool action is one foreground terminal command invoking the absolute skill-owned `prepare-review\.mjs`/); + assert.match(review, /Never guess a plugin installation root or read `CLAUDE_PLUGIN_ROOT`, `COPILOT_PLUGIN_ROOT`, or `PLUGIN_ROOT`/); + assert.match(review, /return its stdout verbatim with no preface or suffix/); + assert.match(review, /Never improvise or reconstruct a handoff/); + assert.match(review, /BATCH_REVIEW_READY/); + assert.match(review, /reviewPath: /); + assert.match(review, /reviewSha256: <64 lowercase hex characters>/); + assert.match(review, /inspectedReposSha256: <64 lowercase hex characters>/); + assert.match(coordinator, /Continue only when its mode is exactly `structured` or `explicit-follow-up` and its value is exactly \*\*Start batch\*\*/); + assert.match(coordinator, /This is an executable internal agent, not an advisory agent/); + assert.match(coordinator, /Never claim that the user must run the coordinator loop/); + assert.match(coordinator, /Do not end the turn with instructions for another host or person/); + assert.match(assessment, /Explicitly bootstrap the target workspace on every invocation/); + assert.match(assessment, /load the `assessment` skill in `batch-headless` mode/); + assert.match(assessment, /optional `targetRuntime`, `targetComputeServices`, `enableContainerization`, `targetOS`, `minimumCveSeverity`, and `cveScanScope`/); + assert.match(assessment, /Execute every task yourself, serially, in catalog order/); + assert.match(assessment, /For full coverage, archive facts/); + assert.match(coordinator, /Do not require Review text in the prompt/); + assert.match(coordinator, /Treat `review\.json`, `REVIEW\.md`, and `inspected-repos\.json` as opaque inputs to `initialize-assessment`/); + assert.match(coordinator, /The deterministic initializer is the sole authority/); + assert.doesNotMatch(coordinator, /Read `review\.json` as the stable Review authority/); + assert.match(coordinator, /Text in the original request is never approval/); + assert.match(coordinator, /field-incomplete `scope-evidence`/); + assert.match(coordinator, /both string fields must be exact \*\*Start batch\*\*/); + assert.match(coordinator, /Do not call or request `ask_user`; nested agents do not receive that host tool/); + assert.match(coordinator, /Do not create `selection\.json`, copy decisions into the input/); + assert.match(coordinator, /derives `batchId`, selected execution units, attention approvals, and decisions directly from `review\.json`/); + assert.match(coordinator, /call ` open-session` exactly once/); + assert.match(coordinator, /Never read or interpolate `CLAUDE_PLUGIN_ROOT`, `COPILOT_PLUGIN_ROOT`, or `PLUGIN_ROOT`/); + assert.match(coordinator, /retains the raw owner token only in worker memory/); + assert.match(coordinator, /raw owner token must never leave the lease-session worker/); + assert.match(coordinator, /Every coordinator terminal command must be finite, foreground, and synchronous/); + assert.match(coordinator, /Never add a keeper loop \(`while \(\$true\)`, `Start-Sleep`, or equivalent\), run the terminal command itself in async\/background mode/); + assert.match(coordinator, /`lease\.json` contains only a digest and can never reconstruct the token/); + assert.match(coordinator, /Never acquire a second lease, attempt takeover, delete or edit `lease\.json`, `state\.json`, `events\.jsonl`, or `attempts\/`/); + assert.match(coordinator, /Do not release it while a child invocation is active/); + assert.match(coordinator, /session-finalize-assessment/); + assert.match(coordinator, /paths\.reportIndex.*primary, clickable Assessment result/s); + assert.match(coordinator, /do not make the user navigate through `\.github\/modernize\/batches\/\/`/); + assert.match(coordinator, /exact host agent type `github-copilot-modernization:batch-assessment`/); + assert.match(coordinator, /Never invoke `general-purpose`, `task`, or another built-in agent type and merely name it `batch-assessment`/); + assert.match(coordinator, /Invoke exactly once for this immutable `requestPath`/); + assert.match(coordinator, /failed host call as an uncounted loading attempt/); + assert.match(coordinator, /Missing output becomes ProtocolError; do not retry first/); + assert.match(assessment, /authorized and required to run the request-bound Node commands that create or update generated Assessment runtime and report artifacts/); + assert.match(assessment, /Never manually delete, rename, replace, or edit an existing `\.github` path/); + assert.match(assessment, /node bootstrap/); + assert.match(assessment, /--attempt-scratch-root/); + assert.match(assessment, /--run-id /); + assert.match(assessment, /--language /); + assert.match(assessment, /--max-concurrency/); + assert.match(assessment, /"status": "completed",\s+"artifacts":/s); + assert.match(assessment, /"needsInput": null/); + assert.match(assessment, /boolean `retryable`/); + assert.match(assessment, /resolve `\.\.\/\.\.\/batch-modernization\/scripts\/batch-attempt\.mjs`/); + assert.match(assessment, /node publish/); + assert.doesNotMatch(assessment, /node \/skills\/batch-modernization\/scripts\/batch-attempt\.mjs publish/); +}); \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/fragments/guard-features.txt b/plugins/github-copilot-modernization/skills/batch-modernization/fragments/guard-features.txt new file mode 100644 index 0000000..1179588 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/fragments/guard-features.txt @@ -0,0 +1,398 @@ +// BEGIN PLATFORM GUARD EXTENSION CONSTANTS +const BATCH_AGENT_SUFFIXES = ["batch-mode-probe", "batch-review", "batch-coordinator"]; +for (const agentName of BATCH_AGENT_SUFFIXES) { + ALLOWED_ROUTER_AGENTS.add(agentName); +} +const STRUCTURED_APPROVAL = '{"mode":"structured","value":"Start batch","accepted":true}'; +const FOLLOW_UP_APPROVAL = '{"mode":"explicit-follow-up","value":"Start batch","entireUserTurn":"Start batch","immediatelyAfterReview":true}'; +const WORKSPACE_MODE_CHOICES = new Set([ + "Process repositories from repos.json", + "Only process the current repository", +]); +// END PLATFORM GUARD EXTENSION CONSTANTS + +// BEGIN PLATFORM GUARD EXTENSION OUTPUT +function postToolResult(additionalContext, block = false) { + const result = { + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext, + }, + }; + if (block) { + result.decision = "block"; + result.reason = additionalContext; + } + return result; +} +// END PLATFORM GUARD EXTENSION OUTPUT + +// BEGIN PLATFORM GUARD EXTENSION PROBE +function defaultConfigProbe(input) { + const launchRoot = input.cwd ?? input.working_directory ?? input.workingDirectory; + if (typeof launchRoot !== "string" || !path.isAbsolute(launchRoot)) { + throw new Error("the hook received no absolute launch root"); + } + const resolvedLaunchRoot = path.resolve(launchRoot); + const launchStat = fs.statSync(resolvedLaunchRoot, { throwIfNoEntry: false }); + if (!launchStat?.isDirectory()) { + throw new Error("the launch root is not an existing directory"); + } + const configPath = path.join(resolvedLaunchRoot, ".github", "modernize", "repos.json"); + const configStat = fs.statSync(configPath, { throwIfNoEntry: false }); + return { + schemaVersion: 1, + launchRoot: resolvedLaunchRoot, + configPath, + status: configStat?.isFile() ? "found" : configStat ? "invalid" : "absent", + }; +} + +function findProbeResult(value) { + const queue = [value]; + const visited = new Set(); + while (queue.length > 0) { + const current = queue.shift(); + if (current && typeof current === "object") { + if (visited.has(current)) continue; + visited.add(current); + if (Object.hasOwn(current, "schemaVersion") + && Object.hasOwn(current, "launchRoot") + && Object.hasOwn(current, "configPath") + && Object.hasOwn(current, "status")) { + return current; + } + queue.push(...Object.values(current)); + continue; + } + if (typeof current !== "string") continue; + try { + const parsed = JSON.parse(current); + if (parsed !== current) queue.push(parsed); + } catch {} + for (const candidate of current.match(/\{[^{}\r\n]*\}/g) ?? []) { + try { + queue.push(JSON.parse(candidate)); + } catch {} + } + } + return null; +} + +function comparablePath(value) { + if (typeof value !== "string" || !path.isAbsolute(value)) return null; + const normalized = path.normalize(value); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function sameProbeResult(actual, expected) { + return actual?.schemaVersion === expected.schemaVersion + && comparablePath(actual.launchRoot) === comparablePath(expected.launchRoot) + && comparablePath(actual.configPath) === comparablePath(expected.configPath) + && actual.status === expected.status; +} + +function isWorkspaceModeQuestion(toolInput) { + const questions = Array.isArray(toolInput.questions) ? toolInput.questions : []; + const labels = questions.flatMap((question) => + (Array.isArray(question?.options) ? question.options : []) + .map((option) => String(option?.label ?? option?.value ?? option))); + return labels.length === WORKSPACE_MODE_CHOICES.size + && labels.every((label) => WORKSPACE_MODE_CHOICES.has(label)); +} + +function evaluateWorkspaceModeQuestion(input, toolInput) { + if (!isWorkspaceModeQuestion(toolInput)) return null; + let probe; + try { + probe = defaultConfigProbe(input); + } catch (error) { + return decision( + "deny", + `Workspace Mode cannot be verified because ${error.message}. Do not ask this question; stop with a compact probe error.`, + ); + } + if (probe.status === "absent") { + return decision( + "deny", + `Workspace Mode is not applicable because the default config is absent at ${probe.configPath}. Treat the probe as status absent and resume the original request through the absent-config routing rules without asking this question.`, + ); + } + if (probe.status === "invalid") { + return decision( + "deny", + `The default Batch config path is invalid because it is not a file: ${probe.configPath}. Stop with a compact configuration error.`, + ); + } + return null; +} +// END PLATFORM GUARD EXTENSION PROBE + +// BEGIN PLATFORM GUARD EXTENSION APPROVAL +function hasHandoffField(prompt, field) { + const name = field.slice(0, -1); + return prompt.includes(field) || new RegExp(`"${name}"\\s*:`).test(prompt); +} + +function hasApprovedReviewHandoff(prompt) { + const requiredFields = [ + "BATCH_REVIEW_READY", + "batchRoot:", + "reviewPath:", + "reviewMarkdownPath:", + "reviewSha256:", + "reviewMarkdownSha256:", + "inspectedReposPath:", + "inspectedReposSha256:", + "batchAttemptScriptPath:", + "configSha256:", + "selectedExecutionUnitIds:", + "approvedNeedsAttention:", + "effectiveAssessments:", + "blockedExecutionUnits:", + "analysisCoverage:", + "maxConcurrency:", + ]; + const hasApproval = prompt.includes(STRUCTURED_APPROVAL) || prompt.includes(FOLLOW_UP_APPROVAL); + return hasApproval + && prompt.includes("BATCH_REVIEW_READY") + && requiredFields.slice(1).every((field) => hasHandoffField(prompt, field)); +} + +function topLevel(event) { + return event?.agentId === undefined || event.agentId === null; +} +// END PLATFORM GUARD EXTENSION APPROVAL + +// BEGIN PLATFORM GUARD EXTENSION FALLBACK +function normalizeFallbackCoordinatorInput(input, toolInput) { + const events = transcriptEvents(input); + let approvalIndex = -1; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type === "user.message" && topLevel(event)) { + const content = String(event?.data?.content ?? "").trim(); + if (content === "Start batch") { + approvalIndex = index; + } + break; + } + } + if (approvalIndex < 0) { + return null; + } + + let reviewIndex = -1; + let reviewResult = ""; + for (let index = approvalIndex - 1; index >= 0; index -= 1) { + const event = events[index]; + const content = String(event?.data?.result?.content ?? ""); + if (event?.type === "tool.execution_complete" && topLevel(event) && content.includes("BATCH_REVIEW_READY")) { + reviewIndex = index; + reviewResult = content; + break; + } + } + if (reviewIndex < 0) { + return null; + } + + let selectionIndex = -1; + for (let index = reviewIndex - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type === "user.message" + && topLevel(event) + && String(event?.data?.content ?? "").trim() === "Process repositories from repos.json") { + selectionIndex = index; + break; + } + } + if (selectionIndex < 0) { + return null; + } + + const originalRequest = events + .slice(0, selectionIndex) + .findLast((event) => event?.type === "user.message" && topLevel(event) && typeof event?.data?.content === "string") + ?.data?.content; + const launchRoot = input.cwd ?? input.working_directory ?? input.workingDirectory; + if (typeof originalRequest !== "string" || typeof launchRoot !== "string" || !path.isAbsolute(launchRoot)) { + return null; + } + + const scopeEvidence = JSON.stringify({ + mode: "explicit-follow-up", + value: "Process repositories from repos.json", + configPath: path.join(launchRoot, ".github", "modernize", "repos.json"), + }); + const prompt = [ + `launch-root: ${launchRoot}`, + `user-request: ${JSON.stringify(originalRequest)}`, + `scope-evidence: ${scopeEvidence}`, + "batch-review-handoff:", + reviewResult, + `approval-evidence: ${FOLLOW_UP_APPROVAL}`, + ].join("\n"); + return { ...toolInput, prompt }; +} +// END PLATFORM GUARD EXTENSION FALLBACK + +// BEGIN PLATFORM GUARD EXTENSION PRODUCT BOUNDARY + const serialized = serializedToolInput(toolInput); + const readsConfiguration = /(?:^|\\)\.github\\modernize\\repos\.json/.test(serialized); + if (readsConfiguration && ["view", "read", "grep", "search"].includes(toolName)) { + return decision( + "deny", + "Do not read repos.json directly. Delegate the pending Batch selection to github-copilot-modernization:batch-review.", + ); + } + + const readsReview = serialized.includes("\\.github\\modernize\\batches\\") + && (serialized.includes("\\review.json") || serialized.includes("\\review.md")); + if (readsReview && ["view", "read", "grep", "search"].includes(toolName)) { + return decision( + "deny", + "Do not re-read or rewrite a completed Batch Review. Present the batch-review response unchanged and use only Start batch or Cancel.", + ); + } + + const writesFiles = ["create", "edit", "apply_patch", "write"].includes(toolName); + const writesWorkaround = serialized.includes("\\.copilot\\batch-review") + || serialized.includes("\\.github\\modernize\\batch-report.json"); + if (writesFiles && writesWorkaround) { + return decision( + "deny", + "Manual Batch Review and aggregate artifacts are forbidden. Use batch-review and the deterministic Batch control plane.", + ); + } + + const terminalTool = ["powershell", "bash", "execute", "runinterminal"].includes(toolName); + if (terminalTool && serialized.includes("\\.copilot\\batch-review")) { + return decision( + "deny", + "The .copilot/batch-review workaround is forbidden. Invoke github-copilot-modernization:batch-review.", + ); + } +// END PLATFORM GUARD EXTENSION PRODUCT BOUNDARY + +// BEGIN PLATFORM GUARD EXTENSION SCOPED GUARDS +function evaluateReviewTool(toolName, toolInput) { + if (toolName === "skill") { + return decision("allow"); + } + const terminalTool = ["powershell", "bash", "execute", "runinterminal"].includes(toolName); + const serialized = serializedToolInput(toolInput); + if (terminalTool && serialized.includes("\\skills\\batch-modernization\\scripts\\prepare-review.mjs")) { + return decision("allow"); + } + return decision( + "deny", + "batch-review may only load its skill and run that skill's deterministic prepare-review.mjs.", + ); +} + +function evaluateCoordinatorTool(toolName, toolInput) { + if (toolName === "skill" || toolName === "todo") { + return decision("allow"); + } + if (toolName === "agent" || toolName === "task") { + return targets(normalizedAgentName(toolInput), "batch-assessment") + ? decision("allow") + : decision("deny", "batch-coordinator may delegate only to github-copilot-modernization:batch-assessment."); + } + const terminalTool = ["powershell", "bash", "execute", "runinterminal"].includes(toolName); + if (terminalTool) { + const serialized = serializedToolInput(toolInput); + const simulatesPhaseResult = serialized.includes("outcome.json") + || serialized.includes("artifactsdir") + || serialized.includes("assessment completed (simulated)") + || /batch-attempt\.mjs["']?\s+publish\b/.test(serialized); + return simulatesPhaseResult + ? decision("deny", "batch-coordinator must never create or publish a phase outcome. Commit the single batch-assessment result as-is; missing output is ProtocolError.") + : decision("allow"); + } + return decision("deny", "batch-coordinator is limited to its skill, exact phase-agent dispatch, and deterministic control-plane commands."); +} +// END PLATFORM GUARD EXTENSION SCOPED GUARDS + +// BEGIN PLATFORM GUARD EXTENSION ASSESSMENT + skipCoverageValidation = /(?:^|\s)--attempt-scratch-root(?:=|\s)/i.test(command) + && /\bbatch-headless\b|\brequest\.json\b/i.test(prompt); +// END PLATFORM GUARD EXTENSION ASSESSMENT + +// BEGIN PLATFORM GUARD EXTENSION SCOPES + if (scope === "review") { + return evaluateReviewTool(toolName, toolInput); + } + if (scope === "coordinator") { + return evaluateCoordinatorTool(toolName, toolInput); + } +// END PLATFORM GUARD EXTENSION SCOPES + +// BEGIN PLATFORM GUARD EXTENSION QUESTION + if (scope === "inline" && (toolName === "ask_user" || toolName === "askuser")) { + const workspaceModeDecision = evaluateWorkspaceModeQuestion(input, toolInput); + if (workspaceModeDecision) return workspaceModeDecision; + } +// END PLATFORM GUARD EXTENSION QUESTION + +// BEGIN PLATFORM GUARD EXTENSION DELEGATION + if (targets(agentName, "batch-coordinator")) { + const normalizedInput = normalizeFallbackCoordinatorInput(input, toolInput); + if (normalizedInput && hasApprovedReviewHandoff(normalizedInput.prompt)) { + return decision("allow", undefined, normalizedInput); + } + if (!hasApprovedReviewHandoff(prompt)) { + return decision( + "deny", + "batch-coordinator requires the complete BATCH_REVIEW_READY handoff and one exact approval-evidence JSON object. Preserve every handoff field in its line format or as a lossless JSON object. Do not rerun or repair the Review; use the Review already returned in the immediately preceding turn or stop.", + ); + } + } + + const batchIntent = /\bbatch\b|repos\.json|multiple repositories|process repositories/i.test(prompt); + const targetsBatchAgent = BATCH_AGENT_SUFFIXES.some((suffix) => targets(agentName, suffix)); + if (scope === "inline" && batchIntent && !targetsBatchAgent) { + return decision( + "deny", + "Batch scope can be delegated only to batch-mode-probe, batch-review, or an approved batch-coordinator invocation.", + ); + } +// END PLATFORM GUARD EXTENSION DELEGATION + +// BEGIN PLATFORM GUARD EXTENSION POST TOOL + const input = parseObject(payload); + const toolName = normalizedToolName(input.tool_name ?? input.toolName ?? input.name ?? ""); + const toolInput = parseObject(input.tool_input ?? input.toolInput ?? input.input ?? input.arguments); + if ((toolName !== "agent" && toolName !== "task") + || !targets(normalizedAgentName(toolInput), "batch-mode-probe")) { + return {}; + } + + let expected; + try { + expected = defaultConfigProbe(input); + } catch (error) { + return postToolResult( + `BATCH_MODE_PROBE_FAILED: ${error.message}. Stop with a compact probe error.`, + true, + ); + } + + const authoritativeContext = `Authoritative batch-mode probe result: ${JSON.stringify(expected)}. Use this object unchanged for workspace-mode routing.`; + const actual = findProbeResult(input.tool_response ?? input.toolResponse ?? input.output); + if (!sameProbeResult(actual, expected)) { + return postToolResult( + `The raw batch-mode-probe response was superseded by the filesystem check. ${authoritativeContext} Treat the authoritative object as the successful probe result and continue without rerunning the probe.`, + ); + } + return postToolResult(authoritativeContext); +// END PLATFORM GUARD EXTENSION POST TOOL + +// BEGIN PLATFORM GUARD EXTENSION RUN SCOPE + if (process.argv.includes("--review")) { + scope = "review"; + } else if (process.argv.includes("--coordinator")) { + scope = "coordinator"; + } +// END PLATFORM GUARD EXTENSION RUN SCOPE \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/fragments/modernize-agents.yml b/plugins/github-copilot-modernization/skills/batch-modernization/fragments/modernize-agents.yml new file mode 100644 index 0000000..b8e3366 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/fragments/modernize-agents.yml @@ -0,0 +1,3 @@ + - batch-mode-probe + - batch-review + - batch-coordinator \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/fragments/modernize-hooks.yml b/plugins/github-copilot-modernization/skills/batch-modernization/fragments/modernize-hooks.yml new file mode 100644 index 0000000..9883fb9 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/fragments/modernize-hooks.yml @@ -0,0 +1,4 @@ + PostToolUse: + - type: command + command: node "$APPMOD_HOOK_SCRIPTS_DIR/guardModernizeDelegation.mjs" --post + windows: "powershell -ExecutionPolicy Bypass -NonInteractive -Command \"& node (Join-Path $env:APPMOD_HOOK_SCRIPTS_DIR 'guardModernizeDelegation.mjs') --post\"" \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/fragments/modernize-instructions.md b/plugins/github-copilot-modernization/skills/batch-modernization/fragments/modernize-instructions.md new file mode 100644 index 0000000..83c536f --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/fragments/modernize-instructions.md @@ -0,0 +1,66 @@ +## Workspace Mode Selection And Batch Assessment + +Before classifying a new scope/action, resolve these pending same-session fallback states in order: + +1. A pending Batch Review approval exists only when your immediately preceding turn presented a valid Batch Review and stopped solely because the top-level host did not expose `ask_user`. In that state only, treat the current top-level user turn as fallback approval when its entire trimmed content is exactly `Start batch` or exactly `Cancel`. Do not run another Review. +2. A pending workspace mode selection exists only when your immediately preceding turn reported a found default `.github/modernize/repos.json`, asked the mode question below, and stopped solely because the top-level host did not expose `ask_user`. In that state only: + - exact `Process repositories from repos.json` selects Batch mode for the original pending request; + - exact `Only process the current repository` selects classic Single mode for the original pending request. + Do not probe again. Continue the original request in the selected mode. For the Batch choice, retain this exact scope evidence object through Review and later coordinator delegation; replace only the path placeholder with the absolute `configPath` from the immediately preceding successful probe: + +```json +{"mode":"explicit-follow-up","value":"Process repositories from repos.json","configPath":""} +``` + +For this exact Batch fallback choice, the next tool target must be exactly `github-copilot-modernization:batch-review`. `batch-coordinator` cannot prepare or repair a Review and is forbidden until a later turn supplies a valid `BATCH_REVIEW_READY` handoff plus exact Start approval. Do not emit preliminary prose before the Review call. + +If the entire current user turn is exactly either mode choice, checking this pending state is mandatory and happens before the “every new request” probe rule. It is forbidden to invoke `batch-mode-probe` for that exact choice turn when the immediately preceding assistant turn presented the mode question. + +Any longer text, a choice embedded in the original request, inferred intent, assistant prose, or a non-adjacent turn is not fallback selection or approval. + +For every new request, determine workspace mode before action routing or honoring scope wording: + +1. Before any action-routing tool, delegate exactly once to the internal agent type `github-copilot-modernization:batch-mode-probe` with only the absolute launch root. Never expose that agent name to the user. This probe is mandatory even when the original request says current repository, single repository, multiple repositories, Batch, or `repos.json`. + - `status: absent` → use explicit scope from the original request when present; explicit Batch scope selects Batch, otherwise continue through classic Single mode without an extra question. + - `status: invalid` or malformed probe output without an authoritative `PostToolUse` replacement → stop with a compact configuration error; do not select a mode. + - An `Authoritative batch-mode probe result` supplied by the `PostToolUse` hook supersedes the raw subagent response. Treat that authoritative object as the successful probe result and route from its `status` without stopping or rerunning the probe. + - `status: found` → ignore scope wording until the user chooses. Your immediate next action must be the top-level question tool: invoke `#vscode/askQuestions` in VS Code, or `#ask_user` in a host that exposes only that alias. Ask one required question with enum values exactly **Process repositories from repos.json** and **Only process the current repository**. This must be the first user-visible question for the request, even if the original request explicitly mentioned Batch or the current repository. + - Only if the host exposes neither `vscode/askQuestions` nor `ask_user`, present the same two exact choices and stop. A fresh immediately following turn may use the pending fallback above. Headless execution must stop here rather than choosing silently. +2. A structured or exact-fallback Batch choice selects Batch mode but does not approve execution. A Single choice immediately resumes the original request through the unchanged classic Single routes and must not invoke any batch Review, coordinator, or phase agent. The explicit scope wording in the original request cannot override this choice. Normalize a structured Batch choice to exactly `{"mode":"structured","value":"Process repositories from repos.json","configPath":""}`. Do not summarize, rename, or omit any scope-evidence field. + +Mode selection is local and final for the request. It does not install generated runtime; the probe checks only whether the fixed default path is a file. Single Assessment and Batch Review materialize their own runtime on demand after routing. The probe never reads `repos.json`, creates a Review, or inspects repositories. Do not call web, documentation, MCP, repository tools, or a phase coordinator before mode selection completes. + +After mode selection, classify the requested action: + +1. **Batch mode + Assessment:** run the approval sequence below. Do not create a todo, query or update session history, load a skill, or call repository, web, MCP, or phase tools anywhere in this sequence. +2. **Batch mode + any other action:** stop without tools or delegation and return: `Batch mode supports Assessment only. Batch Planning, Execution, upgrade, migration, security remediation, and full modernization are not available. No action was taken.` +3. **Single mode:** continue through the existing single-repository routes unchanged. + +Use this exact Batch Assessment foreground sequence: + +1. Your immediate next tool action must delegate exactly once to the internal `batch-review` with the launch root, original request, explicit config path when supplied, scope evidence when Batch mode came from the mode question, and normalized proposed Assessment decisions. For “cloud readiness”, pass domain `cloud-readiness`; for unspecified domains omit domains so batch-review applies the Single default separately to each execution unit. Preserve every explicit Single Assessment option (`targetRuntime`, `targetComputeServices`, `enableContainerization`, `targetOS`, `minimumCveSeverity`, and `cveScanScope`) without inventing omitted values. It performs read-only preflight and must return a user-visible Review plus a compact handoff containing absolute digest-bound `reviewPath`, `reviewMarkdownPath`, and `inspectedReposPath`, `batchRoot`, `batchAttemptScriptPath`, selected execution-unit IDs, approved attention IDs, effective assessments, blockers, and proposed Assessment decisions. Never use background mode. Emitting ordinary prose, asking Start/Cancel, or ending the turn before this tool result is a ProtocolError. +2. If the review invocation returns `BATCH_REVIEW_BLOCKED`, present that Review and stop without approval or `batch-coordinator`. If it fails or a ready Review omits any required handoff field, stop with ProtocolError. Do not ask for approval and do not invoke `batch-coordinator`. +3. Your immediate next action after a valid Review is to invoke the top-level question tool: use `#vscode/askQuestions` in VS Code, or `#ask_user` in a host that exposes only that alias. Send the Review as its prompt and request one required choice whose enum values are exactly **Start batch** and **Cancel**. This top-level tool call is required because the current host does not expose a question tool inside a nested agent invocation. When either tool is exposed: Do not emit text asking the user to reply, choose, or confirm; do not replace the tool call with ordinary prose or another tool. +4. If and only if the top-level host exposes neither `vscode/askQuestions` nor `ask_user`, immediately return the complete `batch-review` response verbatim and stop without another tool call, summary, replacement token, delegation, or approval-bearing artifact. The deterministic Review already presents the exact **Start batch** and **Cancel** fallback choices. The immediately following fresh user turn may use the exact fallback described above. Never consume `Start batch` text from the original request as fallback approval, and never invent an `APPROVE_BATCH:` token. +5. **Cancel**, a missing structured result, or any approval value other than exact **Start batch** stops with no approval-bearing artifacts, initialization, lease, or phase invocation. +6. After either the structured result selects **Start batch** or a valid exact fallback turn is **Start batch**, do not acknowledge approval in prose and do not end the invocation. Your immediate next tool action delegates exactly once to `batch-coordinator` in foreground/synchronous mode with the launch root, original request, the complete compact `BATCH_REVIEW_READY` handoff block, the retained scope-evidence JSON object when mode selection was required, and exactly one of these approval-evidence JSON objects: + +```json +{"mode":"structured","value":"Start batch","accepted":true} +{"mode":"explicit-follow-up","value":"Start batch","entireUserTurn":"Start batch","immediatelyAfterReview":true} +``` + +The second shape is valid only when the entire fresh current user turn is exact `Start batch` and immediately follows the pending Review. Pass the applicable JSON object verbatim in the coordinator prompt; do not paraphrase it as “approved” or omit its booleans. Do not reconstruct or require the Review Markdown inside the coordinator prompt. The coordinator reads the stable Review from the digest-bound paths, executes the entire repository loop, and returns one aggregate result. + +Do not read repositories, run preflight, initialize state, hold a lease, or dispatch phase agents yourself. Do not invoke either internal agent outside this sequence and do not start a second execution coordinator for the same approved Review. + +Batch mode supports Assessment only: + +- When the default config is absent, an explicit multi-repository Assessment selects Batch directly. When it is present, every request asks the mode question first; a Batch choice delegates internally to `batch-review` and later requires separate Start approval. +- An explicit multi-repository Planning, Execution, upgrade, migration, security remediation, or full modernization request must explain that this action is not available in Batch mode. Do not silently run it as single-repository work. +- A default `.github/modernize/repos.json` must trigger the mode question for every new request. Its existence never silently selects Batch and never starts execution. +- Do not invoke `batch-review` or `batch-coordinator` more than once for the same Review. The execution coordinator owns its entire repository loop and returns one aggregate result. + +The classic Single-mode coordinator todo rule does not apply to the Batch Assessment sequence. Batch mode must follow its no-todo Review and coordinator protocol exactly. + +Headless never bypasses the mandatory workspace-mode probe or a found-config Batch/Single choice, and it never bypasses Batch Review or the separate exact **Start batch** approval. \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/references/phase-contract.md b/plugins/github-copilot-modernization/skills/batch-modernization/references/phase-contract.md new file mode 100644 index 0000000..59a9e95 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/references/phase-contract.md @@ -0,0 +1,82 @@ +# Batch Phase Contract V1 + +## Ownership + +The batch control plane owns configuration, preflight, lease, state, event, summary, and result verification. A phase agent owns one execution unit, one phase, and one attempt. + +Natural-language returns are notifications only. The attempt result artifact is the protocol boundary. + +## Schemas + +The authoritative v1 files are under `schemas/`: + +- `resolved-repos.schema.json` +- `execution-unit.schema.json` +- `attempt-request.schema.json` +- `attempt-result.schema.json` +- `batch-state.schema.json` +- `event.schema.json` +- `needs-input.schema.json` + +Unknown schema versions fail closed. Do not infer compatibility. + +## Attempt Identity + +The following fields must exactly match the persisted dispatch record: + +```text +batchId +invocationId +repoId +executionUnitId +phase +attempt +``` + +An attempt request never includes the batch owner token. It contains only its approved workspace/scope, input artifacts, decisions, and result path. + +## Result Status + +Supported result statuses are: + +```text +completed +completed_with_issues +failed +protocol_error +needs_input +skipped +interrupted +``` + +- `needs_input` requires a valid persisted question payload. +- Other statuses require `needsInput: null`. +- `failed` requires an error payload. +- Successful statuses require `error: null` and phase evidence. +- Schema, identity, artifact, or evidence failure is always `protocol_error`. + +## Artifact Boundary + +Every artifact path must be absolute, exist, resolve canonically, and remain under either the batch root or execution-unit workspace. Symlink/junction escapes are rejected. + +Success evidence by phase: + +| Phase | Required evidence | +|---|---| +| Assessment | Parseable public `report.json`, schema-valid internal `normalized-assessment.json`, and non-empty HTML report. | +| Planning | Non-empty `plan.md` and parseable tasks document containing `tasks[]`. | +| Execution | Non-empty summary, non-empty terminal task status list, and explicit build/test result or exemption. | + +## State And Lease + +- Manifest is immutable. +- JSON files use temporary write, file flush, and atomic rename. +- Events append under the same exclusive mutation lock as takeover. +- Only the active owner token can mutate. +- The raw owner token is never persisted. +- Takeover compare-and-swap rotates ownership but remains read-only. +- A read-only takeover cannot schedule or mutate until worker fencing is implemented. + +## Recovery Boundary + +A read-only takeover does not dispatch agents, resume work, retry attempts, process `NeedsInput`, or mutate source workspaces. Those capabilities require worker fencing and an explicit recovery workflow. \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/references/repos-json-compatibility.md b/plugins/github-copilot-modernization/skills/batch-modernization/references/repos-json-compatibility.md new file mode 100644 index 0000000..6e87505 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/references/repos-json-compatibility.md @@ -0,0 +1,66 @@ +# `repos.json` Compatibility + +## V1 + +V1 is a non-empty array. Each repository requires `name` and either `url` or absolute `path`: + +```json +[ + { + "name": "orders-api", + "url": "https://github.com/contoso/orders-api.git" + } +] +``` + +## V2 + +V2 is an object with non-empty `repos`, optional `producer`, and optional `apps`: + +```json +{ + "producer": "portfolio-team", + "repos": [ + { + "name": "orders", + "path": "C:\\src\\orders", + "include_paths": ["services/api"] + } + ], + "apps": [ + { + "identifier": "commerce", + "repos": ["orders"] + } + ] +} +``` + +## Field Rules + +| Field | Rule | +|---|---| +| `name` | Required, non-empty, case-insensitively unique. Its sanitized cross-platform ID must also be unique. | +| `url` | HTTPS, SSH URI, or SCP-style SSH. HTTP and malformed forms are rejected. Persisted form contains no userinfo, query, or fragment. | +| `path` | Absolute local path; `~` expands to the user home. If both URL and path exist, URL wins and a warning is emitted. | +| `branch` | Applies only to URL repositories and must be a valid Git branch/ref name. Local-path branch is ignored with a warning. | +| `include_paths` | Repository-relative project paths. Absolute paths, `..`, duplicates, and execution-unit ID collisions are rejected. | +| `apps` | Grouping metadata only. Every repository reference must resolve case-insensitively. | +| `producer` | Preserved for source tracking. | +| Other fields | Preserved under `unknownFields`; credential-like keys and secret-bearing URLs are redacted. | + +## Execution Units + +- No `include_paths`: repository root becomes one execution unit. +- With `include_paths`: each valid recognized project path becomes a separate execution unit. +- `workspacePath` and the initial sole `scopeRoot` are the canonical project path, not the repository root. +- `repoId` identifies configuration ownership; `executionUnitId` identifies scheduling and artifacts. +- Units sharing a Git root must remain serialized when mutation is later enabled. + +## Preflight States + +- `ready`: path, project, Git expectations, and authorization are valid with no warnings. +- `needs_attention`: usable but requires a batch-level decision, such as dirty workspace or non-Git local project. +- `blocked`: missing/unauthorized path, unsupported project, path escape, invalid clone target, origin mismatch, branch mismatch, or other safety violation. + +Unknown fields do not make the configuration invalid unless they contain values that cannot be safely retained. \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/schemas/aggregate-report.v1.json b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/aggregate-report.v1.json new file mode 100644 index 0000000..bd1e1f8 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/aggregate-report.v1.json @@ -0,0 +1,319 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "aggregate-report.v1.json", + "title": "Plugin Batch Assessment aggregate report v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "version", + "producer", + "platform", + "metadata", + "summary", + "projects", + "rules", + "extensions", + "apps" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "version": { "const": "1.0.0" }, + "producer": { "const": "GitHub Copilot Modernization Plugin" }, + "platform": { "const": "copilot-cli-plugin" }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "batchId", + "generatedAt", + "analysisStartTime", + "analysisEndTime", + "status", + "domains", + "mode", + "repos" + ], + "properties": { + "schema": { "const": "github-copilot-modernization/batch-assessment/v1" }, + "batchId": { "type": "string", "minLength": 1 }, + "generatedAt": { "type": "string", "minLength": 1 }, + "analysisStartTime": { "type": "string", "minLength": 1 }, + "analysisEndTime": { "type": "string", "minLength": 1 }, + "status": { "enum": ["completed", "completed_with_issues", "failed"] }, + "domains": { + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "mode": { "enum": ["issue-only", "full"] }, + "repos": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "identity", + "name", + "path", + "sourceRepo", + "appIdentifiers", + "status", + "language", + "reportPath" + ], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "minLength": 1 }, + "sourceRepo": { "type": "string", "minLength": 1 }, + "appIdentifiers": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "status": { + "enum": ["completed", "completed_with_issues", "protocol_error", "failed", "interrupted"] + }, + "language": { "enum": ["java", "dotnet", "javascript", "typescript"] }, + "reportPath": { + "anyOf": [ + { "type": "string", "minLength": 1 }, + { "type": "null" } + ] + } + } + } + } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "totalProjects", + "totalIssues", + "totalIncidents", + "totalEffort" + ], + "properties": { + "totalProjects": { "type": "integer", "minimum": 0 }, + "totalIssues": { "type": "integer", "minimum": 0 }, + "totalIncidents": { "type": "integer", "minimum": 0 }, + "totalEffort": { "type": "integer", "minimum": 0 } + } + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["properties", "incidents"], + "properties": { + "properties": { "type": "object" }, + "incidents": { "type": "array" } + } + } + }, + "rules": { "type": "object" }, + "extensions": { + "type": "object", + "additionalProperties": false, + "required": ["github-copilot-modernization"], + "properties": { + "github-copilot-modernization": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "batchId", "status", "counts", "assessmentConfig", "topRecommendations", "planningSupported", "repositories"], + "properties": { + "schema": { "const": "github-copilot-modernization/batch-assessment/v1" }, + "batchId": { "type": "string", "minLength": 1 }, + "status": { "enum": ["completed", "completed_with_issues", "failed"] }, + "counts": { + "type": "object", + "additionalProperties": false, + "required": ["total", "completed", "completedWithIssues", "failed", "findings", "actionableFindings", "trackedFindings", "bySeverity", "byState"], + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "completed": { "type": "integer", "minimum": 0 }, + "completedWithIssues": { "type": "integer", "minimum": 0 }, + "failed": { "type": "integer", "minimum": 0 }, + "findings": { "type": "integer", "minimum": 0 }, + "actionableFindings": { "type": "integer", "minimum": 0 }, + "trackedFindings": { "type": "integer", "minimum": 0 }, + "bySeverity": { + "type": "object", + "additionalProperties": false, + "required": ["critical", "high", "medium", "low", "info"], + "properties": { + "critical": { "type": "integer", "minimum": 0 }, + "high": { "type": "integer", "minimum": 0 }, + "medium": { "type": "integer", "minimum": 0 }, + "low": { "type": "integer", "minimum": 0 }, + "info": { "type": "integer", "minimum": 0 } + } + }, + "byState": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0 } + } + } + }, + "assessmentConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "targetRuntime": { "type": "string", "minLength": 1 }, + "targetComputeServices": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "enableContainerization": { "type": "boolean" }, + "targetOS": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "minimumCveSeverity": { "type": "string", "minLength": 1 }, + "cveScanScope": { "type": "string", "minLength": 1 } + } + }, + "topRecommendations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["identity", "kind", "summary", "nextAction", "prefilledPrompt"], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "kind": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1 }, + "nextAction": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "prefilledPrompt": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + } + } + }, + "planningSupported": { + "type": "object", + "additionalProperties": false, + "required": ["supported", "unsupported", "unavailable"], + "properties": { + "supported": { "type": "integer", "minimum": 0 }, + "unsupported": { "type": "integer", "minimum": 0 }, + "unavailable": { "type": "integer", "minimum": 0 } + } + }, + "repositories": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["identity", "repoId", "executionUnitId", "status", "language", "workspacePath", "reports", "findings", "assessmentConfig", "topRecommendation", "planningSupported", "errors"], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "repoId": { "type": "string", "minLength": 1 }, + "executionUnitId": { "type": "string", "minLength": 1 }, + "status": { "enum": ["completed", "completed_with_issues", "protocol_error", "failed", "interrupted"] }, + "language": { "enum": ["java", "dotnet", "javascript", "typescript"] }, + "workspacePath": { "type": "string", "minLength": 1 }, + "reports": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["json", "html", "facts", "digests"], + "properties": { + "json": { "type": "string", "minLength": 1 }, + "html": { "type": "string", "minLength": 1 }, + "facts": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "digests": { "type": "object", "additionalProperties": { "type": "string", "pattern": "^sha256:[A-Fa-f0-9]{64}$" } } + } + }, + { "type": "null" } + ] + }, + "findings": { + "type": "object", + "additionalProperties": false, + "required": ["total", "actionable", "tracked", "categories", "security", "bySeverity", "byState"], + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "actionable": { "type": "integer", "minimum": 0 }, + "tracked": { "type": "integer", "minimum": 0 }, + "categories": { "type": "integer", "minimum": 0 }, + "security": { "type": "integer", "minimum": 0 }, + "bySeverity": { + "type": "object", + "additionalProperties": false, + "required": ["critical", "high", "medium", "low", "info"], + "properties": { + "critical": { "type": "integer", "minimum": 0 }, + "high": { "type": "integer", "minimum": 0 }, + "medium": { "type": "integer", "minimum": 0 }, + "low": { "type": "integer", "minimum": 0 }, + "info": { "type": "integer", "minimum": 0 } + } + }, + "byState": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0 } + } + } + }, + "assessmentConfig": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "targetRuntime": { "type": "string", "minLength": 1 }, + "targetComputeServices": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "enableContainerization": { "type": "boolean" }, + "targetOS": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "minimumCveSeverity": { "type": "string", "minLength": 1 }, + "cveScanScope": { "type": "string", "minLength": 1 } + } + }, + { "type": "null" } + ] + }, + "topRecommendation": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "summary", "nextAction", "prefilledPrompt"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1 }, + "nextAction": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "prefilledPrompt": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + } + }, + { "type": "null" } + ] + }, + "planningSupported": { + "anyOf": [{ "type": "boolean" }, { "type": "null" }] + }, + "errors": { "type": "array", "items": { "type": "string" } } + } + } + } + } + } + } + }, + "apps": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["identifier", "repos"], + "properties": { + "identifier": { "type": "string", "minLength": 1 }, + "repos": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + } + } + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/schemas/assessment-finalization.v1.json b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/assessment-finalization.v1.json new file mode 100644 index 0000000..b8ee709 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/assessment-finalization.v1.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "assessment-finalization.v1.json", + "title": "Batch Assessment finalization journal v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "batchId", + "phase", + "status", + "summaryJsonDigest", + "summaryMarkdownDigest", + "reportDirectoryPath", + "reportIndexPath", + "aggregateReportPath", + "reportDirectoryDigest", + "reportIndexDigest", + "aggregateReportDigest", + "completedAt", + "releaseReady", + "released" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "batchId": { + "type": "string", + "minLength": 1 + }, + "phase": { + "const": "assessment" + }, + "status": { + "enum": [ + "completed", + "completed_with_issues", + "failed" + ] + }, + "summaryJsonDigest": { + "type": "string", + "pattern": "^sha256:[A-Fa-f0-9]{64}$" + }, + "summaryMarkdownDigest": { + "type": "string", + "pattern": "^sha256:[A-Fa-f0-9]{64}$" + }, + "reportDirectoryPath": { + "type": "string", + "minLength": 1 + }, + "reportIndexPath": { + "type": "string", + "minLength": 1 + }, + "aggregateReportPath": { + "type": "string", + "minLength": 1 + }, + "reportDirectoryDigest": { + "type": "string", + "pattern": "^sha256:[A-Fa-f0-9]{64}$" + }, + "reportIndexDigest": { + "type": "string", + "pattern": "^sha256:[A-Fa-f0-9]{64}$" + }, + "aggregateReportDigest": { + "type": "string", + "pattern": "^sha256:[A-Fa-f0-9]{64}$" + }, + "completedAt": { + "type": "string", + "minLength": 1 + }, + "releaseReady": { + "const": true + }, + "released": { + "type": "boolean" + } + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/schemas/attempt-request.schema.json b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/attempt-request.schema.json new file mode 100644 index 0000000..9ac4c66 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/attempt-request.schema.json @@ -0,0 +1,140 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "attempt-request.schema.json", + "title": "Batch phase attempt request v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "batchId", + "invocationId", + "repoId", + "executionUnitId", + "workspacePath", + "scopeRoots", + "assessmentCliPath", + "runId", + "language", + "phase", + "attempt", + "mode", + "userRequest", + "phaseApproved", + "resultPath", + "inputArtifacts", + "decisions" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "batchId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + }, + "invocationId": { + "type": "string", + "pattern": "^[A-Fa-f0-9-]{36}$" + }, + "repoId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + }, + "executionUnitId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$" + }, + "workspacePath": { + "type": "string", + "minLength": 1 + }, + "scopeRoots": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "assessmentCliPath": { + "type": "string", + "minLength": 1 + }, + "runId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + }, + "language": { + "enum": [ + "java", + "dotnet", + "javascript", + "typescript" + ] + }, + "phase": { + "enum": [ + "assessment", + "planning", + "execution" + ] + }, + "attempt": { + "type": "integer", + "minimum": 1 + }, + "mode": { + "const": "batch-headless" + }, + "userRequest": { + "type": "string", + "minLength": 1 + }, + "phaseApproved": { + "type": "boolean" + }, + "resultPath": { + "type": "string", + "minLength": 1 + }, + "inputArtifacts": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "decisions": { + "type": "object", + "additionalProperties": false, + "required": ["domains", "analysisCoverage", "maxConcurrency"], + "properties": { + "domains": { + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { "enum": ["security", "cloud-readiness", "java-upgrade"] } + }, + "analysisCoverage": { "enum": ["issue-only", "full"] }, + "maxConcurrency": { "type": "integer", "minimum": 1, "maximum": 7 }, + "targetRuntime": { "type": "string", "minLength": 1 }, + "targetComputeServices": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "enableContainerization": { "type": "boolean" }, + "targetOS": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "minimumCveSeverity": { "type": "string", "minLength": 1 }, + "cveScanScope": { "type": "string", "minLength": 1 } + } + } + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/schemas/attempt-result.schema.json b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/attempt-result.schema.json new file mode 100644 index 0000000..a6c354b --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/attempt-result.schema.json @@ -0,0 +1,130 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "attempt-result.schema.json", + "title": "Batch phase attempt result v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "batchId", + "invocationId", + "repoId", + "executionUnitId", + "phase", + "attempt", + "status", + "artifacts", + "evidence", + "needsInput", + "error", + "completedAt" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "batchId": { + "type": "string", + "minLength": 1 + }, + "invocationId": { + "type": "string", + "pattern": "^[A-Fa-f0-9-]{36}$" + }, + "repoId": { + "type": "string", + "minLength": 1 + }, + "executionUnitId": { + "type": "string", + "minLength": 1 + }, + "phase": { + "enum": [ + "assessment", + "planning", + "execution" + ] + }, + "attempt": { + "type": "integer", + "minimum": 1 + }, + "status": { + "enum": [ + "completed", + "completed_with_issues", + "failed", + "protocol_error", + "needs_input", + "skipped", + "interrupted" + ] + }, + "artifacts": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "evidence": { + "type": "object", + "required": [ + "artifactValidation" + ], + "properties": { + "artifactValidation": { + "enum": [ + "passed", + "failed", + "not_run" + ] + } + } + }, + "needsInput": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "needs-input.schema.json" + } + ] + }, + "error": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message", + "retryable" + ], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + }, + "retryable": { + "type": "boolean" + } + } + } + ] + }, + "completedAt": { + "type": "string", + "minLength": 1 + } + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/schemas/attempt-validation.v1.json b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/attempt-validation.v1.json new file mode 100644 index 0000000..98b558e --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/attempt-validation.v1.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "attempt-validation.v1.json", + "title": "Batch attempt validation record v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "batchId", + "invocationId", + "repoId", + "executionUnitId", + "phase", + "attempt", + "requestDigest", + "resultDigest", + "status", + "valid", + "errors", + "artifacts", + "artifactDigests", + "validatedAt" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "batchId": { + "type": "string", + "minLength": 1 + }, + "invocationId": { + "type": "string", + "pattern": "^[A-Fa-f0-9-]{36}$" + }, + "repoId": { + "type": "string", + "minLength": 1 + }, + "executionUnitId": { + "type": "string", + "minLength": 1 + }, + "phase": { + "const": "assessment" + }, + "attempt": { + "type": "integer", + "minimum": 1 + }, + "requestDigest": { + "type": "string", + "pattern": "^sha256:[A-Fa-f0-9]{64}$" + }, + "resultDigest": { + "anyOf": [ + { + "type": "string", + "pattern": "^sha256:[A-Fa-f0-9]{64}$" + }, + { + "type": "null" + } + ] + }, + "status": { + "enum": [ + "completed", + "completed_with_issues", + "failed", + "protocol_error", + "needs_input", + "skipped", + "interrupted" + ] + }, + "valid": { + "type": "boolean" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "artifacts": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "artifactDigests": { + "type": "object", + "additionalProperties": { + "type": "string", + "pattern": "^sha256:[A-Fa-f0-9]{64}$" + } + }, + "validatedAt": { + "type": "string", + "minLength": 1 + } + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/schemas/batch-state.schema.json b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/batch-state.schema.json new file mode 100644 index 0000000..20533d3 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/batch-state.schema.json @@ -0,0 +1,173 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "batch-state.schema.json", + "title": "Batch state v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "batchId", + "status", + "revision", + "createdAt", + "updatedAt", + "activeLeaseDigest", + "executionUnits", + "progress" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "batchId": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "draft", + "ready", + "running", + "awaiting_input", + "paused", + "completed", + "completed_with_issues", + "failed", + "cancelled" + ] + }, + "revision": { + "type": "integer", + "minimum": 0 + }, + "createdAt": { + "type": "string", + "minLength": 1 + }, + "updatedAt": { + "type": "string", + "minLength": 1 + }, + "activeLeaseDigest": { + "type": [ + "string", + "null" + ] + }, + "executionUnits": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "repoId", + "executionUnitId", + "phase", + "attempt", + "invocationId", + "status", + "resultPath", + "startedAt", + "finishedAt" + ], + "properties": { + "repoId": { + "type": "string" + }, + "executionUnitId": { + "type": "string" + }, + "phase": { + "enum": [ + "assessment", + "planning", + "execution" + ] + }, + "attempt": { + "type": "integer", + "minimum": 0 + }, + "invocationId": { + "type": [ + "string", + "null" + ] + }, + "status": { + "enum": [ + "pending", + "preparing", + "running", + "needs_input", + "completed", + "completed_with_issues", + "protocol_error", + "failed", + "not_applicable", + "excluded", + "blocked", + "interrupted" + ] + }, + "resultPath": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "finishedAt": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "progress": { + "type": "object", + "additionalProperties": false, + "required": [ + "wave", + "eligible", + "terminal", + "successful", + "issues", + "failed" + ], + "properties": { + "wave": { + "type": "integer", + "minimum": 1 + }, + "eligible": { + "type": "integer", + "minimum": 0 + }, + "terminal": { + "type": "integer", + "minimum": 0 + }, + "successful": { + "type": "integer", + "minimum": 0 + }, + "issues": { + "type": "integer", + "minimum": 0 + }, + "failed": { + "type": "integer", + "minimum": 0 + } + } + } + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/schemas/event.schema.json b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/event.schema.json new file mode 100644 index 0000000..39b8e7c --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/event.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "event.schema.json", + "title": "Batch state event v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "eventId", + "sequence", + "batchId", + "type", + "at", + "repoId", + "executionUnitId", + "invocationId", + "payload" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "eventId": { + "type": "string", + "pattern": "^[A-Fa-f0-9-]{36}$" + }, + "sequence": { + "type": "integer", + "minimum": 1 + }, + "batchId": { + "type": "string", + "minLength": 1 + }, + "type": { + "enum": [ + "batch_created", + "lease_acquired", + "lease_released", + "takeover_recorded", + "attempt_started", + "attempt_finished", + "input_requested", + "input_answered", + "pause_requested", + "batch_paused", + "batch_completed" + ] + }, + "at": { + "type": "string", + "minLength": 1 + }, + "repoId": { + "type": [ + "string", + "null" + ] + }, + "executionUnitId": { + "type": [ + "string", + "null" + ] + }, + "invocationId": { + "type": [ + "string", + "null" + ] + }, + "payload": { + "type": "object" + } + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/schemas/execution-unit.schema.json b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/execution-unit.schema.json new file mode 100644 index 0000000..4329675 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/execution-unit.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "execution-unit.schema.json", + "title": "Batch execution unit v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "repoId", + "executionUnitId", + "displayName", + "workspacePath", + "scopeRoots", + "languages", + "source" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "repoId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + }, + "executionUnitId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$" + }, + "displayName": { + "type": "string", + "minLength": 1 + }, + "workspacePath": { + "type": "string", + "minLength": 1 + }, + "gitRoot": { + "type": [ + "string", + "null" + ] + }, + "scopeRoots": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "languages": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "java", + "dotnet", + "javascript", + "typescript", + "unknown" + ] + } + }, + "source": { + "enum": [ + "repository-root", + "include-path" + ] + } + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/schemas/needs-input.schema.json b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/needs-input.schema.json new file mode 100644 index 0000000..c6bc99a --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/needs-input.schema.json @@ -0,0 +1,149 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "needs-input.schema.json", + "title": "Persisted batch questions and answers v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "requestId", + "batchId", + "invocationId", + "repoId", + "executionUnitId", + "sourceAttempt", + "status", + "questions", + "answers" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + }, + "batchId": { + "type": "string", + "minLength": 1 + }, + "invocationId": { + "type": "string", + "pattern": "^[A-Fa-f0-9-]{36}$" + }, + "repoId": { + "type": "string", + "minLength": 1 + }, + "executionUnitId": { + "type": "string", + "minLength": 1 + }, + "sourceAttempt": { + "type": "integer", + "minimum": 1 + }, + "status": { + "enum": [ + "pending", + "answered", + "skipped", + "cancelled" + ] + }, + "questions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "kind", + "prompt", + "required", + "options" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + }, + "kind": { + "enum": [ + "confirm", + "select", + "multi_select", + "text" + ] + }, + "prompt": { + "type": "string", + "minLength": 1 + }, + "required": { + "type": "boolean" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "value", + "label" + ], + "properties": { + "value": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + "answers": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "questionId", + "value" + ], + "properties": { + "questionId": { + "type": "string", + "minLength": 1 + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + } + } + } + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/schemas/normalized-assessment.v1.json b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/normalized-assessment.v1.json new file mode 100644 index 0000000..61a01ff --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/normalized-assessment.v1.json @@ -0,0 +1,143 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "normalized-assessment.v1.json", + "title": "Normalized assessment v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "kind", + "metadata", + "categories", + "findings", + "security" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "kind": { + "const": "github-copilot-modernization/normalized-assessment" + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "runId", + "generatedAt", + "analysisStartTime", + "analysisEndTime", + "status", + "domains", + "language", + "intent", + "totalFindings", + "totalActionableFindings", + "totalTrackedFindings" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "runId": { + "type": "string", + "minLength": 1 + }, + "generatedAt": { + "type": "string", + "minLength": 1 + }, + "analysisStartTime": { + "type": "string", + "minLength": 1 + }, + "analysisEndTime": { + "type": "string", + "minLength": 1 + }, + "status": { + "const": "completed" + }, + "domains": { + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "language": { + "enum": [ + "java", + "dotnet", + "javascript", + "typescript" + ] + }, + "intent": { + "type": "object" + }, + "totalFindings": { + "type": "integer", + "minimum": 0 + }, + "totalActionableFindings": { + "type": "integer", + "minimum": 0 + }, + "totalTrackedFindings": { + "type": "integer", + "minimum": 0 + } + } + }, + "categories": { + "type": "array", + "items": { + "type": "object", + "required": [ + "category", + "categoryId", + "sourceSkill", + "issues", + "solutions" + ], + "properties": { + "category": { + "type": "string", + "minLength": 1 + }, + "categoryId": { + "type": "string", + "minLength": 1 + }, + "sourceSkill": { + "type": "string", + "minLength": 1 + }, + "issues": { + "type": "array" + }, + "solutions": { + "type": "array" + } + } + } + }, + "findings": { + "type": "array", + "items": { + "type": "object" + } + }, + "security": { + "type": "array", + "items": { + "type": "object" + } + } + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/schemas/resolved-repos.schema.json b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/resolved-repos.schema.json new file mode 100644 index 0000000..bab2bf7 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/schemas/resolved-repos.schema.json @@ -0,0 +1,164 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "resolved-repos.schema.json", + "title": "Resolved batch repositories v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "configPath", + "configSha256", + "producer", + "repositories", + "apps", + "unknownFields" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "configPath": { + "type": "string", + "minLength": 1 + }, + "configSha256": { + "type": "string", + "pattern": "^[A-Fa-f0-9]{64}$" + }, + "producer": { + "type": [ + "string", + "null" + ] + }, + "repositories": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "repoId", + "name", + "input", + "workspacePath", + "preflightStatus", + "warnings", + "errors", + "executionUnits", + "unknownFields" + ], + "properties": { + "repoId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "input": { + "type": "object", + "additionalProperties": false, + "required": [ + "url", + "path", + "branch", + "includePaths" + ], + "properties": { + "url": { + "type": [ + "string", + "null" + ] + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "branch": { + "type": [ + "string", + "null" + ] + }, + "includePaths": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, + "workspacePath": { + "type": "string", + "minLength": 1 + }, + "preflightStatus": { + "enum": [ + "ready", + "needs_attention", + "blocked" + ] + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "executionUnits": { + "type": "array", + "items": { + "$ref": "execution-unit.schema.json" + } + }, + "unknownFields": { + "type": "object" + } + } + } + }, + "apps": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "identifier", + "repoIds", + "unknownFields" + ], + "properties": { + "identifier": { + "type": "string", + "minLength": 1 + }, + "repoIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "unknownFields": { + "type": "object" + } + } + } + }, + "unknownFields": { + "type": "object" + } + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/batch-assessment-report.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/batch-assessment-report.mjs new file mode 100644 index 0000000..2e05fad --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/batch-assessment-report.mjs @@ -0,0 +1,587 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { BatchStateError, fileDigest } from "./batch-state.mjs"; +import { validateSchema } from "./schema-validator.mjs"; + +const scriptRoot = path.dirname(fileURLToPath(import.meta.url)); +const aggregateSchemaPath = path.resolve(scriptRoot, "..", "schemas", "aggregate-report.v1.json"); +const aggregateSchema = JSON.parse(fs.readFileSync(aggregateSchemaPath, "utf8")); +const USABLE_STATUSES = new Set(["completed", "completed_with_issues"]); +const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; +const SEVERITIES = ["critical", "high", "medium", "low", "info"]; +const ASSESSMENT_CONFIG_FIELDS = [ + "targetRuntime", + "targetComputeServices", + "enableContainerization", + "targetOS", + "minimumCveSeverity", + "cveScanScope", +]; + +function assessmentConfig(value = {}) { + return Object.fromEntries(ASSESSMENT_CONFIG_FIELDS + .filter((name) => value[name] !== undefined) + .map((name) => [name, value[name]])); +} + +function asDate(value, label) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new BatchStateError(`${label} is not a valid timestamp`, "invalid_report_timestamp"); + } + return date; +} + +function timestamp(value) { + const date = asDate(value, "Batch completion time"); + const part = (number) => String(number).padStart(2, "0"); + return [ + date.getUTCFullYear(), + part(date.getUTCMonth() + 1), + part(date.getUTCDate()), + part(date.getUTCHours()), + part(date.getUTCMinutes()), + part(date.getUTCSeconds()), + ].join(""); +} + +export function assessmentReportPaths({ batchRoot, completedAt } = {}) { + const root = path.resolve(batchRoot ?? ""); + const batchesDirectory = path.dirname(root); + const modernizeDirectory = path.dirname(batchesDirectory); + const githubDirectory = path.dirname(modernizeDirectory); + if (path.basename(batchesDirectory).toLowerCase() !== "batches" + || path.basename(modernizeDirectory).toLowerCase() !== "modernize" + || path.basename(githubDirectory).toLowerCase() !== ".github") { + throw new BatchStateError( + "Batch root must be a direct child of /.github/modernize/batches", + "invalid_batch_root", + ); + } + const reportDirectory = path.join( + modernizeDirectory, + "assessment", + `reports-${timestamp(completedAt)}`, + ); + return { + reportDirectory, + reportIndex: path.join(reportDirectory, "index.html"), + aggregateReport: path.join(reportDirectory, "aggregate-report.json"), + }; +} + +function reportIdentity(value) { + let sanitized = String(value) + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[\\/]+/g, ".") + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-.]+|[-.]+$/g, "") + .slice(0, 120); + if (!sanitized) sanitized = `repo-${crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 8)}`; + if (WINDOWS_RESERVED_NAME.test(sanitized)) sanitized = `repo-${sanitized}`; + return sanitized; +} + +function reportIdentities(results) { + const used = new Set(); + return new Map(results.map((result) => { + const base = reportIdentity(result.executionUnitId); + let candidate = base; + if (used.has(candidate.toLowerCase())) { + const suffix = crypto.createHash("sha256").update(result.executionUnitId).digest("hex").slice(0, 8); + candidate = `${base.slice(0, 111)}-${suffix}`; + } + if (used.has(candidate.toLowerCase())) { + throw new BatchStateError( + `Report directory identity collision: ${result.executionUnitId}`, + "report_identity_collision", + ); + } + used.add(candidate.toLowerCase()); + return [result.executionUnitId, candidate]; + })); +} + +function posixRelative(root, filePath) { + return path.relative(root, filePath).split(path.sep).join("/"); +} + +function copyValidatedArtifact(sourcePath, destinationPath, expectedDigest) { + if (!expectedDigest || fileDigest(sourcePath) !== expectedDigest) { + throw new BatchStateError( + `Validated artifact digest changed before report publication: ${sourcePath}`, + "validated_artifact_changed", + ); + } + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.copyFileSync(sourcePath, destinationPath); + if (fileDigest(destinationPath) !== expectedDigest) { + throw new BatchStateError( + `Published report copy does not match its validated source: ${destinationPath}`, + "report_copy_mismatch", + ); + } +} + +function integer(value) { + return Number.isInteger(value) && value >= 0 ? value : 0; +} + +function reportMetrics(report) { + return { + total: integer(report?.metadata?.totalFindings), + actionable: integer(report?.metadata?.totalActionableFindings), + tracked: integer(report?.metadata?.totalTrackedFindings), + categories: Array.isArray(report?.categories) ? report.categories.length : 0, + security: Array.isArray(report?.security) ? report.security.length : 0, + }; +} + +function singleReportSummary(htmlPath, language) { + const html = fs.readFileSync(htmlPath, "utf8"); + const match = html.match(/${"complete".repeat(2_000)}`, + ); + const artifacts = { + report: reportPath, + normalizedAssessment: normalizedAssessmentPath, + html: htmlPath, + }; + if (request.decisions.domains.some((domain) => domain !== "security")) { + const appcatPath = path.join(request.workspacePath, ".github", "modernize", ".memory", "runs", request.runId, "appcat", "report.json"); + fs.mkdirSync(path.dirname(appcatPath), { recursive: true }); + fs.writeFileSync(appcatPath, "{\"rules\":[],\"incidents\":[]}\n"); + artifacts.appcat = appcatPath; + } + if (request.decisions.analysisCoverage === "full") { + const factsDirectory = path.join(reportDirectory, "facts"); + fs.mkdirSync(factsDirectory); + for (const skillId of FACT_SKILL_IDS) { + fs.writeFileSync(path.join(factsDirectory, `${skillId}.md`), `# ${skillId}\n`); + } + } + if (request.decisions.domains.includes("security")) { + const securityDirectory = path.join(attemptDirectory, "scratch", "engines", "security", "incoming"); + fs.mkdirSync(securityDirectory, { recursive: true }); + fs.writeFileSync(path.join(securityDirectory, "cve-known-vulnerabilities.json"), "[]\n"); + for (const skillId of SECURITY_CWE_SKILL_IDS) { + fs.writeFileSync( + path.join(securityDirectory, `${skillId}.json`), + `${JSON.stringify({ status: "success", result: { values: [{ status: "NOT_FOUND" }] } })}\n`, + ); + } + } + return artifacts; +} + +test("assessment attempt is bound to one unit and commits verified artifacts", (t) => { + const fixture = createFixture(t); + const started = start(fixture); + assert.equal(fs.existsSync(started.requestPath), true); + assert.equal(started.request.executionUnitId, "orders/api"); + assert.equal(started.request.workspacePath, fixture.workspacePath); + assert.equal(path.basename(started.request.assessmentCliPath), "assess-cli.mjs"); + assert.equal(fs.statSync(started.request.assessmentCliPath).isFile(), true); + assert.deepEqual(started.request.decisions, { + domains: ["security"], + analysisCoverage: "full", + maxConcurrency: 1, + targetRuntime: "java-21", + targetComputeServices: ["azure-container-apps"], + enableContainerization: true, + targetOS: ["linux"], + minimumCveSeverity: "high", + cveScanScope: "all", + }); + assert.equal(Object.hasOwn(started.request, "leaseToken"), false); + assert.equal(readState(fixture.batchRoot).executionUnits[0].status, "running"); + + const artifacts = createAssessmentArtifacts(started, "orders-api"); + const reportPath = artifacts.report; + const htmlPath = artifacts.html; + publishAttemptResult({ + requestPath: started.requestPath, + outcome: { + status: "completed", + artifacts, + evidence: { artifactValidation: "passed" }, + needsInput: null, + error: null, + }, + now: "2026-08-17T12:01:00.000Z", + }); + assert.throws( + () => publishAttemptResult({ + requestPath: started.requestPath, + outcome: { + status: "completed", + artifacts, + evidence: { artifactValidation: "passed" }, + }, + }), + /already exists/, + ); + + assert.throws( + () => commitAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + requestPath: started.requestPath, + now: "2026-08-17T12:01:30.000Z", + checkpoint: (step) => { + if (step === "validation") throw new Error("simulated crash after validation"); + }, + }), + /simulated crash after validation/, + ); + assert.equal(readState(fixture.batchRoot).executionUnits[0].status, "running"); + assert.equal(fs.existsSync(path.join(path.dirname(started.requestPath), "validation.json")), true); + + assert.throws( + () => commitAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + requestPath: started.requestPath, + now: "2026-08-17T12:02:00.000Z", + checkpoint: (step) => { + if (step === "state") throw new Error("simulated crash after state"); + }, + }), + /simulated crash after state/, + ); + assert.equal(fs.existsSync(path.join(fixture.batchRoot, "repos", "orders.json")), false); + assert.throws( + () => finalizeAssessmentBatch({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + }), + (error) => error.code === "validation_commit_incomplete", + ); + + assert.throws( + () => commitAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + requestPath: started.requestPath, + now: "2026-08-17T12:02:30.000Z", + checkpoint: (step) => { + if (step === "repo") throw new Error("simulated crash after repo"); + }, + }), + /simulated crash after repo/, + ); + assert.equal(fs.existsSync(path.join(fixture.batchRoot, "repos", "orders.json")), true); + assert.equal( + fs.readFileSync(path.join(fixture.batchRoot, "events.jsonl"), "utf8") + .split(/\r?\n/) + .filter(Boolean) + .filter((line) => JSON.parse(line).type === "attempt_finished") + .length, + 0, + ); + + const committed = commitAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + requestPath: started.requestPath, + now: "2026-08-17T12:02:45.000Z", + }); + assert.equal(committed.validation.valid, true); + assert.equal(committed.state.status, "completed"); + assert.equal(committed.state.executionUnits[0].status, "completed"); + assert.deepEqual(committed.state.progress, { + wave: 1, + eligible: 1, + terminal: 1, + successful: 1, + issues: 0, + failed: 0, + }); + const revision = committed.state.revision; + const replayed = commitAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + requestPath: started.requestPath, + now: "2026-08-17T12:02:50.000Z", + }); + assert.equal(replayed.state.revision, revision); + const events = fs.readFileSync(path.join(fixture.batchRoot, "events.jsonl"), "utf8") + .trim() + .split(/\r?\n/) + .map((line) => JSON.parse(line)); + assert.equal(events.filter((event) => event.type === "attempt_finished").length, 1); + + const originalReport = fs.readFileSync(reportPath); + fs.appendFileSync(reportPath, "\n"); + assert.throws( + () => finalizeAssessmentBatch({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + }), + (error) => error.code === "validated_artifact_changed", + ); + fs.writeFileSync(reportPath, originalReport); + + const factPath = path.join(path.dirname(reportPath), "facts", `${FACT_SKILL_IDS[0]}.md`); + const originalFact = fs.readFileSync(factPath); + fs.appendFileSync(factPath, "\n"); + assert.throws( + () => finalizeAssessmentBatch({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + }), + (error) => error.code === "validated_artifact_changed", + ); + fs.writeFileSync(factPath, originalFact); + + const securityPath = path.join( + path.dirname(started.requestPath), + "scratch", + "engines", + "security", + "incoming", + `${SECURITY_CWE_SKILL_IDS[0]}.json`, + ); + const originalSecurity = fs.readFileSync(securityPath); + fs.appendFileSync(securityPath, "\n"); + assert.throws( + () => finalizeAssessmentBatch({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + }), + (error) => error.code === "validated_artifact_changed", + ); + fs.writeFileSync(securityPath, originalSecurity); + + assert.throws( + () => finalizeAssessmentBatch({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + now: "2026-08-17T12:03:00.000Z", + checkpoint: (step) => { + if (step === "summary") throw new Error("simulated crash after summary"); + }, + }), + /simulated crash after summary/, + ); + const summaryJson = fs.readFileSync(path.join(fixture.batchRoot, "summary.json")); + const summaryMarkdown = fs.readFileSync(path.join(fixture.batchRoot, "summary.md")); + assert.throws( + () => finalizeAssessmentBatch({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + now: "2026-08-17T12:03:30.000Z", + checkpoint: (step) => { + if (step === "event") throw new Error("simulated crash after event"); + }, + }), + /simulated crash after event/, + ); + assert.notEqual(readLease(fixture.batchRoot), null); + assert.throws( + () => finalizeAssessmentBatch({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + now: "2026-08-17T12:03:45.000Z", + checkpoint: (step) => { + if (step === "release-ready") throw new Error("simulated crash after release-ready"); + }, + }), + /simulated crash after release-ready/, + ); + assert.notEqual(readLease(fixture.batchRoot), null); + assert.throws( + () => finalizeAssessmentBatch({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + now: "2026-08-17T12:04:00.000Z", + checkpoint: (step) => { + if (step === "released") throw new Error("simulated crash after release"); + }, + }), + /simulated crash after release/, + ); + assert.equal(readLease(fixture.batchRoot), null); + const finalized = finalizeAssessmentBatch({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + now: "2026-08-17T12:05:00.000Z", + }); + assert.equal(finalized.summary.status, "completed"); + assert.equal(finalized.summary.results[0].artifacts.report, reportPath); + assert.equal(fs.existsSync(finalized.paths.markdown), true); + const userReportRoot = path.join( + fixture.root, + ".github", + "modernize", + "assessment", + "reports-20260817120200", + ); + assert.equal(finalized.paths.reportDirectory, userReportRoot); + assert.equal(finalized.paths.reportIndex, path.join(userReportRoot, "index.html")); + assert.equal(finalized.paths.aggregateReport, path.join(userReportRoot, "aggregate-report.json")); + const publishedReportPath = path.join(userReportRoot, "repos", "orders.api", "report.json"); + assert.equal(fs.existsSync(publishedReportPath), true); + assert.deepEqual(fs.readFileSync(publishedReportPath), fs.readFileSync(reportPath)); + assert.equal( + fs.existsSync(path.join(userReportRoot, "repos", "orders.api", "normalized-assessment.json")), + false, + ); + assert.equal(fs.existsSync(path.join(userReportRoot, "repos", "orders.api", "report.html")), true); + const aggregate = JSON.parse(fs.readFileSync(finalized.paths.aggregateReport, "utf8")); + assert.equal(aggregate.metadata.batchId, "batch-1"); + assert.equal(aggregate.metadata.repos[0].identity, "orders/api"); + assert.equal(aggregate.projects[0].properties.repo, "orders/api"); + assert.deepEqual(aggregate.rules, {}); + const extension = aggregate.extensions["github-copilot-modernization"]; + assert.equal(extension.counts.completed, 1); + assert.deepEqual(extension.assessmentConfig, { + targetRuntime: "java-21", + targetComputeServices: ["azure-container-apps"], + enableContainerization: true, + targetOS: ["linux"], + minimumCveSeverity: "high", + cveScanScope: "all", + }); + assert.deepEqual(extension.repositories[0].assessmentConfig, extension.assessmentConfig); + assert.equal(extension.repositories[0].reports.html, "repos/orders.api/report.html"); + assert.equal(finalized.summary.reports.index, finalized.paths.reportIndex); + assert.equal(readLease(fixture.batchRoot), null); + assert.deepEqual(fs.readFileSync(finalized.paths.json), summaryJson); + assert.deepEqual(fs.readFileSync(finalized.paths.markdown), summaryMarkdown); + const finalization = JSON.parse(fs.readFileSync(path.join(fixture.batchRoot, "finalization.json"))); + assert.equal(finalization.released, true); + assert.equal(finalization.reportDirectoryPath, userReportRoot); + assert.match(finalization.reportDirectoryDigest, /^sha256:[a-f0-9]{64}$/); + const originalIndex = fs.readFileSync(finalized.paths.reportIndex); + fs.appendFileSync(finalized.paths.reportIndex, "\n"); + assert.throws( + () => finalizeAssessmentBatch({ batchRoot: fixture.batchRoot, ownerToken: fixture.ownerToken }), + (error) => error.code === "finalization_record_mismatch", + ); + fs.writeFileSync(finalized.paths.reportIndex, originalIndex); + const finalEvents = fs.readFileSync(path.join(fixture.batchRoot, "events.jsonl"), "utf8") + .trim() + .split(/\r?\n/) + .map((line) => JSON.parse(line)); + assert.equal(finalEvents.filter((event) => event.type === "batch_completed").length, 1); +}); + +test("lease session retains ownership across stateless CLI processes", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-lease-session-")); + let leaseSessionId; + t.after(() => { + if (leaseSessionId) { + runBatchAttemptCli(["session-release", "--lease-session-id", leaseSessionId]); + } + fs.rmSync(root, { recursive: true, force: true }); + }); + const repositories = ["orders", "billing"].map((repoId) => { + const workspacePath = path.join(root, repoId); + fs.mkdirSync(workspacePath); + return { + repoId, + workspacePath, + executionUnits: [assessmentUnit({ repoId, workspacePath, language: "java" })], + }; + }); + const batchRoot = path.join(root, ".github", "modernize", "batches", "lease-session-assessment"); + initializeApprovedAssessmentBatch({ + batchRoot, + resolvedConfig: resolvedConfig(root, repositories), + selection: { executionUnitIds: ["orders", "billing"], approvedNeedsAttention: [] }, + input: { + batchId: "lease-session-assessment", + userRequest: "Assess selected repositories", + phaseApproved: true, + inputArtifacts: {}, + decisions: { domains: ["cloud-readiness"], analysisCoverage: "issue-only", maxConcurrency: 1 }, + }, + }); + + const opened = cliJson(runBatchAttemptCli([ + "open-session", + "--batch-root", batchRoot, + "--invocation-id", "lease-session-test", + "--execution-unit-id", "orders", + ])); + leaseSessionId = opened.leaseSessionId; + assert.match(leaseSessionId, /^[0-9a-f-]{36}$/i); + + for (const [index, repoId] of ["orders", "billing"].entries()) { + const started = index === 0 + ? opened + : cliJson(runBatchAttemptCli([ + "session-start", + "--lease-session-id", leaseSessionId, + "--execution-unit-id", repoId, + ])); + const artifacts = createAssessmentArtifacts(started, repoId); + const publishedArtifacts = { ...artifacts }; + delete publishedArtifacts.appcat; + publishAttemptResult({ + requestPath: started.requestPath, + outcome: { + status: "completed", + artifacts: publishedArtifacts, + evidence: { artifactValidation: "passed" }, + needsInput: null, + error: null, + }, + }); + const committed = cliJson(runBatchAttemptCli([ + "session-commit", + "--lease-session-id", leaseSessionId, + "--request", started.requestPath, + ])); + assert.equal(committed.validation.valid, true, committed.validation.errors.join("\n")); + } + + const finalized = cliJson(runBatchAttemptCli([ + "session-finalize-assessment", + "--lease-session-id", leaseSessionId, + ])); + leaseSessionId = null; + assert.equal(finalized.summary.status, "completed"); + assert.equal(finalized.summary.counts.completed, 2); + assert.equal(fs.existsSync(finalized.paths.reportIndex), true); + assert.equal(readLease(batchRoot), null); +}); + +test("missing result commits protocol_error instead of trusting agent completion", (t) => { + const fixture = createFixture(t); + const started = start(fixture); + + const committed = commitAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + requestPath: started.requestPath, + now: "2026-08-17T12:02:00.000Z", + }); + + assert.equal(committed.validation.valid, false); + assert.equal(committed.validation.status, "protocol_error"); + assert.equal(committed.state.executionUnits[0].status, "protocol_error"); + assert.equal(committed.state.status, "failed"); + assert.equal(committed.state.progress.failed, 1); +}); + +test("unapproved or conflicting attempts fail closed", (t) => { + const fixture = createFixture(t); + assert.throws( + () => startAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + executionUnitId: "orders/api", + input: { userRequest: "Assess", phaseApproved: false }, + }), + /not approved/, + ); + const started = start(fixture); + const replayed = start(fixture); + assert.equal(replayed.requestPath, started.requestPath); + assert.equal(replayed.state.revision, started.state.revision); + assert.throws( + () => startAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + executionUnitId: "orders/api", + input: { + userRequest: "Assess selected repositories", + phaseApproved: true, + decisions: CONFIGURED_DECISIONS, + }, + invocationId: "22222222-2222-4222-8222-222222222222", + }), + (error) => error.code === "attempt_artifact_conflict", + ); +}); + +test("commit rejects request identity tampering before validation", (t) => { + const fixture = createFixture(t); + const started = start(fixture); + const original = JSON.parse(fs.readFileSync(started.requestPath, "utf8")); + + fs.writeFileSync(started.requestPath, `${JSON.stringify({ ...original, repoId: "billing" }, null, 2)}\n`); + assert.throws( + () => commitAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + requestPath: started.requestPath, + }), + (error) => error.code === "attempt_request_mismatch" && /repoId/.test(error.message), + ); + + fs.writeFileSync(started.requestPath, `${JSON.stringify({ ...original, batchId: "other-batch" }, null, 2)}\n`); + assert.throws( + () => commitAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + requestPath: started.requestPath, + }), + (error) => error.code === "attempt_request_mismatch" && /batchId/.test(error.message), + ); + assert.equal(fs.existsSync(path.join(path.dirname(started.requestPath), "validation.json")), false); + assert.equal(readState(fixture.batchRoot).executionUnits[0].status, "running"); +}); + +test("lease session releases ownership when the initial attempt cannot start", async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-lease-start-failure-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const workspacePath = path.join(root, "orders"); + fs.mkdirSync(workspacePath); + const batchRoot = path.join(root, ".github", "modernize", "batches", "lease-start-failure"); + initializeApprovedAssessmentBatch({ + batchRoot, + resolvedConfig: resolvedConfig(root, [{ + repoId: "orders", + workspacePath, + executionUnits: [assessmentUnit({ repoId: "orders", workspacePath, language: "java" })], + }]), + selection: { executionUnitIds: ["orders"], approvedNeedsAttention: [] }, + input: { + batchId: "lease-start-failure", + userRequest: "Assess orders", + phaseApproved: true, + inputArtifacts: {}, + decisions: { domains: ["cloud-readiness"], analysisCoverage: "issue-only", maxConcurrency: 1 }, + }, + }); + + await assert.rejects( + openLeaseSession({ + batchRoot, + invocationId: "failed-session", + executionUnitId: "unknown-unit", + }), + /not scheduled/, + ); + assert.equal(readLease(batchRoot), null); + assert.equal(readState(batchRoot).status, "ready"); +}); + +test("start replay repairs state and event after request persistence", (t) => { + const fixture = createFixture(t); + assert.throws( + () => startAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + executionUnitId: "orders/api", + input: { + userRequest: "Assess selected repositories", + phaseApproved: true, + decisions: CONFIGURED_DECISIONS, + }, + invocationId: "33333333-3333-4333-8333-333333333333", + checkpoint: (step) => { + if (step === "request") throw new Error("simulated crash after request"); + }, + }), + /simulated crash after request/, + ); + assert.equal(readState(fixture.batchRoot).executionUnits[0].status, "pending"); + + assert.throws( + () => startAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + executionUnitId: "orders/api", + input: { + userRequest: "Assess selected repositories", + phaseApproved: true, + decisions: CONFIGURED_DECISIONS, + }, + checkpoint: (step) => { + if (step === "state") throw new Error("simulated crash after start state"); + }, + }), + /simulated crash after start state/, + ); + assert.equal(readState(fixture.batchRoot).executionUnits[0].status, "running"); + assert.equal(fs.readFileSync(path.join(fixture.batchRoot, "events.jsonl"), "utf8"), ""); + + const recovered = startAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + executionUnitId: "orders/api", + input: { + userRequest: "Assess selected repositories", + phaseApproved: true, + decisions: CONFIGURED_DECISIONS, + }, + }); + assert.equal(recovered.request.invocationId, "33333333-3333-4333-8333-333333333333"); + assert.equal(recovered.state.executionUnits[0].status, "running"); + const replayed = startAttempt({ + batchRoot: fixture.batchRoot, + ownerToken: fixture.ownerToken, + executionUnitId: "orders/api", + input: { + userRequest: "Assess selected repositories", + phaseApproved: true, + decisions: CONFIGURED_DECISIONS, + }, + }); + assert.equal(replayed.state.revision, recovered.state.revision); + const events = fs.readFileSync(path.join(fixture.batchRoot, "events.jsonl"), "utf8") + .trim() + .split(/\r?\n/) + .map((line) => JSON.parse(line)); + assert.equal(events.filter((event) => event.type === "attempt_started").length, 1); +}); + +test("initialization derives pending units only from approved preflight selections", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-initialize-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const workspacePath = path.join(root, "workspace"); + fs.mkdirSync(workspacePath); + const unit = { + schemaVersion: 1, + repoId: "orders", + executionUnitId: "orders", + displayName: "orders", + workspacePath, + gitRoot: workspacePath, + scopeRoots: [workspacePath], + languages: ["java"], + source: "repository-root", + }; + const resolvedConfig = { + schemaVersion: 1, + configPath: path.join(root, "repos.json"), + configSha256: "a".repeat(64), + producer: null, + repositories: [{ + repoId: "orders", + name: "orders", + input: { url: null, path: workspacePath, branch: null, includePaths: [] }, + workspacePath, + preflightStatus: "needs_attention", + warnings: ["local non-Git workspace"], + errors: [], + executionUnits: [unit], + unknownFields: {}, + }], + apps: [], + unknownFields: {}, + }; + const input = { + batchId: "batch-approved", + userRequest: "Assess orders", + phaseApproved: true, + inputArtifacts: {}, + decisions: { domains: ["cloud-readiness"], analysisCoverage: "issue-only", maxConcurrency: 1 }, + }; + assert.throws( + () => initializeApprovedAssessmentBatch({ + batchRoot: path.join(root, "unapproved"), + resolvedConfig, + selection: { executionUnitIds: ["orders"], approvedNeedsAttention: [] }, + input, + }), + /explicit attention approval/, + ); + + const initialized = initializeApprovedAssessmentBatch({ + batchRoot: path.join(root, "approved"), + resolvedConfig, + selection: { executionUnitIds: ["orders"], approvedNeedsAttention: ["orders"] }, + input, + now: "2026-08-17T11:00:00.000Z", + }); + assert.equal(initialized.state.status, "ready"); + assert.equal(initialized.state.executionUnits[0].status, "pending"); + assert.deepEqual(initialized.manifest.selectedExecutionUnitIds, ["orders"]); +}); + +test("initialization rejects authority substituted outside the approved Review", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-review-authority-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const alphaPath = path.join(root, "alpha"); + const betaPath = path.join(root, "beta"); + fs.mkdirSync(alphaPath); + fs.mkdirSync(betaPath); + const reviewedConfig = resolvedConfig(root, [ + { + repoId: "alpha", + workspacePath: alphaPath, + executionUnits: [assessmentUnit({ repoId: "alpha", workspacePath: alphaPath, language: "java" })], + }, + { + repoId: "beta", + workspacePath: betaPath, + executionUnits: [assessmentUnit({ repoId: "beta", workspacePath: betaPath, language: "java" })], + }, + ]); + const decisions = { domains: ["cloud-readiness"], analysisCoverage: "issue-only", maxConcurrency: 1 }; + const selection = { executionUnitIds: ["alpha"], approvedNeedsAttention: [] }; + const batchRoot = path.join(root, "approved-batch"); + const reviewFiles = approvedReviewFiles({ + batchRoot, + resolvedConfig: reviewedConfig, + batchId: "approved-batch", + selection, + decisions, + }); + const initialization = { + batchRoot, + resolvedConfig: reviewedConfig, + ...reviewFiles, + input: { userRequest: "Assess approved repositories" }, + }; + + assert.throws( + () => initializeAssessmentBatch({ + ...initialization, + selection: { executionUnitIds: ["beta"], approvedNeedsAttention: [] }, + }), + (error) => error.code === "invalid_initialization_authority", + ); + assert.throws( + () => initializeAssessmentBatch({ + ...initialization, + input: { ...initialization.input, decisions: { ...decisions, analysisCoverage: "full" } }, + }), + (error) => error.code === "invalid_attempt_input" && /decisions/.test(error.message), + ); + assert.throws( + () => initializeAssessmentBatch({ ...initialization, reviewSha256: "0".repeat(64) }), + (error) => error.code === "review_digest_mismatch", + ); + + const substitutedConfig = structuredClone(reviewedConfig); + substitutedConfig.repositories[0].workspacePath = betaPath; + substitutedConfig.repositories[0].input.path = betaPath; + substitutedConfig.repositories[0].executionUnits[0] = assessmentUnit({ + repoId: "alpha", + workspacePath: betaPath, + language: "java", + }); + assert.throws( + () => initializeAssessmentBatch({ ...initialization, resolvedConfig: substitutedConfig }), + (error) => error.code === "review_selection_mismatch", + ); + assert.equal(fs.existsSync(path.join(batchRoot, "manifest.json")), false); +}); + +test("URL-only Review flows through approved clone, reinspection, and initialization", async (t) => { + const launchRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-remote-flow-")); + t.after(() => fs.rmSync(launchRoot, { recursive: true, force: true })); + const remoteUrl = "https://github.com/contoso/remote-orders.git"; + const sourcePath = path.join(launchRoot, "clone-source"); + createJavaGitProject(sourcePath, remoteUrl); + const configPath = path.join(launchRoot, "repos.json"); + fs.writeFileSync(configPath, `${JSON.stringify({ + repos: [{ name: "remote-orders", url: remoteUrl, branch: "main" }], + }, null, 2)}\n`); + + const review = prepareBatchReview({ + configPath, + launchRoot, + allowedRoots: [launchRoot], + batchId: "remote-flow", + }); + assert.equal(review.status, "ready_for_approval"); + assert.deepEqual(review.selectedExecutionUnitIds, ["remote-orders"]); + assert.deepEqual(review.effectiveAssessments[0].languages, undefined); + assert.equal(review.effectiveAssessments[0].language, "unknown"); + + const resolved = JSON.parse(fs.readFileSync(review.resolvedReposPath, "utf8")); + const targetPath = resolved.repositories[0].workspacePath; + cloneRepository({ + url: remoteUrl, + targetPath, + allowedRoot: launchRoot, + branch: "main", + spawnSyncImpl: (_command, args) => { + fs.cpSync(sourcePath, args.at(-1), { recursive: true }); + return { status: 0, stdout: "", stderr: "" }; + }, + }); + const inspected = inspectResolvedRepositories(resolved, { allowedRoots: [launchRoot] }); + assert.equal(inspected.repositories[0].preflightStatus, "ready"); + assert.deepEqual(inspected.repositories[0].executionUnits[0].languages, ["java"]); + + const runtimeBatchAttempt = await import(pathToFileURL(review.batchAttemptScriptPath).href); + const initialized = runtimeBatchAttempt.initializeAssessmentBatch({ + batchRoot: review.batchRoot, + resolvedConfig: inspected, + reviewPath: review.reviewPath, + reviewSha256: review.reviewSha256, + reviewMarkdownSha256: review.reviewMarkdownSha256, + input: { userRequest: "Assess my app" }, + }); + assert.deepEqual(initialized.manifest.selectedExecutionUnitIds, ["remote-orders"]); + assert.deepEqual(initialized.manifest.assessment.decisions, { + analysisCoverage: "issue-only", + maxConcurrency: 1, + }); + assert.equal(initialized.state.executionUnits[0].status, "pending"); +}); + +test("Java, .NET, and TypeScript attempts use their Single defaults", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-mixed-assessment-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const specifications = [ + { + repoId: "java-orders", + language: "java", + status: "completed", + trackedFindings: 3, + bySeverity: { critical: 1, high: 1, medium: 1, low: 0, info: 0 }, + byState: { new: 2, accepted: 1 }, + topRecommendation: { + kind: "security", + summary: "Address critical Java vulnerability", + next_action: "create-modernization-plan", + prefilled_prompt: "Fix the Java vulnerability", + }, + }, + { + repoId: "dotnet-billing", + language: "dotnet", + status: "completed_with_issues", + trackedFindings: 2, + bySeverity: { critical: 0, high: 1, medium: 0, low: 1, info: 0 }, + byState: { new: 1, resolved: 1 }, + topRecommendation: { + kind: "readiness", + summary: "Move billing configuration out of process", + next_action: "create-modernization-plan", + prefilled_prompt: "Plan billing configuration migration", + }, + }, + { + repoId: "typescript-portal", + language: "typescript", + status: "completed", + trackedFindings: 1, + bySeverity: { critical: 0, high: 0, medium: 0, low: 0, info: 1 }, + byState: { new: 1 }, + topRecommendation: { + kind: "generic", + summary: "Update the portal dependencies", + next_action: null, + prefilled_prompt: null, + }, + }, + ]; + const repositories = specifications.map((specification) => { + const workspacePath = path.join(root, specification.repoId); + fs.mkdirSync(workspacePath); + return { + repoId: specification.repoId, + workspacePath, + executionUnits: [assessmentUnit({ + repoId: specification.repoId, + workspacePath, + language: specification.language, + })], + }; + }); + const batchRoot = path.join(root, ".github", "modernize", "batches", "mixed-assessment"); + initializeApprovedAssessmentBatch({ + batchRoot, + resolvedConfig: resolvedConfig(root, repositories), + selection: { executionUnitIds: specifications.map(({ repoId }) => repoId), approvedNeedsAttention: [] }, + input: { + batchId: "mixed-assessment", + userRequest: "Assess all selected repositories", + phaseApproved: true, + inputArtifacts: {}, + decisions: { analysisCoverage: "full", maxConcurrency: 1 }, + }, + now: "2026-08-17T14:00:00.000Z", + }); + const { ownerToken } = acquireLease({ batchRoot, invocationId: "mixed-coordinator" }); + const requestDirectories = []; + + for (const [index, specification] of specifications.entries()) { + const started = startAttempt({ + batchRoot, + ownerToken, + executionUnitId: specification.repoId, + invocationId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, + now: `2026-08-17T14:0${index}:00.000Z`, + }); + requestDirectories.push(path.dirname(started.requestPath)); + assert.deepEqual(started.request.decisions.domains, [ + ["java-upgrade", "cloud-readiness"], + ["cloud-readiness"], + [], + ][index]); + assert.equal(readState(batchRoot).executionUnits.filter((unit) => unit.status === "running").length, 1); + const artifacts = createAssessmentArtifacts(started, specification.repoId, specification); + publishAttemptResult({ + requestPath: started.requestPath, + outcome: { + status: specification.status, + artifacts, + evidence: { + artifactValidation: "passed", + ...(specification.language === "typescript" ? { planningSupported: false } : {}), + }, + needsInput: null, + error: null, + }, + now: `2026-08-17T14:0${index}:30.000Z`, + }); + const committed = commitAttempt({ + batchRoot, + ownerToken, + requestPath: started.requestPath, + now: `2026-08-17T14:0${index}:45.000Z`, + }); + assert.equal(committed.validation.valid, true, committed.validation.errors.join("\n")); + } + + assert.equal(new Set(requestDirectories).size, specifications.length); + assert.deepEqual( + readState(batchRoot).executionUnits.map(({ status }) => status), + ["completed", "completed_with_issues", "completed"], + ); + const typescriptResult = JSON.parse(fs.readFileSync( + path.join(requestDirectories[2], "result.json"), + "utf8", + )); + assert.equal(typescriptResult.evidence.planningSupported, false); + + const finalized = finalizeAssessmentBatch({ + batchRoot, + ownerToken, + now: "2026-08-17T14:04:00.000Z", + }); + assert.equal(finalized.summary.status, "completed_with_issues"); + assert.deepEqual(finalized.summary.counts, { + total: 3, + completed: 2, + completedWithIssues: 1, + failed: 0, + }); + const aggregate = JSON.parse(fs.readFileSync(finalized.paths.aggregateReport, "utf8")); + const extension = aggregate.extensions["github-copilot-modernization"]; + assert.equal(extension.counts.total, 3); + assert.deepEqual(extension.counts.bySeverity, { + critical: 1, + high: 2, + medium: 1, + low: 1, + info: 1, + }); + assert.deepEqual(extension.counts.byState, { accepted: 1, new: 4, resolved: 1 }); + assert.deepEqual(extension.planningSupported, { supported: 2, unsupported: 1, unavailable: 0 }); + assert.deepEqual( + extension.topRecommendations.map(({ identity, summary }) => ({ identity, summary })), + [ + { identity: "java-orders", summary: "Address critical Java vulnerability" }, + { identity: "dotnet-billing", summary: "Move billing configuration out of process" }, + { identity: "typescript-portal", summary: "Update the portal dependencies" }, + ], + ); + assert.equal(extension.repositories[0].planningSupported, true); + assert.equal(extension.repositories[2].planningSupported, false); + assert.deepEqual(extension.repositories[0].findings.byState, { accepted: 1, new: 2 }); + assert.equal(extension.repositories[0].topRecommendation.summary, "Address critical Java vulnerability"); + assert.equal(aggregate.summary.totalProjects, 3); + assert.deepEqual( + extension.repositories.map(({ status }) => status), + ["completed", "completed_with_issues", "completed"], + ); + assert.equal(aggregate.metadata.repos[2].language, "typescript"); + assert.deepEqual(finalized.summary.findings.bySeverity, extension.counts.bySeverity); + assert.deepEqual(finalized.summary.findings.byState, extension.counts.byState); + assert.deepEqual(finalized.summary.planningSupported, extension.planningSupported); + assert.match(fs.readFileSync(finalized.paths.reportIndex, "utf8"), /Address critical Java vulnerability/); + assert.match(fs.readFileSync(finalized.paths.markdown, "utf8"), /Critical: 1/); +}); + +test("initialization rejects a mixed-language execution unit before state creation", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-mixed-language-unit-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const workspacePath = path.join(root, "workspace"); + fs.mkdirSync(workspacePath); + const batchRoot = path.join(root, "batch"); + const repository = { + repoId: "mixed", + workspacePath, + executionUnits: [{ + ...assessmentUnit({ repoId: "mixed", workspacePath, language: "java" }), + languages: ["java", "typescript"], + }], + }; + + assert.throws( + () => initializeApprovedAssessmentBatch({ + batchRoot, + resolvedConfig: resolvedConfig(root, [repository]), + selection: { executionUnitIds: ["mixed"], approvedNeedsAttention: [] }, + input: { + batchId: "mixed-language-unit", + userRequest: "Assess mixed", + phaseApproved: true, + decisions: { analysisCoverage: "issue-only", maxConcurrency: 1 }, + }, + }), + /exactly one supported language/, + ); + assert.equal(fs.existsSync(path.join(batchRoot, "manifest.json")), false); + assert.equal(fs.existsSync(path.join(batchRoot, "state.json")), false); +}); + +test("initialization rejects unknown decisions before state creation", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-unknown-decision-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const workspacePath = path.join(root, "workspace"); + fs.mkdirSync(workspacePath); + const batchRoot = path.join(root, "batch"); + const repository = { + repoId: "orders", + workspacePath, + executionUnits: [assessmentUnit({ repoId: "orders", workspacePath, language: "java" })], + }; + + assert.throws( + () => initializeApprovedAssessmentBatch({ + batchRoot, + resolvedConfig: resolvedConfig(root, [repository]), + selection: { executionUnitIds: ["orders"], approvedNeedsAttention: [] }, + input: { + batchId: "unknown-decision", + userRequest: "Assess orders", + phaseApproved: true, + decisions: { + domains: ["cloud-readiness"], + analysisCoverage: "issue-only", + maxConcurrency: 1, + repositoryScheduling: "sequential", + }, + }, + }), + /unsupported fields: repositoryScheduling/, + ); + assert.equal(fs.existsSync(path.join(batchRoot, "manifest.json")), false); + assert.equal(fs.existsSync(path.join(batchRoot, "state.json")), false); +}); + +test("initialization rejects include-path units before creating batch state", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-include-paths-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const gitRoot = path.join(root, "portfolio"); + const apiPath = path.join(gitRoot, "services", "api"); + const webPath = path.join(gitRoot, "services", "web"); + fs.mkdirSync(apiPath, { recursive: true }); + fs.mkdirSync(webPath, { recursive: true }); + const units = [ + assessmentUnit({ + repoId: "portfolio", + executionUnitId: "portfolio/services-api", + workspacePath: apiPath, + gitRoot, + language: "java", + source: "include-path", + }), + assessmentUnit({ + repoId: "portfolio", + executionUnitId: "portfolio/services-web", + workspacePath: webPath, + gitRoot, + language: "typescript", + source: "include-path", + }), + ]; + const batchRoot = path.join(root, "batch"); + assert.throws( + () => initializeApprovedAssessmentBatch({ + batchRoot, + resolvedConfig: resolvedConfig(root, [{ + repoId: "portfolio", + workspacePath: gitRoot, + executionUnits: units, + includePaths: ["services/api", "services/web"], + }]), + selection: { + executionUnitIds: units.map(({ executionUnitId }) => executionUnitId), + approvedNeedsAttention: [], + }, + input: { + batchId: "include-paths", + userRequest: "Assess both portfolio services", + phaseApproved: true, + inputArtifacts: {}, + decisions: { domains: ["cloud-readiness"], analysisCoverage: "issue-only", maxConcurrency: 1 }, + }, + }), + (error) => error.code === "unsupported_execution_unit_source" + && /portfolio\/services-api/.test(error.message) + && /whole repositories only/.test(error.message), + ); + assert.equal(fs.existsSync(path.join(batchRoot, "manifest.json")), false); + assert.equal(fs.existsSync(path.join(batchRoot, "state.json")), false); + assert.equal(fs.existsSync(path.join(batchRoot, "events.jsonl")), false); + + const cliRoot = path.join(root, "cli"); + const resolvedPath = path.join(root, "resolved.json"); + const inputPath = path.join(root, "input.json"); + const inspectedConfig = resolvedConfig(root, [{ + repoId: "portfolio", + workspacePath: gitRoot, + executionUnits: units, + includePaths: ["services/api", "services/web"], + }]); + fs.writeFileSync(resolvedPath, `${JSON.stringify(inspectedConfig, null, 2)}\n`); + const reviewFiles = approvedReviewFiles({ + batchRoot: cliRoot, + resolvedConfig: inspectedConfig, + batchId: "include-path-cli", + selection: { + executionUnitIds: [units[0].executionUnitId], + approvedNeedsAttention: [], + }, + decisions: { domains: ["cloud-readiness"], analysisCoverage: "issue-only", maxConcurrency: 1 }, + }); + fs.writeFileSync(inputPath, `${JSON.stringify({ + userRequest: "Assess one portfolio service", + }, null, 2)}\n`); + const cliResult = runBatchAttemptCli([ + "initialize-assessment", + "--batch-root", cliRoot, + "--resolved", resolvedPath, + "--review", reviewFiles.reviewPath, + "--review-sha256", reviewFiles.reviewSha256, + "--review-markdown-sha256", reviewFiles.reviewMarkdownSha256, + "--input", inputPath, + ]); + assert.equal(cliResult.status, 1); + assert.match(cliResult.stderr, /unsupported_execution_unit_source/); + assert.equal(fs.existsSync(path.join(cliRoot, "manifest.json")), false); +}); \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/batch-state.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/batch-state.mjs new file mode 100644 index 0000000..9e61f74 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/batch-state.mjs @@ -0,0 +1,594 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { fileURLToPath } from "node:url"; + +import { sanitizeGitUrl } from "./resolve-repos.mjs"; +import { validateSchema } from "./schema-validator.mjs"; + +const scriptRoot = path.dirname(fileURLToPath(import.meta.url)); +const stateSchemaPath = path.resolve(scriptRoot, "..", "schemas", "batch-state.schema.json"); +const eventSchemaPath = path.resolve(scriptRoot, "..", "schemas", "event.schema.json"); +const stateSchema = JSON.parse(fs.readFileSync(stateSchemaPath, "utf8")); +const eventSchema = JSON.parse(fs.readFileSync(eventSchemaPath, "utf8")); + +export class BatchStateError extends Error { + constructor(message, code = "batch_state_error") { + super(message); + this.name = "BatchStateError"; + this.code = code; + } +} + +function jsonText(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function fsyncDirectory(directory) { + try { + const descriptor = fs.openSync(directory, "r"); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + } catch { + // Windows can reject directory fsync; the file itself is still flushed. + } +} + +export function atomicWriteFile(filePath, content) { + const absolutePath = path.resolve(filePath); + const directory = path.dirname(absolutePath); + fs.mkdirSync(directory, { recursive: true }); + const temporaryPath = path.join( + directory, + `.${path.basename(absolutePath)}.${process.pid}.${crypto.randomUUID()}.tmp`, + ); + const descriptor = fs.openSync(temporaryPath, "wx", 0o600); + try { + fs.writeFileSync(descriptor, content, "utf8"); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + try { + fs.renameSync(temporaryPath, absolutePath); + fsyncDirectory(directory); + } catch (error) { + fs.rmSync(temporaryPath, { force: true }); + throw error; + } +} + +export function atomicWriteJson(filePath, value) { + atomicWriteFile(filePath, jsonText(value)); +} + +function writeExclusiveJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const descriptor = fs.openSync(filePath, "wx", 0o600); + try { + fs.writeFileSync(descriptor, jsonText(value), "utf8"); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + fsyncDirectory(path.dirname(filePath)); +} + +function createExclusiveEmptyFile(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const descriptor = fs.openSync(filePath, "wx", 0o600); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + fsyncDirectory(path.dirname(filePath)); +} + +function sha256(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function ownerDigest(ownerToken) { + return `sha256:${sha256(ownerToken)}`; +} + +function pathsFor(batchRoot) { + const root = path.resolve(batchRoot); + return { + root, + manifest: path.join(root, "manifest.json"), + lease: path.join(root, "lease.json"), + takeoverLock: path.join(root, ".takeover.lock"), + state: path.join(root, "state.json"), + events: path.join(root, "events.jsonl"), + summaryJson: path.join(root, "summary.json"), + summaryMarkdown: path.join(root, "summary.md"), + repos: path.join(root, "repos"), + attempts: path.join(root, "attempts"), + }; +} + +function readJson(filePath, label) { + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + throw new BatchStateError(`Unable to read ${label}: ${error.message}`, "invalid_json"); + } + if (parsed?.schemaVersion !== 1) { + throw new BatchStateError( + `Unsupported ${label} schemaVersion: ${JSON.stringify(parsed?.schemaVersion)}`, + "unsupported_schema", + ); + } + return parsed; +} + +function walkStrings(value, visit) { + if (typeof value === "string") { + visit(value); + } else if (Array.isArray(value)) { + value.forEach((entry) => walkStrings(entry, visit)); + } else if (value && typeof value === "object") { + Object.values(value).forEach((entry) => walkStrings(entry, visit)); + } +} + +function findSecretKeys(value, currentPath = "$") { + const findings = []; + if (Array.isArray(value)) { + value.forEach((entry, index) => findings.push(...findSecretKeys(entry, `${currentPath}[${index}]`))); + } else if (value && typeof value === "object") { + for (const [key, entry] of Object.entries(value)) { + const entryPath = `${currentPath}.${key}`; + if ( + /(?:password|passwd|token|secret|credential|api[-_]?key|access[-_]?key)$/i.test(key) + && entry !== "" + && entry !== null + ) { + findings.push(entryPath); + } else { + findings.push(...findSecretKeys(entry, entryPath)); + } + } + } + return findings; +} + +export function assertSafePersistedValue(value) { + const secretKeys = findSecretKeys(value); + if (secretKeys.length > 0) { + throw new BatchStateError( + `Persisted batch values must not contain credential fields: ${secretKeys.join(", ")}`, + "unsafe_persisted_value", + ); + } + walkStrings(value, (text) => { + const urls = text.match(/(?:https?|ssh):\/\/[^\s'"<>]+/gi) ?? []; + for (const candidate of urls) { + let parsed; + try { + parsed = new URL(candidate); + } catch { + continue; + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new BatchStateError("Persisted batch values must not contain URL credentials, query, or fragment", "unsafe_persisted_value"); + } + try { + sanitizeGitUrl(candidate); + } catch { + // Non-Git HTTPS strings are allowed when they have no secret-bearing components. + } + } + }); +} + +function assertDocumentSchema(value, schema, schemaPath, label) { + const errors = validateSchema(value, schema, schemaPath); + if (errors.length > 0) { + throw new BatchStateError( + `${label} violates its v1 schema: ${errors.join("; ")}`, + "schema_validation_failed", + ); + } +} + +export function initializeBatch({ batchRoot, manifest, state, now = new Date().toISOString() } = {}) { + const files = pathsFor(batchRoot); + fs.mkdirSync(files.root, { recursive: true }); + fs.mkdirSync(files.repos, { recursive: true }); + fs.mkdirSync(files.attempts, { recursive: true }); + const existingControlFile = [ + files.manifest, + files.state, + files.events, + files.lease, + files.summaryJson, + files.summaryMarkdown, + ].find((filePath) => fs.existsSync(filePath)); + if (existingControlFile) { + throw new BatchStateError( + `Batch control file already exists: ${path.basename(existingControlFile)}`, + "batch_exists", + ); + } + const persistedManifest = { ...manifest, schemaVersion: 1 }; + assertSafePersistedValue(persistedManifest); + const persistedState = { + schemaVersion: 1, + batchId: persistedManifest.batchId, + status: "draft", + revision: 0, + createdAt: now, + updatedAt: now, + activeLeaseDigest: null, + executionUnits: [], + progress: { wave: 1, eligible: 0, terminal: 0, successful: 0, issues: 0, failed: 0 }, + ...state, + schemaVersion: 1, + batchId: persistedManifest.batchId, + }; + if (persistedState.batchId !== persistedManifest.batchId) { + throw new BatchStateError("Manifest and state batch IDs must match", "batch_id_mismatch"); + } + assertSafePersistedValue(persistedState); + assertDocumentSchema(persistedState, stateSchema, stateSchemaPath, "Batch state"); + try { + writeExclusiveJson(files.manifest, persistedManifest); + atomicWriteJson(files.state, persistedState); + if (!fs.existsSync(files.events)) createExclusiveEmptyFile(files.events); + } catch (error) { + fs.rmSync(files.manifest, { force: true }); + fs.rmSync(files.state, { force: true }); + fs.rmSync(files.events, { force: true }); + throw error; + } + return { manifestPath: files.manifest, statePath: files.state }; +} + +function writeLeaseExclusive(leasePath, lease) { + try { + writeExclusiveJson(leasePath, lease); + } catch (error) { + if (error.code === "EEXIST") { + throw new BatchStateError("Batch lease is already held", "lease_held"); + } + throw error; + } +} + +export function acquireLease({ batchRoot, invocationId, now = new Date().toISOString() } = {}) { + const files = pathsFor(batchRoot); + readJson(files.manifest, "batch manifest"); + readJson(files.state, "batch state"); + const ownerToken = crypto.randomBytes(32).toString("base64url"); + const lease = { + schemaVersion: 1, + ownerTokenDigest: ownerDigest(ownerToken), + acquiredAt: now, + lastHeartbeat: now, + invocationId, + schedulingAllowed: true, + takeoverOf: null, + }; + writeLeaseExclusive(files.lease, lease); + return { ownerToken, lease, leaseFileDigest: fileDigest(files.lease) }; +} + +export function fileDigest(filePath) { + return `sha256:${sha256(fs.readFileSync(filePath))}`; +} + +export function readLease(batchRoot) { + const leasePath = pathsFor(batchRoot).lease; + if (!fs.existsSync(leasePath)) return null; + const lease = readJson(leasePath, "lease"); + return { ...lease, leaseFileDigest: fileDigest(leasePath) }; +} + +export function assertLeaseOwner(batchRoot, ownerToken) { + if (!ownerToken) throw new BatchStateError("Owner token is required", "owner_token_required"); + const lease = readLease(batchRoot); + if (!lease) throw new BatchStateError("Batch lease is not held", "lease_missing"); + const supplied = Buffer.from(ownerDigest(ownerToken)); + const expected = Buffer.from(lease.ownerTokenDigest); + if (supplied.length !== expected.length || !crypto.timingSafeEqual(supplied, expected)) { + throw new BatchStateError("Owner token does not match the active lease", "owner_token_mismatch"); + } + return lease; +} + +function withExclusiveLock(lockPath, action) { + let descriptor; + try { + descriptor = fs.openSync(lockPath, "wx", 0o600); + } catch (error) { + if (error.code === "EEXIST") { + throw new BatchStateError("Another lease mutation is in progress", "lease_mutation_locked"); + } + throw error; + } + try { + return action(); + } finally { + fs.closeSync(descriptor); + fs.rmSync(lockPath, { force: true }); + } +} + +export function heartbeatLease({ batchRoot, ownerToken, now = new Date().toISOString() } = {}) { + const files = pathsFor(batchRoot); + return withExclusiveLock(files.takeoverLock, () => { + const lease = assertLeaseOwner(batchRoot, ownerToken); + const updated = { ...lease, lastHeartbeat: now }; + delete updated.leaseFileDigest; + atomicWriteJson(files.lease, updated); + return updated; + }); +} + +export function releaseLease({ batchRoot, ownerToken } = {}) { + const files = pathsFor(batchRoot); + return withExclusiveLock(files.takeoverLock, () => { + assertLeaseOwner(batchRoot, ownerToken); + fs.rmSync(files.lease); + fsyncDirectory(files.root); + return { released: true }; + }); +} + +export function takeoverLease({ + batchRoot, + expectedLeaseDigest, + invocationId, + now = new Date().toISOString(), +} = {}) { + const files = pathsFor(batchRoot); + return withExclusiveLock(files.takeoverLock, () => { + const current = readLease(batchRoot); + if (!current) throw new BatchStateError("No lease exists to take over", "lease_missing"); + if (current.leaseFileDigest !== expectedLeaseDigest) { + throw new BatchStateError("Lease changed before takeover", "lease_compare_failed"); + } + const ownerToken = crypto.randomBytes(32).toString("base64url"); + const lease = { + schemaVersion: 1, + ownerTokenDigest: ownerDigest(ownerToken), + acquiredAt: now, + lastHeartbeat: now, + invocationId, + schedulingAllowed: false, + takeoverOf: expectedLeaseDigest, + }; + atomicWriteJson(files.lease, lease); + return { ownerToken, lease, leaseFileDigest: fileDigest(files.lease) }; + }); +} + +export function assertSchedulingAllowed(batchRoot, ownerToken) { + const lease = assertLeaseOwner(batchRoot, ownerToken); + if (!lease.schedulingAllowed) { + throw new BatchStateError( + "Takeover lease is read-only until worker fencing is implemented", + "scheduling_fenced", + ); + } + return lease; +} + +function assertWritableLease(batchRoot, ownerToken) { + return assertSchedulingAllowed(batchRoot, ownerToken); +} + +export function readState(batchRoot) { + return readJson(pathsFor(batchRoot).state, "batch state"); +} + +export function updateState({ batchRoot, ownerToken, mutate, now = new Date().toISOString() } = {}) { + const files = pathsFor(batchRoot); + return withExclusiveLock(files.takeoverLock, () => { + const lease = assertWritableLease(batchRoot, ownerToken); + const current = readState(batchRoot); + const next = mutate(structuredClone(current)); + if (!next || next.schemaVersion !== 1 || next.batchId !== current.batchId) { + throw new BatchStateError("State mutation changed immutable protocol fields", "invalid_state_mutation"); + } + next.revision = current.revision + 1; + next.updatedAt = now; + next.activeLeaseDigest = lease.ownerTokenDigest; + assertSafePersistedValue(next); + assertDocumentSchema(next, stateSchema, stateSchemaPath, "Batch state"); + atomicWriteJson(files.state, next); + return next; + }); +} + +function readEventLog(eventsPath) { + if (!fs.existsSync(eventsPath)) return []; + const lines = fs.readFileSync(eventsPath, "utf8").split(/\r?\n/).filter(Boolean); + const events = []; + for (const [index, line] of lines.entries()) { + try { + events.push(JSON.parse(line)); + } catch (error) { + throw new BatchStateError(`Event log is corrupt at line ${index + 1}: ${error.message}`, "invalid_event_log"); + } + } + return events; +} + +function sameEvent(existing, candidate) { + return existing.type === candidate.type + && existing.repoId === candidate.repoId + && existing.executionUnitId === candidate.executionUnitId + && existing.invocationId === candidate.invocationId + && isDeepStrictEqual(existing.payload, candidate.payload); +} + +export function appendEvent({ + batchRoot, + ownerToken, + event, + operationKey, + now = new Date().toISOString(), +} = {}) { + const files = pathsFor(batchRoot); + return withExclusiveLock(files.takeoverLock, () => { + assertWritableLease(batchRoot, ownerToken); + const events = readEventLog(files.events); + const candidate = { + type: event.type, + repoId: event.repoId ?? null, + executionUnitId: event.executionUnitId ?? null, + invocationId: event.invocationId ?? null, + payload: operationKey + ? { ...(event.payload ?? {}), operationKey } + : event.payload ?? {}, + }; + if (operationKey) { + const existing = events.find((entry) => entry.payload?.operationKey === operationKey); + if (existing) { + if (!sameEvent(existing, candidate)) { + throw new BatchStateError("Event operation key is already bound to different content", "event_operation_conflict"); + } + return existing; + } + } + const persisted = { + schemaVersion: 1, + eventId: crypto.randomUUID(), + sequence: events.length === 0 ? 1 : Number(events.at(-1).sequence) + 1, + batchId: readState(batchRoot).batchId, + ...candidate, + at: now, + }; + assertSafePersistedValue(persisted); + assertDocumentSchema(persisted, eventSchema, eventSchemaPath, "Batch event"); + const descriptor = fs.openSync(files.events, "a", 0o600); + try { + fs.writeFileSync(descriptor, `${JSON.stringify(persisted)}\n`, "utf8"); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + return persisted; + }); +} + +export function writeRepoState({ batchRoot, ownerToken, repoId, state } = {}) { + const files = pathsFor(batchRoot); + return withExclusiveLock(files.takeoverLock, () => { + assertWritableLease(batchRoot, ownerToken); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(repoId ?? "")) { + throw new BatchStateError("Repository state ID is not a safe filename", "invalid_repo_id"); + } + if (state.schemaVersion !== 1 || state.repoId !== repoId) { + throw new BatchStateError("Repository state identity is invalid", "invalid_repo_state"); + } + assertSafePersistedValue(state); + const filePath = path.join(files.repos, `${repoId}.json`); + atomicWriteJson(filePath, state); + return filePath; + }); +} + +export function writeSummary({ batchRoot, ownerToken, summary, markdown } = {}) { + const files = pathsFor(batchRoot); + return withExclusiveLock(files.takeoverLock, () => { + assertWritableLease(batchRoot, ownerToken); + if (summary.schemaVersion !== 1) { + throw new BatchStateError("Summary schemaVersion must be 1", "unsupported_schema"); + } + assertSafePersistedValue(summary); + assertSafePersistedValue(markdown); + atomicWriteJson(files.summaryJson, summary); + atomicWriteFile(files.summaryMarkdown, markdown.endsWith("\n") ? markdown : `${markdown}\n`); + return { json: files.summaryJson, markdown: files.summaryMarkdown }; + }); +} + +function optionValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function readInputJson(filePath, label) { + if (!filePath) throw new BatchStateError(`${label} path is required`, "missing_option"); + try { + return JSON.parse(fs.readFileSync(path.resolve(filePath), "utf8")); + } catch (error) { + throw new BatchStateError(`Unable to read ${label}: ${error.message}`, "invalid_json"); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const command = process.argv[2]; + const batchRoot = optionValue("--batch-root"); + try { + let result; + if (command === "initialize") { + const manifest = readInputJson(optionValue("--manifest"), "manifest input"); + const statePath = optionValue("--state"); + const state = statePath ? readInputJson(statePath, "state input") : undefined; + result = initializeBatch({ batchRoot, manifest, state }); + } else if (command === "acquire-lease") { + result = acquireLease({ batchRoot, invocationId: optionValue("--invocation-id") }); + } else if (command === "inspect-lease") { + result = readLease(batchRoot); + } else if (command === "read-state") { + result = readState(batchRoot); + } else if (command === "update-status") { + result = updateState({ + batchRoot, + ownerToken: process.env.BATCH_OWNER_TOKEN, + mutate: (state) => ({ ...state, status: optionValue("--status") }), + }); + } else if (command === "append-event") { + result = appendEvent({ + batchRoot, + ownerToken: process.env.BATCH_OWNER_TOKEN, + event: readInputJson(optionValue("--event"), "event input"), + }); + } else if (command === "write-repo-state") { + result = { + path: writeRepoState({ + batchRoot, + ownerToken: process.env.BATCH_OWNER_TOKEN, + repoId: optionValue("--repo-id"), + state: readInputJson(optionValue("--state"), "repository state input"), + }), + }; + } else if (command === "write-summary") { + const markdownPath = optionValue("--markdown"); + if (!markdownPath) throw new BatchStateError("markdown path is required", "missing_option"); + result = writeSummary({ + batchRoot, + ownerToken: process.env.BATCH_OWNER_TOKEN, + summary: readInputJson(optionValue("--summary"), "summary input"), + markdown: fs.readFileSync(path.resolve(markdownPath), "utf8"), + }); + } else if (command === "assert-scheduling") { + result = assertSchedulingAllowed(batchRoot, process.env.BATCH_OWNER_TOKEN); + } else if (command === "release-lease") { + result = releaseLease({ batchRoot, ownerToken: process.env.BATCH_OWNER_TOKEN }); + } else if (command === "takeover-lease") { + result = takeoverLease({ + batchRoot, + expectedLeaseDigest: optionValue("--expected-digest"), + invocationId: optionValue("--invocation-id"), + }); + } else { + throw new BatchStateError(`Unknown command: ${command}`, "unknown_command"); + } + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + process.stderr.write(`${JSON.stringify({ code: error.code, message: error.message })}\n`); + process.exitCode = error.code === "lease_held" || error.code === "lease_compare_failed" ? 2 : 1; + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/batch-state.test.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/batch-state.test.mjs new file mode 100644 index 0000000..5acaee3 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/batch-state.test.mjs @@ -0,0 +1,338 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + BatchStateError, + acquireLease, + appendEvent, + assertSchedulingAllowed, + initializeBatch, + readLease, + readState, + releaseLease, + takeoverLease, + updateState, + writeRepoState, + writeSummary, +} from "./batch-state.mjs"; + +const scriptPath = fileURLToPath(new URL("./batch-state.mjs", import.meta.url)); + +function createBatch(t, manifest = { batchId: "batch-1", config: { url: "https://example.com/repo.git" } }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-state-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + initializeBatch({ batchRoot: root, manifest }); + return root; +} + +function spawnAcquire(batchRoot, invocationId) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [ + scriptPath, + "acquire-lease", + "--batch-root", + batchRoot, + "--invocation-id", + invocationId, + ], { encoding: "utf8" }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +function runCli(args, env = {}) { + return spawnSync(process.execPath, [scriptPath, ...args], { + encoding: "utf8", + env: { ...process.env, ...env }, + windowsHide: true, + }); +} + +test("initialization creates immutable manifest and rejects secret-bearing persisted URLs", (t) => { + const root = createBatch(t); + assert.equal(JSON.parse(fs.readFileSync(path.join(root, "manifest.json"))).schemaVersion, 1); + assert.equal(readState(root).revision, 0); + assert.throws(() => initializeBatch({ batchRoot: root, manifest: { batchId: "other" } }), /control file already exists/); + + const unsafeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-unsafe-")); + t.after(() => fs.rmSync(unsafeRoot, { recursive: true, force: true })); + assert.throws( + () => initializeBatch({ + batchRoot: unsafeRoot, + manifest: { batchId: "unsafe", url: "https://user:secret@example.com/repo.git?token=x#frag" }, + }), + /must not contain URL credentials/, + ); + assert.equal(fs.existsSync(path.join(unsafeRoot, "manifest.json")), false); + + const unsafeHttpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-unsafe-http-")); + t.after(() => fs.rmSync(unsafeHttpRoot, { recursive: true, force: true })); + assert.throws( + () => initializeBatch({ + batchRoot: unsafeHttpRoot, + manifest: { batchId: "unsafe-http", url: "http://user:secret@example.com/repo.git?token=x" }, + }), + /must not contain URL credentials/, + ); + assert.equal(fs.existsSync(path.join(unsafeHttpRoot, "manifest.json")), false); + + const keyRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-secret-key-")); + t.after(() => fs.rmSync(keyRoot, { recursive: true, force: true })); + assert.throws( + () => initializeBatch({ batchRoot: keyRoot, manifest: { batchId: "unsafe-key", accessToken: "value" } }), + /credential fields/, + ); + + const redactedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-redacted-key-")); + t.after(() => fs.rmSync(redactedRoot, { recursive: true, force: true })); + initializeBatch({ + batchRoot: redactedRoot, + manifest: { batchId: "safe-key", unknownFields: { accessToken: "" } }, + }); + assert.equal(fs.existsSync(path.join(redactedRoot, "manifest.json")), true); +}); + +test("initialization refuses partial pre-existing control files", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-partial-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, "state.json"), "do not overwrite\n"); + assert.throws( + () => initializeBatch({ batchRoot: root, manifest: { batchId: "batch-1" } }), + /state\.json/, + ); + assert.equal(fs.readFileSync(path.join(root, "state.json"), "utf8"), "do not overwrite\n"); +}); + +test("only one competing process acquires a batch lease", async (t) => { + const root = createBatch(t); + const results = await Promise.all([ + spawnAcquire(root, "invocation-a"), + spawnAcquire(root, "invocation-b"), + ]); + assert.deepEqual(results.map((result) => result.code).sort(), [0, 2]); + assert.equal(readLease(root).schedulingAllowed, true); +}); + +test("lease acquisition requires a complete initialized batch", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-no-state-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + assert.throws(() => acquireLease({ batchRoot: root, invocationId: "owner" }), /batch manifest/); + assert.equal(fs.existsSync(path.join(root, "lease.json")), false); +}); + +test("wrong owner tokens fail closed for state, events, summaries, and release", (t) => { + const root = createBatch(t); + const { ownerToken } = acquireLease({ batchRoot: root, invocationId: "owner" }); + assert.throws( + () => updateState({ batchRoot: root, ownerToken: "wrong", mutate: (state) => state }), + /does not match/, + ); + assert.throws( + () => appendEvent({ batchRoot: root, ownerToken: "wrong", event: { type: "batch_created" } }), + /does not match/, + ); + assert.throws( + () => writeSummary({ batchRoot: root, ownerToken: "wrong", summary: { schemaVersion: 1 }, markdown: "# Summary" }), + /does not match/, + ); + assert.throws(() => releaseLease({ batchRoot: root, ownerToken: "wrong" }), /does not match/); + releaseLease({ batchRoot: root, ownerToken }); + assert.equal(readLease(root), null); +}); + +test("owner updates state, event log, repository state, and summary atomically", (t) => { + const root = createBatch(t); + const { ownerToken } = acquireLease({ batchRoot: root, invocationId: "owner" }); + const updated = updateState({ + batchRoot: root, + ownerToken, + mutate: (state) => ({ ...state, status: "ready" }), + }); + assert.equal(updated.revision, 1); + assert.equal(updated.status, "ready"); + assert.match(updated.activeLeaseDigest, /^sha256:/); + const first = appendEvent({ batchRoot: root, ownerToken, event: { type: "batch_created" } }); + const second = appendEvent({ batchRoot: root, ownerToken, event: { type: "lease_acquired" } }); + assert.deepEqual([first.sequence, second.sequence], [1, 2]); + const repoPath = writeRepoState({ + batchRoot: root, + ownerToken, + repoId: "orders", + state: { schemaVersion: 1, repoId: "orders", status: "ready" }, + }); + const summaryPaths = writeSummary({ + batchRoot: root, + ownerToken, + summary: { schemaVersion: 1, batchId: "batch-1", status: "ready" }, + markdown: "# Batch summary", + }); + assert.equal(JSON.parse(fs.readFileSync(repoPath)).status, "ready"); + assert.equal(JSON.parse(fs.readFileSync(summaryPaths.json)).status, "ready"); + assert.equal(fs.readFileSync(summaryPaths.markdown, "utf8"), "# Batch summary\n"); + assert.equal(fs.readdirSync(root).some((name) => name.endsWith(".tmp")), false); +}); + +test("event operation keys make retries idempotent and reject conflicting reuse", (t) => { + const root = createBatch(t); + const { ownerToken } = acquireLease({ batchRoot: root, invocationId: "owner" }); + const event = { + type: "attempt_finished", + repoId: "orders", + executionUnitId: "orders", + invocationId: "11111111-1111-4111-8111-111111111111", + payload: { phase: "assessment", attempt: 1, status: "completed" }, + }; + const first = appendEvent({ + batchRoot: root, + ownerToken, + event, + operationKey: "commit:orders:assessment:1", + }); + const replay = appendEvent({ + batchRoot: root, + ownerToken, + event: { + ...event, + payload: { status: "completed", attempt: 1, phase: "assessment" }, + }, + operationKey: "commit:orders:assessment:1", + now: "2026-08-18T12:00:00.000Z", + }); + assert.deepEqual(replay, first); + assert.equal(fs.readFileSync(path.join(root, "events.jsonl"), "utf8").trim().split(/\r?\n/).length, 1); + assert.throws( + () => appendEvent({ + batchRoot: root, + ownerToken, + event: { ...event, payload: { ...event.payload, status: "failed" } }, + operationKey: "commit:orders:assessment:1", + }), + (error) => error.code === "event_operation_conflict", + ); +}); + +test("takeover uses compare-and-swap and remains read-only", (t) => { + const root = createBatch(t); + const oldOwner = acquireLease({ batchRoot: root, invocationId: "old-owner" }); + const original = readLease(root); + const takeover = takeoverLease({ + batchRoot: root, + expectedLeaseDigest: original.leaseFileDigest, + invocationId: "new-owner", + }); + assert.equal(takeover.lease.schedulingAllowed, false); + assert.throws(() => assertSchedulingAllowed(root, takeover.ownerToken), /read-only/); + assert.throws( + () => updateState({ batchRoot: root, ownerToken: takeover.ownerToken, mutate: (state) => state }), + /read-only/, + ); + assert.throws( + () => appendEvent({ batchRoot: root, ownerToken: takeover.ownerToken, event: { type: "takeover_recorded" } }), + /read-only/, + ); + assert.throws( + () => updateState({ batchRoot: root, ownerToken: oldOwner.ownerToken, mutate: (state) => state }), + /does not match/, + ); + assert.throws( + () => takeoverLease({ + batchRoot: root, + expectedLeaseDigest: original.leaseFileDigest, + invocationId: "loser", + }), + /changed before takeover/, + ); +}); + +test("unknown persisted schema versions are rejected", (t) => { + const root = createBatch(t); + const statePath = path.join(root, "state.json"); + const state = JSON.parse(fs.readFileSync(statePath, "utf8")); + fs.writeFileSync(statePath, JSON.stringify({ ...state, schemaVersion: 99 })); + assert.throws( + () => readState(root), + (error) => error instanceof BatchStateError && error.code === "unsupported_schema", + ); +}); + +test("state and event schema violations fail before persistence", (t) => { + const root = createBatch(t); + const { ownerToken } = acquireLease({ batchRoot: root, invocationId: "owner" }); + assert.throws( + () => updateState({ + batchRoot: root, + ownerToken, + mutate: (state) => ({ ...state, status: "invented" }), + }), + /violates its v1 schema/, + ); + assert.throws( + () => appendEvent({ batchRoot: root, ownerToken, event: { type: "invented" } }), + /violates its v1 schema/, + ); + assert.equal(readState(root).status, "draft"); + assert.equal(fs.readFileSync(path.join(root, "events.jsonl"), "utf8"), ""); +}); + +test("state CLI uses environment-only ownership for deterministic operations", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-cli-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const manifestPath = path.join(root, "manifest-input.json"); + fs.writeFileSync(manifestPath, '{"batchId":"batch-cli"}\n'); + const batchRoot = path.join(root, "batch"); + const initialized = runCli([ + "initialize", + "--batch-root", batchRoot, + "--manifest", manifestPath, + ]); + assert.equal(initialized.status, 0, initialized.stderr); + const acquired = runCli([ + "acquire-lease", + "--batch-root", batchRoot, + "--invocation-id", "cli-owner", + ]); + assert.equal(acquired.status, 0, acquired.stderr); + const { ownerToken } = JSON.parse(acquired.stdout); + assert.equal(ownerToken.length > 20, true); + + const withoutToken = runCli([ + "update-status", + "--batch-root", batchRoot, + "--status", "ready", + ]); + assert.equal(withoutToken.status, 1); + assert.match(withoutToken.stderr, /owner_token_required/); + const env = { BATCH_OWNER_TOKEN: ownerToken }; + const updated = runCli([ + "update-status", + "--batch-root", batchRoot, + "--status", "ready", + ], env); + assert.equal(updated.status, 0, updated.stderr); + assert.equal(JSON.parse(updated.stdout).status, "ready"); + assert.equal(runCli(["assert-scheduling", "--batch-root", batchRoot], env).status, 0); + assert.equal(runCli(["release-lease", "--batch-root", batchRoot], env).status, 0); +}); + +test("repository state IDs cannot escape the repos directory", (t) => { + const root = createBatch(t); + const { ownerToken } = acquireLease({ batchRoot: root, invocationId: "owner" }); + assert.throws( + () => writeRepoState({ + batchRoot: root, + ownerToken, + repoId: "../escape", + state: { schemaVersion: 1, repoId: "../escape" }, + }), + /not a safe filename/, + ); + assert.equal(fs.existsSync(path.join(root, "escape.json")), false); +}); \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/control-plane.integration.test.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/control-plane.integration.test.mjs new file mode 100644 index 0000000..e07907e --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/control-plane.integration.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + acquireLease, + appendEvent, + initializeBatch, + readState, + updateState, + writeRepoState, + writeSummary, +} from "./batch-state.mjs"; +import { inspectResolvedRepositories } from "./inspect-workspaces.mjs"; +import { resolveReposFile } from "./resolve-repos.mjs"; +import { validateSchema } from "./schema-validator.mjs"; + +const scriptsRoot = path.dirname(fileURLToPath(import.meta.url)); +const skillRoot = path.resolve(scriptsRoot, ".."); +const schemasRoot = path.join(skillRoot, "schemas"); +const resolveScript = path.join(scriptsRoot, "resolve-repos.mjs"); +const inspectScript = path.join(scriptsRoot, "inspect-workspaces.mjs"); + +function validate(name, value) { + const schemaPath = path.join(schemasRoot, name); + const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); + assert.deepEqual(validateSchema(value, schema, schemaPath), [], name); +} + +test("local config flows through resolve, inspect, state, event, repo, and summary artifacts", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-control-integration-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const repositoryRoot = path.join(root, "portfolio", "orders"); + const projectRoot = path.join(repositoryRoot, "services", "api"); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync(path.join(projectRoot, "package.json"), "{}\n"); + fs.writeFileSync(path.join(repositoryRoot, "excluded.txt"), "not in scope\n"); + const configPath = path.join(root, "portfolio", ".github", "modernize", "repos.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, `${JSON.stringify({ + producer: "integration-test", + repos: [{ name: "orders", path: repositoryRoot, include_paths: ["services/api"] }], + }, null, 2)}\n`); + + const resolved = resolveReposFile(configPath, { launchRoot: path.join(root, "portfolio") }); + const inspected = inspectResolvedRepositories(resolved, { allowedRoots: [path.join(root, "portfolio")] }); + assert.equal(inspected.repositories[0].preflightStatus, "needs_attention"); + assert.deepEqual(inspected.repositories[0].executionUnits[0].languages, ["javascript"]); + assert.equal(inspected.repositories[0].executionUnits[0].scopeRoots.includes(repositoryRoot), false); + validate("resolved-repos.schema.json", inspected); + + const batchRoot = path.join(root, "portfolio", ".github", "modernize", "batches", "batch-1"); + initializeBatch({ + batchRoot, + manifest: { + batchId: "batch-1", + executionMode: "local", + resolvedConfig: inspected, + }, + }); + const { ownerToken } = acquireLease({ batchRoot, invocationId: "coordinator-1" }); + const unit = inspected.repositories[0].executionUnits[0]; + const state = updateState({ + batchRoot, + ownerToken, + mutate: (current) => ({ + ...current, + status: "ready", + executionUnits: [{ + repoId: unit.repoId, + executionUnitId: unit.executionUnitId, + phase: "assessment", + attempt: 0, + invocationId: null, + status: "pending", + resultPath: null, + startedAt: null, + finishedAt: null, + }], + progress: { wave: 1, eligible: 1, terminal: 0, successful: 0, issues: 0, failed: 0 }, + }), + }); + validate("batch-state.schema.json", state); + const event = appendEvent({ + batchRoot, + ownerToken, + event: { type: "batch_created", payload: { executionUnits: 1 } }, + }); + validate("event.schema.json", event); + const repoPath = writeRepoState({ + batchRoot, + ownerToken, + repoId: "orders", + state: { schemaVersion: 1, repoId: "orders", status: "ready", executionUnitIds: [unit.executionUnitId] }, + }); + const summary = writeSummary({ + batchRoot, + ownerToken, + summary: { schemaVersion: 1, batchId: "batch-1", status: "ready", repositories: 1 }, + markdown: "# Batch batch-1\n\nReady: 1\n", + }); + assert.equal(JSON.parse(fs.readFileSync(repoPath, "utf8")).status, "ready"); + assert.equal(JSON.parse(fs.readFileSync(summary.json, "utf8")).repositories, 1); + assert.equal(readState(batchRoot).revision, 1); +}); + +test("resolve and inspect CLIs publish atomic JSON artifacts", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-control-cli-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const workspace = path.join(root, "workspace"); + fs.mkdirSync(workspace); + fs.writeFileSync(path.join(workspace, "package.json"), "{}\n"); + const configPath = path.join(root, "repos.json"); + fs.writeFileSync(configPath, `${JSON.stringify([{ name: "app", path: workspace }])}\n`); + const resolvedPath = path.join(root, "out", "resolved.json"); + const resolvedRun = spawnSync(process.execPath, [ + resolveScript, + "--config", configPath, + "--launch-root", root, + "--output", resolvedPath, + ], { encoding: "utf8", windowsHide: true }); + assert.equal(resolvedRun.status, 0, resolvedRun.stderr); + assert.equal(JSON.parse(resolvedRun.stdout).outputPath, resolvedPath); + const inspectedPath = path.join(root, "out", "inspected.json"); + const inspectedRun = spawnSync(process.execPath, [ + inspectScript, + "inspect", + "--resolved", resolvedPath, + "--allowed-root", root, + "--output", inspectedPath, + ], { encoding: "utf8", windowsHide: true }); + assert.equal(inspectedRun.status, 0, inspectedRun.stderr); + const inspected = JSON.parse(fs.readFileSync(inspectedPath, "utf8")); + assert.deepEqual(inspected.repositories[0].executionUnits[0].languages, ["javascript"]); + assert.equal(fs.readdirSync(path.dirname(inspectedPath)).some((name) => name.endsWith(".tmp")), false); +}); \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/inspect-workspaces.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/inspect-workspaces.mjs new file mode 100644 index 0000000..e32c68e --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/inspect-workspaces.mjs @@ -0,0 +1,449 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { normalizeRemoteIdentity, sanitizeGitUrl } from "./resolve-repos.mjs"; +import { validateSchema } from "./schema-validator.mjs"; + +const scriptRoot = path.dirname(fileURLToPath(import.meta.url)); +const resolvedSchemaPath = path.resolve(scriptRoot, "..", "schemas", "resolved-repos.schema.json"); +const resolvedSchema = JSON.parse(fs.readFileSync(resolvedSchemaPath, "utf8")); + +const IGNORED_DIRECTORIES = new Set([ + ".git", + ".github", + "bin", + "build", + "dist", + "node_modules", + "obj", + "target", + "vendor", +]); + +export class WorkspaceInspectionError extends Error { + constructor(message, code = "workspace_inspection_failed") { + super(message); + this.name = "WorkspaceInspectionError"; + this.code = code; + } +} + +function canonicalCase(value) { + return process.platform === "win32" ? value.toLowerCase() : value; +} + +export function canonicalPath(inputPath) { + return fs.realpathSync.native(path.resolve(inputPath)); +} + +export function isPathInside(rootPath, candidatePath) { + const root = canonicalCase(path.resolve(rootPath)); + const candidate = canonicalCase(path.resolve(candidatePath)); + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function assertCanonicalContainment(rootPath, candidatePath) { + const root = canonicalPath(rootPath); + const candidate = canonicalPath(candidatePath); + if (!isPathInside(root, candidate)) { + throw new WorkspaceInspectionError( + `Canonical path escapes the approved root: ${candidatePath}`, + "path_escape", + ); + } + return { root, candidate }; +} + +function walkForExtensions(rootPath, extensions, maxEntries = 10_000) { + const pending = [rootPath]; + let visited = 0; + while (pending.length > 0 && visited < maxEntries) { + const current = pending.pop(); + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + visited += 1; + if (entry.isDirectory()) { + if (!IGNORED_DIRECTORIES.has(entry.name.toLowerCase())) { + pending.push(path.join(current, entry.name)); + } + } else if (extensions.has(path.extname(entry.name).toLowerCase())) { + return true; + } + if (visited >= maxEntries) break; + } + } + return false; +} + +export function detectProjectLanguages(workspacePath) { + const exists = (name) => fs.existsSync(path.join(workspacePath, name)); + const entries = fs.existsSync(workspacePath) + ? fs.readdirSync(workspacePath, { withFileTypes: true }) + : []; + const fileNames = entries.filter((entry) => entry.isFile()).map((entry) => entry.name); + const languages = []; + if ( + exists("pom.xml") + || exists("build.gradle") + || exists("build.gradle.kts") + || walkForExtensions(workspacePath, new Set([".java", ".kt", ".scala"])) + ) { + languages.push("java"); + } + if ( + fileNames.some((name) => /\.(?:sln|slnx|csproj)$/i.test(name)) + || walkForExtensions(workspacePath, new Set([".cs"])) + ) { + languages.push("dotnet"); + } + if (exists("package.json")) { + const hasTypeScript = exists("tsconfig.json") + || walkForExtensions(workspacePath, new Set([".ts", ".tsx"])); + languages.push(hasTypeScript ? "typescript" : "javascript"); + } + return [...new Set(languages)]; +} + +export function redactSecrets(value) { + return String(value ?? "") + .replace(/(Authorization:\s*(?:Basic|Bearer)\s+)[^\s]+/gi, "$1") + .replace(/(?:https?|ssh):\/\/[^\s'"<>]+/gi, (match) => { + try { + return sanitizeGitUrl(match.replace(/[),.;]+$/, "")); + } catch { + return ""; + } + }) + .replace(/(?:[^@\s/:]+@)?[^\s/:]+:[^\s]+\.git(?:\?[^\s]*)?(?:#[^\s]*)?/g, (match) => { + try { + return sanitizeGitUrl(match); + } catch { + return ""; + } + }); +} + +function runCommand(executable, args, { cwd, env, spawnSyncImpl = spawnSync } = {}) { + const result = spawnSyncImpl(executable, args, { + cwd, + env, + encoding: "utf8", + windowsHide: true, + }); + return { + status: result.status, + stdout: String(result.stdout ?? "").trim(), + stderr: redactSecrets(result.stderr ?? result.error?.message ?? ""), + error: result.error, + }; +} + +function gitValue(args, options) { + const result = runCommand(options.gitExecutable ?? "git", args, options); + return result.status === 0 ? result.stdout : null; +} + +export function inspectGitWorkspace(workspacePath, { + expectedUrl, + expectedBranch, + gitExecutable = "git", + spawnSyncImpl = spawnSync, +} = {}) { + const options = { cwd: workspacePath, gitExecutable, spawnSyncImpl }; + const inside = gitValue(["rev-parse", "--is-inside-work-tree"], options); + if (inside !== "true") { + return { isGit: false, errors: [], warnings: ["workspace is not a Git repository"] }; + } + const gitRoot = gitValue(["rev-parse", "--show-toplevel"], options); + const branch = gitValue(["branch", "--show-current"], options) || null; + const originRaw = gitValue(["remote", "get-url", "origin"], options); + const status = gitValue(["status", "--porcelain"], options) ?? ""; + const warnings = []; + const errors = []; + let origin = null; + let originIdentity = null; + if (originRaw) { + try { + origin = sanitizeGitUrl(originRaw); + originIdentity = normalizeRemoteIdentity(originRaw); + } catch { + errors.push("Git origin is not a supported HTTPS or SSH repository URL"); + } + } + if (expectedUrl) { + if (!originIdentity) { + errors.push("Git origin is missing"); + } else if (originIdentity !== normalizeRemoteIdentity(expectedUrl)) { + errors.push("Git origin does not match the configured repository URL"); + } + } + if (expectedBranch && branch !== expectedBranch) { + errors.push(`current branch ${JSON.stringify(branch)} does not match configured branch ${JSON.stringify(expectedBranch)}`); + } + if (status) warnings.push("workspace has uncommitted changes"); + return { + isGit: true, + gitRoot: gitRoot ? canonicalPath(gitRoot) : canonicalPath(workspacePath), + origin, + branch, + dirty: Boolean(status), + warnings, + errors, + }; +} + +function lexicalPathInside(rootPath, candidatePath) { + return isPathInside(path.resolve(rootPath), path.resolve(candidatePath)); +} + +function isAuthorizedWorkspace(workspacePath, allowedRoots) { + if (!fs.existsSync(workspacePath)) return false; + const canonicalWorkspace = canonicalPath(workspacePath); + return allowedRoots.some((root) => { + if (!fs.existsSync(root)) return false; + return isPathInside(canonicalPath(root), canonicalWorkspace); + }); +} + +function inspectExecutionUnits(repository, canonicalWorkspace, gitRoot) { + const errors = []; + const units = []; + for (const planned of repository.executionUnits) { + let unitPath; + try { + unitPath = canonicalPath(planned.workspacePath); + } catch { + errors.push(`${planned.executionUnitId}: project path does not exist`); + continue; + } + if (!isPathInside(canonicalWorkspace, unitPath)) { + errors.push(`${planned.executionUnitId}: canonical project path escapes the repository workspace`); + continue; + } + const languages = detectProjectLanguages(unitPath); + if (languages.length === 0) { + errors.push(`${planned.executionUnitId}: no supported project was detected`); + continue; + } + units.push({ + ...planned, + workspacePath: unitPath, + gitRoot, + scopeRoots: [unitPath], + languages, + }); + } + return { units, errors }; +} + +export function inspectResolvedRepositories(resolved, { + allowedRoots = [], + gitExecutable = "git", + spawnSyncImpl = spawnSync, +} = {}) { + const schemaErrors = validateSchema(resolved, resolvedSchema, resolvedSchemaPath); + if (schemaErrors.length > 0) { + throw new WorkspaceInspectionError( + `Resolved repository input violates the v1 schema: ${schemaErrors.join("; ")}`, + resolved?.schemaVersion === 1 ? "invalid_resolved_input" : "unsupported_schema", + ); + } + const repositories = resolved.repositories.map((repository) => { + const warnings = repository.warnings.filter((warning) => warning !== "workspace inspection pending"); + const errors = []; + if (!fs.existsSync(repository.workspacePath)) { + if (repository.input.url) { + warnings.push("repository clone is required"); + return { ...repository, preflightStatus: "needs_attention", warnings, errors }; + } + errors.push("local workspace does not exist"); + return { ...repository, preflightStatus: "blocked", warnings, errors, executionUnits: [] }; + } + if (!isAuthorizedWorkspace(repository.workspacePath, allowedRoots)) { + errors.push("workspace is outside the approved path roots"); + return { ...repository, preflightStatus: "blocked", warnings, errors, executionUnits: [] }; + } + + const canonicalWorkspace = canonicalPath(repository.workspacePath); + const git = inspectGitWorkspace(canonicalWorkspace, { + expectedUrl: repository.input.url, + expectedBranch: repository.input.branch, + gitExecutable, + spawnSyncImpl, + }); + warnings.push(...git.warnings); + errors.push(...git.errors); + if (repository.input.url && !git.isGit) { + errors.push("configured clone target exists but is not a Git repository"); + } + const gitRoot = git.isGit ? git.gitRoot : canonicalWorkspace; + const inspectedUnits = inspectExecutionUnits(repository, canonicalWorkspace, gitRoot); + errors.push(...inspectedUnits.errors); + let preflightStatus = "ready"; + if (errors.length > 0 || inspectedUnits.units.length === 0) { + preflightStatus = inspectedUnits.units.length > 0 && !repository.input.url + ? "needs_attention" + : "blocked"; + } else if (warnings.length > 0) { + preflightStatus = "needs_attention"; + } + return { + ...repository, + workspacePath: canonicalWorkspace, + preflightStatus, + warnings, + errors, + executionUnits: inspectedUnits.units, + }; + }); + return { ...resolved, repositories }; +} + +function ensureCloneTargetAllowed(targetPath, allowedRoot) { + const parent = path.dirname(path.resolve(targetPath)); + if (!fs.existsSync(parent)) fs.mkdirSync(parent, { recursive: true }); + const canonicalRoot = canonicalPath(allowedRoot); + const canonicalParent = canonicalPath(parent); + if (!lexicalPathInside(canonicalRoot, canonicalParent)) { + throw new WorkspaceInspectionError("Clone target is outside the approved clone root", "path_escape"); + } +} + +function operationalCloneUrl(value) { + sanitizeGitUrl(value); + if (!value.includes("://")) return value.trim(); + const parsed = new URL(value.trim()); + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + if (parsed.protocol === "https:") parsed.username = ""; + return parsed.toString(); +} + +function cloneEnvironment(value) { + const env = { ...process.env }; + delete env.BATCH_CLONE_URL; + if (!value.includes("://")) return env; + const parsed = new URL(value.trim()); + if (parsed.protocol !== "https:" || (!parsed.username && !parsed.password)) return env; + + const rawConfigCount = env.GIT_CONFIG_COUNT ?? "0"; + if (!/^\d+$/.test(rawConfigCount) || !Number.isSafeInteger(Number(rawConfigCount))) { + throw new WorkspaceInspectionError("Git config environment is invalid", "clone_environment_invalid"); + } + const configIndex = Number(rawConfigCount); + const credentials = `${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`; + env.GIT_CONFIG_COUNT = String(configIndex + 1); + env[`GIT_CONFIG_KEY_${configIndex}`] = "http.extraHeader"; + env[`GIT_CONFIG_VALUE_${configIndex}`] = `Authorization: Basic ${Buffer.from(credentials, "utf8").toString("base64")}`; + return env; +} + +export function cloneRepository({ + url, + targetPath, + branch = null, + allowedRoot, + gitExecutable = "git", + spawnSyncImpl = spawnSync, +} = {}) { + const transportUrl = operationalCloneUrl(url); + const absoluteTarget = path.resolve(targetPath); + ensureCloneTargetAllowed(absoluteTarget, allowedRoot); + if (fs.existsSync(absoluteTarget)) { + throw new WorkspaceInspectionError("Clone target already exists", "clone_target_exists"); + } + const temporaryPath = path.join( + path.dirname(absoluteTarget), + `.${path.basename(absoluteTarget)}.clone-${crypto.randomUUID()}`, + ); + const args = ["clone"]; + if (branch) args.push("--branch", branch, "--single-branch"); + args.push("--", transportUrl, temporaryPath); + const result = runCommand(gitExecutable, args, { + cwd: path.dirname(absoluteTarget), + env: cloneEnvironment(url), + spawnSyncImpl, + }); + if (result.status !== 0) { + fs.rmSync(temporaryPath, { recursive: true, force: true }); + throw new WorkspaceInspectionError( + `Git clone failed${result.stderr ? `: ${result.stderr}` : ""}`, + "clone_failed", + ); + } + if (!fs.existsSync(temporaryPath)) { + fs.rmSync(temporaryPath, { recursive: true, force: true }); + throw new WorkspaceInspectionError("Git clone reported success without creating a workspace", "clone_missing_output"); + } + try { + fs.renameSync(temporaryPath, absoluteTarget); + } catch (error) { + fs.rmSync(temporaryPath, { recursive: true, force: true }); + throw new WorkspaceInspectionError(`Unable to publish cloned workspace: ${error.message}`, "clone_publish_failed"); + } + return absoluteTarget; +} + +function optionValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function optionValues(name) { + return process.argv.flatMap((value, index) => value === name ? [process.argv[index + 1]] : []); +} + +function emitResult(result) { + const output = optionValue("--output"); + if (!output) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + const outputPath = path.resolve(output); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + const temporaryPath = `${outputPath}.${process.pid}.${crypto.randomUUID()}.tmp`; + fs.writeFileSync(temporaryPath, `${JSON.stringify(result, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); + fs.renameSync(temporaryPath, outputPath); + process.stdout.write(`${JSON.stringify({ outputPath })}\n`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const command = process.argv[2]; + try { + let result; + if (command === "inspect") { + const resolvedPath = path.resolve(optionValue("--resolved") ?? ""); + const resolved = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); + result = inspectResolvedRepositories(resolved, { + allowedRoots: optionValues("--allowed-root").map((root) => path.resolve(root)), + }); + } else if (command === "clone") { + const url = process.env.BATCH_CLONE_URL; + if (!url) throw new WorkspaceInspectionError("BATCH_CLONE_URL is required", "clone_url_required"); + result = { + workspacePath: cloneRepository({ + url, + targetPath: optionValue("--target"), + allowedRoot: optionValue("--allowed-root"), + branch: optionValue("--branch") ?? null, + }), + }; + } else { + throw new WorkspaceInspectionError(`Unknown command: ${command}`, "unknown_command"); + } + emitResult(result); + } catch (error) { + process.stderr.write(`${JSON.stringify({ code: error.code, message: redactSecrets(error.message) })}\n`); + process.exitCode = 1; + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/inspect-workspaces.test.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/inspect-workspaces.test.mjs new file mode 100644 index 0000000..370717a --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/inspect-workspaces.test.mjs @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +import { + assertCanonicalContainment, + cloneRepository, + detectProjectLanguages, + inspectResolvedRepositories, + redactSecrets, +} from "./inspect-workspaces.mjs"; +import { resolveReposDocument } from "./resolve-repos.mjs"; + +function git(root, ...args) { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8", windowsHide: true }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); +} + +function createGitProject(root, { remote = "https://github.com/contoso/orders.git", branch = "main" } = {}) { + fs.mkdirSync(root, { recursive: true }); + git(root, "init"); + git(root, "config", "user.email", "batch@example.test"); + git(root, "config", "user.name", "Batch Test"); + fs.writeFileSync(path.join(root, "pom.xml"), "\n"); + git(root, "add", "pom.xml"); + git(root, "commit", "-m", "initial"); + git(root, "branch", "-M", branch); + git(root, "remote", "add", "origin", remote); +} + +test("project detection recognizes Java, .NET, JavaScript, and TypeScript roots", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-languages-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, "pom.xml"), "\n"); + fs.writeFileSync(path.join(root, "demo.csproj"), "\n"); + fs.writeFileSync(path.join(root, "package.json"), "{}\n"); + fs.writeFileSync(path.join(root, "tsconfig.json"), "{}\n"); + assert.deepEqual(detectProjectLanguages(root), ["java", "dotnet", "typescript"]); +}); + +test("workspace inspection reports clean URL repos ready and dirty repos needs attention", (t) => { + const launchRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-inspect-")); + t.after(() => fs.rmSync(launchRoot, { recursive: true, force: true })); + const workspace = path.join(launchRoot, "repos", "orders"); + createGitProject(workspace); + const resolved = resolveReposDocument({ + repos: [{ name: "orders", url: "git@github.com:contoso/orders.git", branch: "main" }], + }, { launchRoot }); + + const clean = inspectResolvedRepositories(resolved, { allowedRoots: [launchRoot] }); + assert.equal(clean.repositories[0].preflightStatus, "ready"); + assert.deepEqual(clean.repositories[0].executionUnits[0].languages, ["java"]); + fs.writeFileSync(path.join(workspace, "dirty.txt"), "dirty\n"); + const dirty = inspectResolvedRepositories(resolved, { allowedRoots: [launchRoot] }); + assert.equal(dirty.repositories[0].preflightStatus, "needs_attention"); + assert.match(dirty.repositories[0].warnings.join("\n"), /uncommitted changes/); +}); + +test("origin and branch mismatches block configured URL workspaces without leaking credentials", (t) => { + const launchRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-origin-")); + t.after(() => fs.rmSync(launchRoot, { recursive: true, force: true })); + const workspace = path.join(launchRoot, "repos", "orders"); + createGitProject(workspace, { + remote: "https://user:secret@github.com/contoso/other.git?token=hidden#fragment", + branch: "other", + }); + const resolved = resolveReposDocument({ + repos: [{ name: "orders", url: "https://github.com/contoso/orders.git", branch: "main" }], + }, { launchRoot }); + const inspected = inspectResolvedRepositories(resolved, { allowedRoots: [launchRoot] }); + const serialized = JSON.stringify(inspected); + assert.equal(inspected.repositories[0].preflightStatus, "blocked"); + assert.match(inspected.repositories[0].errors.join("\n"), /origin does not match/); + assert.match(inspected.repositories[0].errors.join("\n"), /current branch/); + assert.doesNotMatch(serialized, /secret|token=hidden|fragment/); +}); + +test("missing URL workspaces preserve provisional execution units for approval", (t) => { + const launchRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-clone-required-")); + t.after(() => fs.rmSync(launchRoot, { recursive: true, force: true })); + const resolved = resolveReposDocument({ + repos: [{ name: "orders", url: "https://github.com/contoso/orders.git", branch: "main" }], + }, { launchRoot }); + + const inspected = inspectResolvedRepositories(resolved, { allowedRoots: [launchRoot] }); + const repository = inspected.repositories[0]; + assert.equal(repository.preflightStatus, "needs_attention"); + assert.match(repository.warnings.join("\n"), /repository clone is required/); + assert.deepEqual(repository.executionUnits, resolved.repositories[0].executionUnits); + assert.deepEqual(repository.executionUnits[0].languages, ["unknown"]); +}); + +test("include-path units exclude siblings and reject canonical path escapes", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-scope-")); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "batch-outside-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + t.after(() => fs.rmSync(outside, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, "services", "api"), { recursive: true }); + fs.mkdirSync(path.join(root, "services", "excluded"), { recursive: true }); + fs.writeFileSync(path.join(root, "services", "api", "package.json"), "{}\n"); + fs.writeFileSync(path.join(root, "services", "excluded", "pom.xml"), "\n"); + const resolved = resolveReposDocument({ + repos: [{ name: "mono", path: root, include_paths: ["services/api"] }], + }, { launchRoot: root }); + const inspected = inspectResolvedRepositories(resolved, { allowedRoots: [root] }); + assert.equal(inspected.repositories[0].executionUnits.length, 1); + assert.deepEqual(inspected.repositories[0].executionUnits[0].languages, ["javascript"]); + assert.equal(inspected.repositories[0].executionUnits[0].workspacePath.includes("excluded"), false); + assert.throws(() => assertCanonicalContainment(root, outside), /escapes the approved root/); + + const linkPath = path.join(root, "escape-link"); + try { + fs.symlinkSync(outside, linkPath, process.platform === "win32" ? "junction" : "dir"); + assert.throws(() => assertCanonicalContainment(root, linkPath), /escapes the approved root/); + } catch (error) { + if (!["EPERM", "EACCES", "UNKNOWN"].includes(error.code)) throw error; + } +}); + +test("missing, unauthorized, and unsupported local workspaces fail closed", (t) => { + const allowed = fs.mkdtempSync(path.join(os.tmpdir(), "batch-allowed-")); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "batch-denied-")); + t.after(() => fs.rmSync(allowed, { recursive: true, force: true })); + t.after(() => fs.rmSync(outside, { recursive: true, force: true })); + const resolved = resolveReposDocument({ + repos: [ + { name: "missing", path: path.join(allowed, "missing") }, + { name: "denied", path: outside }, + { name: "unsupported", path: allowed }, + ], + }, { launchRoot: allowed }); + const inspected = inspectResolvedRepositories(resolved, { allowedRoots: [allowed] }); + assert.deepEqual(inspected.repositories.map((repo) => repo.preflightStatus), ["blocked", "blocked", "blocked"]); +}); + +test("clone publishes a complete temporary directory atomically and cleans failed attempts", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-clone-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const target = path.join(root, "repos", "orders"); + const seen = []; + const successSpawn = (_command, args, options) => { + const temporaryPath = args.at(-1); + assert.equal(args.at(-2), "https://example.com/orders.git"); + assert.doesNotMatch(JSON.stringify(args), /secret|token=x|fragment/); + assert.equal(options.env.BATCH_CLONE_URL, undefined); + const authIndex = Array.from({ length: Number(options.env.GIT_CONFIG_COUNT) }) + .findIndex((_, index) => options.env[`GIT_CONFIG_KEY_${index}`] === "http.extraHeader"); + assert.notEqual(authIndex, -1); + assert.equal( + options.env[`GIT_CONFIG_VALUE_${authIndex}`], + `Authorization: Basic ${Buffer.from("user:secret").toString("base64")}`, + ); + seen.push(temporaryPath); + fs.mkdirSync(temporaryPath, { recursive: true }); + fs.writeFileSync(path.join(temporaryPath, "complete.txt"), "ready\n"); + return { status: 0, stdout: "", stderr: "" }; + }; + cloneRepository({ + url: "https://user:secret@example.com/orders.git?token=x#fragment", + targetPath: target, + allowedRoot: root, + spawnSyncImpl: successSpawn, + }); + assert.equal(fs.readFileSync(path.join(target, "complete.txt"), "utf8"), "ready\n"); + assert.equal(seen[0].startsWith(path.join(root, "repos", ".orders.clone-")), true); + assert.equal(fs.existsSync(seen[0]), false); + + const failedTarget = path.join(root, "repos", "failed"); + let failedTemporaryPath; + const failedSpawn = (_command, args) => { + failedTemporaryPath = args.at(-1); + fs.mkdirSync(failedTemporaryPath, { recursive: true }); + return { + status: 1, + stdout: "", + stderr: "fatal: https://user:secret@example.com/orders.git?token=x#fragment failed", + }; + }; + assert.throws( + () => cloneRepository({ + url: "https://user:secret@example.com/orders.git?token=x#fragment", + targetPath: failedTarget, + allowedRoot: root, + spawnSyncImpl: failedSpawn, + }), + (error) => { + assert.doesNotMatch(error.message, /secret|token=x|fragment/); + return true; + }, + ); + assert.equal(fs.existsSync(failedTarget), false); + assert.equal(fs.existsSync(failedTemporaryPath), false); +}); + +test("redaction removes credentials from free-form errors", () => { + const redacted = redactSecrets("failed https://user:token@example.com/org/repo.git?secret=x#frag"); + assert.doesNotMatch(redacted, /user|token|secret=x|frag/); + assert.equal(redacted, "failed https://example.com/org/repo.git"); + const http = redactSecrets("failed http://user:token@example.com/org/repo.git?secret=x#frag"); + assert.doesNotMatch(http, /user|token|secret=x|frag/); + assert.equal(http, "failed "); + assert.equal( + redactSecrets("fatal: Authorization: Basic dXNlcjpzZWNyZXQ="), + "fatal: Authorization: Basic ", + ); +}); + +test("workspace inspection rejects malformed resolved documents before touching paths", () => { + assert.throws( + () => inspectResolvedRepositories({ schemaVersion: 1, repositories: [] }, { allowedRoots: [] }), + /violates the v1 schema/, + ); +}); \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/prepare-review.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/prepare-review.mjs new file mode 100644 index 0000000..c900a6a --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/prepare-review.mjs @@ -0,0 +1,454 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { inspectResolvedRepositories } from "./inspect-workspaces.mjs"; +import { resolveReposFile } from "./resolve-repos.mjs"; +import { defaultAssessmentDomains } from "../../assessment/scripts/assessment-catalog.mjs"; + +const scriptRoot = path.dirname(fileURLToPath(import.meta.url)); +const batchSkillRoot = path.resolve(scriptRoot, ".."); +const assessmentSkillRoot = path.resolve(batchSkillRoot, "..", "assessment"); +const SUPPORTED_DOMAINS = new Set(["security", "cloud-readiness", "java-upgrade"]); +const SUPPORTED_COVERAGE = new Set(["issue-only", "full"]); +const BATCH_RUNTIME_SCRIPTS = [ + "batch-assessment-report.mjs", + "batch-attempt.mjs", + "batch-state.mjs", + "inspect-workspaces.mjs", + "prepare-review.mjs", + "probe-default-config.mjs", + "resolve-repos.mjs", + "schema-validator.mjs", + "validate-result.mjs", +]; +const ASSESSMENT_RUNTIME_SCRIPTS = [ + "assess-cli.mjs", + "assess-state.mjs", + "assess-report.mjs", + "assess-runtime.mjs", + "assessment-catalog.mjs", +]; + +export class BatchReviewError extends Error { + constructor(message, code = "batch_review_failed") { + super(message); + this.name = "BatchReviewError"; + this.code = code; + } +} + +function atomicWrite(filePath, content) { + const absolutePath = path.resolve(filePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + const temporaryPath = `${absolutePath}.${process.pid}.${crypto.randomUUID()}.tmp`; + fs.writeFileSync(temporaryPath, content, { encoding: "utf8", flag: "wx" }); + fs.renameSync(temporaryPath, absolutePath); +} + +function atomicWriteJson(filePath, value) { + atomicWrite(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function fileSha256(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function copyRuntimeFile(source, destination) { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(source, destination); +} + +function copyRuntimeFiles(sourceRoot, destinationRoot, names) { + for (const name of names) { + copyRuntimeFile(path.join(sourceRoot, name), path.join(destinationRoot, name)); + } +} + +function materializeBatchRuntime(batchRoot) { + const runtimeRoot = path.join(batchRoot, "runtime"); + const runtimeSkillsRoot = path.join(runtimeRoot, "skills"); + const batchRuntimeRoot = path.join(runtimeSkillsRoot, "batch-modernization"); + const assessmentRuntimeRoot = path.join(runtimeSkillsRoot, "assessment"); + + copyRuntimeFiles(scriptRoot, path.join(batchRuntimeRoot, "scripts"), BATCH_RUNTIME_SCRIPTS); + copyRuntimeFiles( + path.join(batchSkillRoot, "schemas"), + path.join(batchRuntimeRoot, "schemas"), + fs.readdirSync(path.join(batchSkillRoot, "schemas")).filter((name) => name.endsWith(".json")), + ); + copyRuntimeFiles( + path.join(assessmentSkillRoot, "scripts"), + path.join(assessmentRuntimeRoot, "scripts"), + ASSESSMENT_RUNTIME_SCRIPTS, + ); + copyRuntimeFile( + path.join(assessmentSkillRoot, "scripts", "templates", "report.html"), + path.join(assessmentRuntimeRoot, "scripts", "templates", "report.html"), + ); + copyRuntimeFile( + path.join(assessmentSkillRoot, "resources", "solution-mapping.json"), + path.join(assessmentRuntimeRoot, "resources", "solution-mapping.json"), + ); + fs.writeFileSync(path.join(runtimeRoot, ".gitignore"), "*\n!.gitignore\n", "utf8"); + return path.join(batchRuntimeRoot, "scripts", "batch-attempt.mjs"); +} + +function validateDecisions({ domains, analysisCoverage, maxConcurrency }) { + if (domains !== undefined && (!Array.isArray(domains) + || domains.length === 0 + || new Set(domains).size !== domains.length + || domains.some((domain) => !SUPPORTED_DOMAINS.has(domain)))) { + throw new BatchReviewError("Review requires unique supported Assessment domains", "invalid_decisions"); + } + if (!SUPPORTED_COVERAGE.has(analysisCoverage)) { + throw new BatchReviewError("Review coverage must be issue-only or full", "invalid_decisions"); + } + if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 7) { + throw new BatchReviewError("Review maxConcurrency must be between 1 and 7", "invalid_decisions"); + } +} + +function optionalString(value, name) { + if (value === undefined) return undefined; + if (typeof value !== "string" || !value.trim()) { + throw new BatchReviewError(`${name} must be a non-empty string`, "invalid_decisions"); + } + return value.trim(); +} + +function optionalStringArray(value, name) { + if (value === undefined) return undefined; + if (!Array.isArray(value) + || value.length === 0 + || value.some((entry) => typeof entry !== "string" || !entry.trim())) { + throw new BatchReviewError(`${name} must be a non-empty string array`, "invalid_decisions"); + } + const normalized = value.map((entry) => entry.trim()); + if (new Set(normalized).size !== normalized.length) { + throw new BatchReviewError(`${name} must contain unique values`, "invalid_decisions"); + } + return normalized; +} + +function assessmentOptions(options) { + if (options.enableContainerization !== undefined && typeof options.enableContainerization !== "boolean") { + throw new BatchReviewError("enableContainerization must be a boolean", "invalid_decisions"); + } + return Object.fromEntries(Object.entries({ + targetRuntime: optionalString(options.targetRuntime, "targetRuntime"), + targetComputeServices: optionalStringArray(options.targetComputeServices, "targetComputeServices"), + enableContainerization: options.enableContainerization, + targetOS: optionalStringArray(options.targetOS, "targetOS"), + minimumCveSeverity: optionalString(options.minimumCveSeverity, "minimumCveSeverity"), + cveScanScope: optionalString(options.cveScanScope, "cveScanScope"), + }).filter(([, value]) => value !== undefined)); +} + +function repositoryEntry(repository) { + return { + repoId: repository.repoId, + name: repository.name, + workspacePath: repository.workspacePath, + executionUnitIds: repository.executionUnits.map((unit) => unit.executionUnitId), + languages: [...new Set(repository.executionUnits.flatMap((unit) => unit.languages))], + warnings: repository.warnings, + errors: repository.errors, + }; +} + +function markdownReview(review) { + const lines = [ + "# Batch Assessment Review", + "", + `Status: ${review.status}`, + "", + "## Selection", + "", + ...review.selectedExecutionUnitIds.map((executionUnitId) => `- ${executionUnitId}`), + "", + "## Decisions", + "", + `- Domains: ${review.decisions.domains?.join(", ") ?? "language-specific defaults"}`, + `- Coverage: ${review.decisions.analysisCoverage}`, + `- Max concurrency: ${review.decisions.maxConcurrency}`, + "- Repository scheduling: sequential", + ...Object.entries(review.decisions) + .filter(([name]) => !["domains", "analysisCoverage", "maxConcurrency", "repositoryScheduling"].includes(name)) + .map(([name, value]) => `- ${name}: ${JSON.stringify(value)}`), + "", + "## Preflight", + "", + `- Ready: ${review.groups.ready.length}`, + `- Needs attention: ${review.groups.needsAttention.length}`, + `- Clone required: ${review.groups.cloneRequired.length}`, + `- Blocked: ${review.groups.blocked.length}`, + `- Blocked execution units: ${review.blockedExecutionUnits.length}`, + "", + ]; + if (review.effectiveAssessments.length > 0) { + lines.push("### Effective assessments", ""); + for (const assessment of review.effectiveAssessments) { + const assessmentSummary = assessment.language === "unknown" + ? "language and default domains resolve after clone" + : `${assessment.language}; ${assessment.domains.length > 0 ? assessment.domains.join(", ") : "dependency assessment"}`; + lines.push(`- ${assessment.executionUnitId}: ${assessmentSummary}`); + } + lines.push(""); + } + if (review.blockedExecutionUnits.length > 0) { + lines.push("### Blocked execution units", ""); + for (const unit of review.blockedExecutionUnits) { + lines.push(`- ${unit.executionUnitId}: ${unit.languages.join(", ")} (${unit.reason})`); + } + lines.push(""); + } + for (const [heading, entries] of [ + ["Ready", review.groups.ready], + ["Needs attention", review.groups.needsAttention], + ["Clone required", review.groups.cloneRequired], + ["Blocked", review.groups.blocked], + ]) { + if (entries.length === 0) continue; + lines.push(`### ${heading}`, ""); + for (const entry of entries) { + const details = [...entry.warnings, ...entry.errors]; + lines.push(`- ${entry.repoId}${details.length > 0 ? `: ${details.join("; ")}` : ""}`); + } + lines.push(""); + } + if (review.status === "ready_for_approval") { + lines.push( + "## Approval", + "", + "This Review does not authorize execution. The only valid choices are exactly:", + "", + "- Start batch", + "- Cancel", + "", + ); + } + lines.push(`Result directory: ${review.batchRoot}`, ""); + return lines.join("\n"); +} + +export function formatReviewHandoff(review) { + const fields = [ + review.status === "ready_for_approval" ? "BATCH_REVIEW_READY" : "BATCH_REVIEW_BLOCKED", + `batchRoot: ${review.batchRoot}`, + `reviewPath: ${review.reviewPath}`, + `reviewMarkdownPath: ${review.reviewMarkdownPath}`, + `reviewSha256: ${review.reviewSha256}`, + `reviewMarkdownSha256: ${review.reviewMarkdownSha256}`, + `inspectedReposPath: ${review.inspectedReposPath}`, + `inspectedReposSha256: ${review.inspectedReposSha256}`, + `batchAttemptScriptPath: ${review.batchAttemptScriptPath}`, + `configSha256: ${review.configSha256}`, + `selectedExecutionUnitIds: ${JSON.stringify(review.selectedExecutionUnitIds)}`, + `approvedNeedsAttention: ${JSON.stringify(review.approvedNeedsAttention)}`, + ...(review.decisions.domains === undefined + ? [] + : [`domains: ${JSON.stringify(review.decisions.domains)}`]), + `effectiveAssessments: ${JSON.stringify(review.effectiveAssessments)}`, + `blockedExecutionUnits: ${JSON.stringify(review.blockedExecutionUnits)}`, + `analysisCoverage: ${review.decisions.analysisCoverage}`, + `maxConcurrency: ${review.decisions.maxConcurrency}`, + ...Object.entries(review.decisions) + .filter(([name]) => !["domains", "analysisCoverage", "maxConcurrency", "repositoryScheduling"].includes(name)) + .map(([name, value]) => `${name}: ${JSON.stringify(value)}`), + ]; + return `${review.markdown}\n${fields.join("\n")}`; +} + +export function prepareBatchReview({ + configPath, + launchRoot, + allowedRoots = [], + domains, + analysisCoverage = "issue-only", + maxConcurrency = 1, + targetRuntime, + targetComputeServices, + enableContainerization, + targetOS, + minimumCveSeverity, + cveScanScope, + selectedExecutionUnitIds, + batchId, +} = {}) { + if (!path.isAbsolute(launchRoot ?? "") || !path.isAbsolute(configPath ?? "")) { + throw new BatchReviewError("Review launch root and config path must be absolute", "invalid_path"); + } + validateDecisions({ domains, analysisCoverage, maxConcurrency }); + const explicitOptions = assessmentOptions({ + targetRuntime, + targetComputeServices, + enableContainerization, + targetOS, + minimumCveSeverity, + cveScanScope, + }); + const absoluteLaunchRoot = path.resolve(launchRoot); + const absoluteConfigPath = path.resolve(configPath); + const resolved = resolveReposFile(absoluteConfigPath, { launchRoot: absoluteLaunchRoot }); + const authorizedRoots = allowedRoots.length > 0 ? allowedRoots : [absoluteLaunchRoot]; + const inspected = inspectResolvedRepositories(resolved, { + allowedRoots: authorizedRoots.map((root) => path.resolve(root)), + }); + const allUnits = inspected.repositories.flatMap((repository) => + repository.executionUnits.map((unit) => ({ repository, unit }))); + const eligible = allUnits.filter(({ repository }) => repository.preflightStatus !== "blocked"); + const requestedIds = selectedExecutionUnitIds?.length > 0 + ? selectedExecutionUnitIds + : eligible.map(({ unit }) => unit.executionUnitId); + if (new Set(requestedIds).size !== requestedIds.length) { + throw new BatchReviewError("Review requires unique execution units", "invalid_selection"); + } + const requested = requestedIds.map((executionUnitId) => { + const matches = eligible.filter(({ unit }) => unit.executionUnitId === executionUnitId); + if (matches.length !== 1) { + throw new BatchReviewError( + `Execution unit is not uniquely selectable: ${executionUnitId}`, + "invalid_selection", + ); + } + return matches[0]; + }); + const blockedExecutionUnits = eligible + .filter(({ unit }) => unit.languages.length > 1) + .map(({ unit }) => ({ + executionUnitId: unit.executionUnitId, + languages: unit.languages, + reason: "mixed-language execution units are not supported", + })); + const selected = requested.filter(({ unit }) => unit.languages.length === 1); + const selectedIds = selected.map(({ unit }) => unit.executionUnitId); + const effectiveAssessments = selected.map(({ unit }) => ({ + executionUnitId: unit.executionUnitId, + language: unit.languages[0], + domains: domains ?? (unit.languages[0] === "unknown" ? [] : defaultAssessmentDomains(unit.languages[0])), + })); + + const stableBatchId = batchId ?? `batch-review-${crypto.randomUUID()}`; + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(stableBatchId)) { + throw new BatchReviewError("Review batch ID is invalid", "invalid_batch_id"); + } + const batchesRoot = path.join(absoluteLaunchRoot, ".github", "modernize", "batches"); + const batchRoot = path.join(batchesRoot, stableBatchId); + fs.mkdirSync(batchesRoot, { recursive: true }); + fs.mkdirSync(batchRoot, { recursive: false }); + const batchAttemptScriptPath = materializeBatchRuntime(batchRoot); + const scratchRoot = path.join(batchRoot, "scratch"); + fs.mkdirSync(scratchRoot); + const resolvedReposPath = path.join(scratchRoot, "resolved-repos.json"); + const inspectedReposPath = path.join(scratchRoot, "inspected-repos.json"); + const reviewPath = path.join(batchRoot, "review.json"); + const reviewMarkdownPath = path.join(batchRoot, "REVIEW.md"); + atomicWriteJson(resolvedReposPath, resolved); + atomicWriteJson(inspectedReposPath, inspected); + + const cloneRequired = inspected.repositories.filter((repository) => + repository.warnings.includes("repository clone is required")); + const groups = { + ready: inspected.repositories.filter((repository) => repository.preflightStatus === "ready") + .map(repositoryEntry), + needsAttention: inspected.repositories.filter((repository) => + repository.preflightStatus === "needs_attention" && !cloneRequired.includes(repository)) + .map(repositoryEntry), + cloneRequired: cloneRequired.map(repositoryEntry), + blocked: inspected.repositories.filter((repository) => repository.preflightStatus === "blocked") + .map(repositoryEntry), + }; + const review = { + schemaVersion: 1, + status: selectedIds.length > 0 ? "ready_for_approval" : "blocked", + batchId: stableBatchId, + batchRoot, + reviewPath, + reviewMarkdownPath, + batchAttemptScriptPath, + launchRoot: absoluteLaunchRoot, + resolvedReposPath, + inspectedReposPath, + inspectedReposSha256: fileSha256(inspectedReposPath), + configSha256: inspected.configSha256, + selectedExecutionUnitIds: selectedIds, + approvedNeedsAttention: selected + .filter(({ repository }) => repository.preflightStatus === "needs_attention") + .map(({ unit }) => unit.executionUnitId), + decisions: { + ...(domains === undefined ? {} : { domains }), + analysisCoverage, + maxConcurrency, + ...explicitOptions, + }, + repositoryScheduling: "sequential", + apps: inspected.apps, + groups, + effectiveAssessments, + blockedExecutionUnits, + }; + review.markdown = markdownReview(review); + atomicWriteJson(reviewPath, review); + atomicWrite(reviewMarkdownPath, `${review.markdown}\n`); + return { + ...review, + reviewSha256: fileSha256(reviewPath), + reviewMarkdownSha256: fileSha256(reviewMarkdownPath), + }; +} + +function optionValue(name) { + const index = process.argv.indexOf(name); + const value = index >= 0 ? process.argv[index + 1] : undefined; + return value && !value.startsWith("--") ? value : undefined; +} + +function booleanOption(name) { + const value = optionValue(name); + if (value === undefined) return undefined; + if (value === "true") return true; + if (value === "false") return false; + throw new BatchReviewError(`${name} must be true or false`, "invalid_decisions"); +} + +function optionValues(name) { + return process.argv.flatMap((value, index) => { + const candidate = process.argv[index + 1]; + return value === name && candidate && !candidate.startsWith("--") ? [candidate] : []; + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const domainValues = optionValues("--domain"); + const targetComputeServices = optionValues("--target-compute-service"); + const targetOS = optionValues("--target-os"); + const review = prepareBatchReview({ + configPath: optionValue("--config"), + launchRoot: optionValue("--launch-root"), + allowedRoots: optionValues("--allowed-root"), + domains: domainValues.length > 0 ? domainValues : undefined, + analysisCoverage: optionValue("--coverage") ?? "issue-only", + maxConcurrency: Number(optionValue("--max-concurrency") ?? 1), + targetRuntime: optionValue("--target-runtime"), + targetComputeServices: targetComputeServices.length > 0 ? targetComputeServices : undefined, + enableContainerization: booleanOption("--enable-containerization"), + targetOS: targetOS.length > 0 ? targetOS : undefined, + minimumCveSeverity: optionValue("--minimum-cve-severity"), + cveScanScope: optionValue("--cve-scan-scope"), + selectedExecutionUnitIds: optionValues("--execution-unit-id"), + batchId: optionValue("--batch-id"), + }); + process.stdout.write(formatReviewHandoff(review)); + } catch (error) { + process.stderr.write(`${JSON.stringify({ + code: error.code ?? "batch_review_failed", + message: error.message, + })}\n`); + process.exitCode = 1; + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/prepare-review.test.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/prepare-review.test.mjs new file mode 100644 index 0000000..2742336 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/prepare-review.test.mjs @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { formatReviewHandoff, prepareBatchReview } from "./prepare-review.mjs"; + +const scriptPath = fileURLToPath(new URL("./prepare-review.mjs", import.meta.url)); + +function createRepository(root, name) { + const repositoryPath = path.join(root, "repos", name); + fs.mkdirSync(path.join(repositoryPath, "src"), { recursive: true }); + fs.writeFileSync(path.join(repositoryPath, "package.json"), `${JSON.stringify({ name })}\n`); + fs.writeFileSync(path.join(repositoryPath, "src", "index.js"), "export default true;\n"); + return repositoryPath; +} + +test("prepareBatchReview writes a read-only handoff", (t) => { + const launchRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-review-test-")); + t.after(() => fs.rmSync(launchRoot, { recursive: true, force: true })); + const repositories = ["alpha-service", "beta-service"].map((name) => ({ + name, + path: createRepository(launchRoot, name), + })); + const configPath = path.join(launchRoot, ".github", "modernize", "repos.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, `${JSON.stringify({ repos: repositories }, null, 2)}\n`); + + const review = prepareBatchReview({ + configPath, + launchRoot, + allowedRoots: [launchRoot], + domains: ["cloud-readiness"], + analysisCoverage: "issue-only", + maxConcurrency: 1, + targetRuntime: "java-21", + targetComputeServices: ["azure-container-apps", "app-service"], + enableContainerization: true, + targetOS: ["linux"], + minimumCveSeverity: "high", + cveScanScope: "all", + batchId: "review-test", + }); + + assert.deepEqual(review.selectedExecutionUnitIds, ["alpha-service", "beta-service"]); + assert.deepEqual(review.approvedNeedsAttention, ["alpha-service", "beta-service"]); + assert.equal(review.groups.needsAttention.length, 2); + assert.deepEqual(review.decisions, { + domains: ["cloud-readiness"], + analysisCoverage: "issue-only", + maxConcurrency: 1, + targetRuntime: "java-21", + targetComputeServices: ["azure-container-apps", "app-service"], + enableContainerization: true, + targetOS: ["linux"], + minimumCveSeverity: "high", + cveScanScope: "all", + }); + assert.match(review.configSha256, /^[a-f0-9]{64}$/); + assert.equal(fs.existsSync(path.join(review.batchRoot, "review.json")), true); + assert.equal(fs.existsSync(path.join(review.batchRoot, "REVIEW.md")), true); + assert.equal(review.reviewPath, path.join(review.batchRoot, "review.json")); + assert.equal(review.reviewMarkdownPath, path.join(review.batchRoot, "REVIEW.md")); + assert.equal( + review.batchAttemptScriptPath, + path.join(review.batchRoot, "runtime", "skills", "batch-modernization", "scripts", "batch-attempt.mjs"), + ); + assert.equal(fs.statSync(review.batchAttemptScriptPath).isFile(), true); + assert.equal( + fs.statSync(path.join(review.batchRoot, "runtime", "skills", "assessment", "scripts", "assess-cli.mjs")).isFile(), + true, + ); + assert.match(review.reviewSha256, /^[a-f0-9]{64}$/); + assert.match(review.reviewMarkdownSha256, /^[a-f0-9]{64}$/); + assert.match(review.inspectedReposSha256, /^[a-f0-9]{64}$/); + assert.equal(fs.existsSync(review.resolvedReposPath), true); + assert.equal(fs.existsSync(review.inspectedReposPath), true); + for (const forbidden of [ + "manifest.json", + "state.json", + "lease.json", + "selection.json", + "assessment-input.json", + "attempts", + ]) { + assert.equal(fs.existsSync(path.join(review.batchRoot, forbidden)), false); + } + + const handoff = formatReviewHandoff(review); + assert.match(handoff, /^# Batch Assessment Review/m); + assert.match(handoff, /The only valid choices are exactly:/); + assert.match(handoff, /^- Start batch$/m); + assert.match(handoff, /^- Cancel$/m); + assert.match(handoff, /BATCH_REVIEW_READY/); + assert.match(handoff, new RegExp(`reviewPath: ${review.reviewPath.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")}`)); + assert.match(handoff, new RegExp(`reviewSha256: ${review.reviewSha256}`)); + assert.match(handoff, new RegExp(`inspectedReposSha256: ${review.inspectedReposSha256}`)); + assert.match(handoff, new RegExp(`batchAttemptScriptPath: ${review.batchAttemptScriptPath.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")}`)); + assert.match(handoff, /selectedExecutionUnitIds: \["alpha-service","beta-service"\]/); + assert.match(handoff, /approvedNeedsAttention: \["alpha-service","beta-service"\]/); + assert.match(handoff, /targetRuntime: "java-21"/); + assert.match(handoff, /targetComputeServices: \["azure-container-apps","app-service"\]/); + assert.match(handoff, /enableContainerization: true/); + assert.match(handoff, /targetOS: \["linux"\]/); + assert.match(handoff, /minimumCveSeverity: "high"/); + assert.match(handoff, /cveScanScope: "all"/); + assert.equal(handoff.endsWith("\n"), false); +}); + +test("prepareBatchReview rejects invalid decisions before creating a preview", () => { + assert.throws( + () => prepareBatchReview({ + configPath: path.resolve("repos.json"), + launchRoot: path.resolve("workspace"), + domains: ["unsupported"], + }), + /supported Assessment domains/, + ); + assert.throws( + () => prepareBatchReview({ + configPath: path.resolve("repos.json"), + launchRoot: path.resolve("workspace"), + targetComputeServices: "azure-container-apps", + }), + /targetComputeServices/, + ); + assert.throws( + () => prepareBatchReview({ + configPath: path.resolve("repos.json"), + launchRoot: path.resolve("workspace"), + enableContainerization: "true", + }), + /enableContainerization/, + ); +}); + +test("prepareBatchReview keeps clone-required repositories selectable", (t) => { + const launchRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-review-remote-")); + t.after(() => fs.rmSync(launchRoot, { recursive: true, force: true })); + const configPath = path.join(launchRoot, "repos.json"); + fs.writeFileSync(configPath, `${JSON.stringify({ + repos: [{ name: "remote-only", url: "https://github.com/contoso/remote-only.git" }], + }, null, 2)}\n`); + + const review = prepareBatchReview({ + configPath, + launchRoot, + allowedRoots: [launchRoot], + batchId: "remote-review", + }); + + assert.equal(review.status, "ready_for_approval"); + assert.deepEqual(review.selectedExecutionUnitIds, ["remote-only"]); + assert.deepEqual(review.approvedNeedsAttention, ["remote-only"]); + assert.deepEqual(review.groups.cloneRequired[0].executionUnitIds, ["remote-only"]); + assert.deepEqual(review.effectiveAssessments, [{ + executionUnitId: "remote-only", + language: "unknown", + domains: [], + }]); + assert.match(review.markdown, /language and default domains resolve after clone/); +}); + +test("prepare-review CLI ignores a dangling domain option and applies the language default", (t) => { + const launchRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-review-cli-default-")); + t.after(() => fs.rmSync(launchRoot, { recursive: true, force: true })); + const repositoryPath = createRepository(launchRoot, "alpha-service"); + const configPath = path.join(launchRoot, "repos.json"); + fs.writeFileSync(configPath, `${JSON.stringify({ + repos: [{ name: "alpha-service", path: repositoryPath }], + })}\n`); + const result = spawnSync(process.execPath, [ + scriptPath, + "--config", configPath, + "--launch-root", launchRoot, + "--allowed-root", launchRoot, + "--domain", + "--coverage", "issue-only", + "--max-concurrency", "1", + ], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /effectiveAssessments: \[{"executionUnitId":"alpha-service","language":"javascript","domains":\[\]/); + assert.doesNotMatch(result.stdout, /^domains:/m); +}); + +test("prepareBatchReview derives Single defaults per execution unit", (t) => { + const launchRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-review-language-defaults-")); + t.after(() => fs.rmSync(launchRoot, { recursive: true, force: true })); + const javaPath = path.join(launchRoot, "repos", "java-api"); + const dotnetPath = path.join(launchRoot, "repos", "dotnet-api"); + const typescriptPath = path.join(launchRoot, "repos", "typescript-ui"); + for (const repositoryPath of [javaPath, dotnetPath, typescriptPath]) { + fs.mkdirSync(repositoryPath, { recursive: true }); + } + fs.writeFileSync(path.join(javaPath, "pom.xml"), "\n"); + fs.writeFileSync(path.join(dotnetPath, "app.csproj"), "\n"); + fs.writeFileSync(path.join(typescriptPath, "package.json"), "{}\n"); + fs.writeFileSync(path.join(typescriptPath, "tsconfig.json"), "{}\n"); + const configPath = path.join(launchRoot, "repos.json"); + fs.writeFileSync(configPath, `${JSON.stringify({ + repos: [ + { name: "java-api", path: javaPath }, + { name: "dotnet-api", path: dotnetPath }, + { name: "typescript-ui", path: typescriptPath }, + ], + })}\n`); + + const review = prepareBatchReview({ + configPath, + launchRoot, + allowedRoots: [launchRoot], + batchId: "language-defaults", + }); + + assert.equal(Object.hasOwn(review.decisions, "domains"), false); + assert.deepEqual(review.effectiveAssessments, [ + { executionUnitId: "java-api", language: "java", domains: ["java-upgrade", "cloud-readiness"] }, + { executionUnitId: "dotnet-api", language: "dotnet", domains: ["cloud-readiness"] }, + { executionUnitId: "typescript-ui", language: "typescript", domains: [] }, + ]); +}); + +test("prepareBatchReview reports and excludes a mixed-language execution unit", (t) => { + const launchRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-review-mixed-language-")); + t.after(() => fs.rmSync(launchRoot, { recursive: true, force: true })); + const repositoryPath = createRepository(launchRoot, "mixed-api"); + fs.writeFileSync(path.join(repositoryPath, "pom.xml"), "\n"); + const configPath = path.join(launchRoot, "repos.json"); + fs.writeFileSync(configPath, `${JSON.stringify({ + repos: [{ name: "mixed-api", path: repositoryPath }], + })}\n`); + + const review = prepareBatchReview({ + configPath, + launchRoot, + allowedRoots: [launchRoot], + batchId: "mixed-language", + }); + + assert.equal(review.status, "blocked"); + assert.deepEqual(review.selectedExecutionUnitIds, []); + assert.deepEqual(review.blockedExecutionUnits, [{ + executionUnitId: "mixed-api", + languages: ["java", "javascript"], + reason: "mixed-language execution units are not supported", + }]); + assert.match(review.markdown, /mixed-api: java, javascript/); + assert.match(formatReviewHandoff(review), /BATCH_REVIEW_BLOCKED/); + assert.equal(fs.existsSync(path.join(review.batchRoot, "manifest.json")), false); + assert.equal(fs.existsSync(path.join(review.batchRoot, "state.json")), false); +}); \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/probe-default-config.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/probe-default-config.mjs new file mode 100644 index 0000000..664583a --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/probe-default-config.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export function probeDefaultBatchConfig({ launchRoot } = {}) { + if (!path.isAbsolute(launchRoot ?? "")) { + throw new Error("launch-root must be an absolute path"); + } + const resolvedLaunchRoot = path.resolve(launchRoot); + const stat = fs.statSync(resolvedLaunchRoot, { throwIfNoEntry: false }); + if (!stat?.isDirectory()) { + throw new Error("launch-root must be an existing directory"); + } + const configPath = path.join(resolvedLaunchRoot, ".github", "modernize", "repos.json"); + const configStat = fs.statSync(configPath, { throwIfNoEntry: false }); + return { + schemaVersion: 1, + launchRoot: resolvedLaunchRoot, + configPath, + status: configStat?.isFile() ? "found" : configStat ? "invalid" : "absent", + }; +} + +function optionValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + process.stdout.write(`${JSON.stringify(probeDefaultBatchConfig({ + launchRoot: optionValue("--launch-root"), + }))}\n`); + } catch (error) { + process.stderr.write(`${JSON.stringify({ + code: "batch_mode_probe_failed", + message: error.message, + })}\n`); + process.exitCode = 1; + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/probe-default-config.test.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/probe-default-config.test.mjs new file mode 100644 index 0000000..6163296 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/probe-default-config.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { probeDefaultBatchConfig } from "./probe-default-config.mjs"; + +function fixture(t) { + const launchRoot = fs.mkdtempSync(path.join(os.tmpdir(), "batch-mode-probe-")); + t.after(() => fs.rmSync(launchRoot, { recursive: true, force: true })); + return { + launchRoot, + configPath: path.join(launchRoot, ".github", "modernize", "repos.json"), + }; +} + +test("default config probe distinguishes absent, file, and invalid paths without mutation", (t) => { + const value = fixture(t); + assert.equal(probeDefaultBatchConfig(value).status, "absent"); + assert.deepEqual(fs.readdirSync(value.launchRoot), []); + + fs.mkdirSync(path.dirname(value.configPath), { recursive: true }); + fs.writeFileSync(value.configPath, '{"repos":[]}\n'); + const found = probeDefaultBatchConfig(value); + assert.equal(found.status, "found"); + assert.equal(found.configPath, value.configPath); + + fs.rmSync(value.configPath); + fs.mkdirSync(value.configPath); + assert.equal(probeDefaultBatchConfig(value).status, "invalid"); +}); + +test("default config probe requires an absolute existing launch root", () => { + assert.throws(() => probeDefaultBatchConfig({ launchRoot: "relative" }), /absolute/); + assert.throws( + () => probeDefaultBatchConfig({ launchRoot: path.join(os.tmpdir(), "missing-batch-probe-root") }), + /existing directory/, + ); +}); \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/protocol-schemas.test.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/protocol-schemas.test.mjs new file mode 100644 index 0000000..2c142b7 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/protocol-schemas.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { validateSchema } from "./schema-validator.mjs"; +import { resolveReposDocument } from "./resolve-repos.mjs"; + +const scriptsRoot = path.dirname(fileURLToPath(import.meta.url)); +const schemasRoot = path.resolve(scriptsRoot, "..", "schemas"); +const schemaNames = [ + "resolved-repos.schema.json", + "execution-unit.schema.json", + "attempt-request.schema.json", + "attempt-result.schema.json", + "batch-state.schema.json", + "event.schema.json", + "needs-input.schema.json", +]; + +function loadSchema(name) { + const schemaPath = path.join(schemasRoot, name); + return { schemaPath, schema: JSON.parse(fs.readFileSync(schemaPath, "utf8")) }; +} + +test("batch-modernization ships exactly the seven v1 protocol schemas", () => { + assert.deepEqual( + fs.readdirSync(schemasRoot).filter((name) => name.endsWith(".schema.json")).sort(), + [...schemaNames].sort(), + ); + for (const name of schemaNames) { + const { schema } = loadSchema(name); + assert.equal(schema.$id, name); + assert.equal(schema.properties.schemaVersion.const, 1); + assert.equal(schema.additionalProperties, false); + } +}); + +test("resolved config output satisfies the production schema", () => { + const launchRoot = path.resolve(os.tmpdir(), "batch-schema-root"); + const resolved = resolveReposDocument({ + producer: "portfolio", + future: true, + repos: [{ + name: "orders", + path: path.join(launchRoot, "orders"), + include_paths: ["api"], + future_repo: "preserved", + }], + apps: [{ identifier: "commerce", repos: ["orders"], future_app: 1 }], + }, { launchRoot }); + const { schema, schemaPath } = loadSchema("resolved-repos.schema.json"); + assert.deepEqual(validateSchema(resolved, schema, schemaPath), []); +}); + +test("attempt request schema excludes lease ownership capability", () => { + const { schema } = loadSchema("attempt-request.schema.json"); + assert.equal(Object.hasOwn(schema.properties, "leaseToken"), false); + assert.equal(schema.additionalProperties, false); +}); \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/resolve-repos.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/resolve-repos.mjs new file mode 100644 index 0000000..2f5b9fc --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/resolve-repos.mjs @@ -0,0 +1,430 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPOSITORY_KEYS = new Set([ + "name", + "url", + "path", + "branch", + "include_paths", +]); +const ROOT_KEYS = new Set(["producer", "repos", "apps"]); +const APP_KEYS = new Set(["identifier", "repos"]); +const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; +const SECRET_KEY = /(?:password|passwd|token|secret|credential|api[-_]?key|access[-_]?key)$/i; + +export class ConfigValidationError extends Error { + constructor(issues) { + super(`Invalid repos configuration:\n${issues.map((issue) => `- ${issue}`).join("\n")}`); + this.name = "ConfigValidationError"; + this.issues = issues; + } +} + +function unknownFields(value, knownKeys) { + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => !knownKeys.has(key)) + .map(([key, entry]) => [key, SECRET_KEY.test(key) ? "" : sanitizeUnknownValue(entry)]), + ); +} + +function sanitizeUnknownString(value) { + return value.replace(/(?:https?|ssh):\/\/[^\s'"<>]+/gi, (candidate) => { + try { + const parsed = new URL(candidate); + parsed.username = ""; + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return ""; + } + }); +} + +function sanitizeUnknownValue(value) { + if (typeof value === "string") return sanitizeUnknownString(value); + if (Array.isArray(value)) return value.map(sanitizeUnknownValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + SECRET_KEY.test(key) ? "" : sanitizeUnknownValue(entry), + ]), + ); + } + return value; +} + +function ensureObject(value, field, issues) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + issues.push(`${field} must be an object`); + return null; + } + return value; +} + +function requireString(value, field, issues) { + if (typeof value !== "string" || !value.trim()) { + issues.push(`${field} must be a non-empty string`); + return null; + } + return value.trim(); +} + +export function sanitizeRepositoryName(name) { + const sanitized = String(name) + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-.]+|[-.]+$/g, "") + .slice(0, 128); + return WINDOWS_RESERVED_NAME.test(sanitized) ? `repo-${sanitized}` : sanitized; +} + +function isValidBranchName(branch) { + return !( + branch === "@" + || branch.startsWith("-") + || branch.startsWith("/") + || branch.endsWith("/") + || branch.endsWith(".") + || branch.includes("//") + || branch.includes("..") + || branch.includes("@{") + || /[\u0000-\u0020\u007f~^:?*[\\]/.test(branch) + || branch.split("/").some((part) => part.startsWith(".") || part.endsWith(".lock")) + ); +} + +function parseScpGitUrl(value) { + if (value.includes("://")) { + return null; + } + const match = value.match(/^(?:[^@/:\s]+@)?([^/:\s]+):(.+)$/); + if (!match || /^[A-Za-z]:[\\/]/.test(value)) { + return null; + } + return { host: match[1].toLowerCase(), repositoryPath: match[2] }; +} + +function cleanRepositoryPath(repositoryPath) { + return repositoryPath + .replace(/\\/g, "/") + .replace(/^\/+/, "") + .replace(/\/+$/, "") + .replace(/\.git$/i, ""); +} + +export function sanitizeGitUrl(value) { + if (typeof value !== "string" || !value.trim() || /[\r\n]/.test(value)) { + throw new Error("Git URL must be a non-empty single-line string"); + } + const input = value.trim(); + const scp = parseScpGitUrl(input); + if (scp) { + const repositoryPath = cleanRepositoryPath(scp.repositoryPath); + if (!repositoryPath) throw new Error("Git URL has no repository path"); + return `ssh://${scp.host}/${repositoryPath}.git`; + } + + let parsed; + try { + parsed = new URL(input); + } catch { + throw new Error("Git URL must use HTTPS, SSH, or SCP-style SSH syntax"); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "ssh:") { + throw new Error("Git URL protocol must be HTTPS or SSH"); + } + const repositoryPath = cleanRepositoryPath(parsed.pathname); + if (!parsed.hostname || !repositoryPath) { + throw new Error("Git URL must include a host and repository path"); + } + const port = parsed.port ? `:${parsed.port}` : ""; + return `${parsed.protocol}//${parsed.hostname.toLowerCase()}${port}/${repositoryPath}.git`; +} + +export function normalizeRemoteIdentity(value) { + const sanitized = sanitizeGitUrl(value); + const parsed = new URL(sanitized); + return `${parsed.hostname.toLowerCase()}${parsed.port ? `:${parsed.port}` : ""}/${cleanRepositoryPath(parsed.pathname).toLowerCase()}`; +} + +function normalizeIncludePaths(value, field, issues) { + if (value === undefined) return []; + if (!Array.isArray(value)) { + issues.push(`${field} must be an array`); + return []; + } + const normalized = []; + for (const [index, entry] of value.entries()) { + const itemField = `${field}[${index}]`; + const text = requireString(entry, itemField, issues); + if (!text) continue; + if (path.isAbsolute(text) || /^[A-Za-z]:[\\/]/.test(text)) { + issues.push(`${itemField} must be repository-relative`); + continue; + } + const clean = path.posix.normalize(text.replace(/\\/g, "/")).replace(/^\.\//, ""); + if (clean === ".." || clean.startsWith("../") || clean === ".") { + issues.push(`${itemField} must stay inside the repository and name a project path`); + continue; + } + if (!normalized.includes(clean)) normalized.push(clean); + } + return normalized; +} + +function expandHome(inputPath, homeDir) { + if (inputPath === "~") return homeDir; + if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) { + return path.join(homeDir, inputPath.slice(2)); + } + return inputPath; +} + +function createExecutionUnits(repoId, workspacePath, includePaths, issues, field) { + const relativePaths = includePaths.length > 0 ? includePaths : [null]; + const seen = new Set(); + return relativePaths.flatMap((relativePath) => { + const suffix = relativePath ? sanitizeRepositoryName(relativePath.replaceAll("/", "-")) : ""; + const executionUnitId = suffix ? `${repoId}/${suffix}` : repoId; + if (seen.has(executionUnitId.toLowerCase())) { + issues.push(`${field} produces duplicate execution unit ${JSON.stringify(executionUnitId)}`); + return []; + } + seen.add(executionUnitId.toLowerCase()); + const unitPath = relativePath ? path.resolve(workspacePath, ...relativePath.split("/")) : workspacePath; + return [{ + schemaVersion: 1, + repoId, + executionUnitId, + displayName: relativePath ? `${repoId}/${relativePath}` : repoId, + workspacePath: unitPath, + gitRoot: workspacePath, + scopeRoots: [unitPath], + languages: ["unknown"], + source: relativePath ? "include-path" : "repository-root", + }]; + }); +} + +function normalizeRepository(raw, index, context, issues) { + const field = `repos[${index}]`; + const repository = ensureObject(raw, field, issues); + if (!repository) return null; + const name = requireString(repository.name, `${field}.name`, issues); + const repoId = name ? sanitizeRepositoryName(name) : ""; + if (name && !repoId) issues.push(`${field}.name does not produce a usable repository identifier`); + + const warnings = []; + let sanitizedUrl = null; + if (repository.url !== undefined && repository.url !== null) { + try { + sanitizedUrl = sanitizeGitUrl(repository.url); + } catch (error) { + issues.push(`${field}.url: ${error.message}`); + } + } + let configuredPath = null; + if (repository.path !== undefined && repository.path !== null) { + const pathText = requireString(repository.path, `${field}.path`, issues); + if (pathText) { + const expanded = expandHome(pathText, context.homeDir); + if (!path.isAbsolute(expanded)) { + issues.push(`${field}.path must be absolute`); + } else { + configuredPath = path.resolve(expanded); + } + } + } + if (!sanitizedUrl && !configuredPath) { + issues.push(`${field} must define url or path`); + } + if (sanitizedUrl && configuredPath) { + warnings.push("Both url and path are present; url takes precedence"); + } + + let branch = null; + if (repository.branch !== undefined && repository.branch !== null) { + const branchText = requireString(repository.branch, `${field}.branch`, issues); + if (branchText && !isValidBranchName(branchText)) { + issues.push(`${field}.branch contains unsupported Git ref characters`); + } else if (branchText && sanitizedUrl) { + branch = branchText; + } else if (branchText) { + warnings.push("branch is ignored for a local path repository"); + } + } + + const includePaths = normalizeIncludePaths(repository.include_paths, `${field}.include_paths`, issues); + const workspacePath = sanitizedUrl + ? path.join(context.launchRoot, "repos", repoId || `invalid-${index}`) + : configuredPath; + const executionUnits = workspacePath && repoId + ? createExecutionUnits(repoId, workspacePath, includePaths, issues, `${field}.include_paths`) + : []; + + return { + repoId, + name: name ?? "", + input: { + url: sanitizedUrl, + path: configuredPath, + branch, + includePaths, + }, + workspacePath: workspacePath ?? "", + preflightStatus: "needs_attention", + warnings: [...warnings, "workspace inspection pending"], + errors: [], + executionUnits, + unknownFields: unknownFields(repository, REPOSITORY_KEYS), + }; +} + +function normalizeApps(rawApps, repositories, issues) { + if (rawApps === undefined) return []; + if (!Array.isArray(rawApps)) { + issues.push("apps must be an array"); + return []; + } + const names = new Map(repositories.map((repo) => [repo.name.toLowerCase(), repo.repoId])); + const identifiers = new Set(); + return rawApps.flatMap((raw, index) => { + const field = `apps[${index}]`; + const app = ensureObject(raw, field, issues); + if (!app) return []; + const identifier = requireString(app.identifier, `${field}.identifier`, issues); + if (identifier && identifiers.has(identifier.toLowerCase())) { + issues.push(`${field}.identifier duplicates another app (case-insensitive)`); + } + if (identifier) identifiers.add(identifier.toLowerCase()); + if (!Array.isArray(app.repos)) { + issues.push(`${field}.repos must be an array`); + return []; + } + const repoIds = []; + for (const [repoIndex, repositoryName] of app.repos.entries()) { + const text = requireString(repositoryName, `${field}.repos[${repoIndex}]`, issues); + if (!text) continue; + const repoId = names.get(text.toLowerCase()); + if (!repoId) { + issues.push(`${field}.repos[${repoIndex}] references unknown repository ${JSON.stringify(text)}`); + } else if (!repoIds.includes(repoId)) { + repoIds.push(repoId); + } + } + return [{ identifier: identifier ?? "", repoIds, unknownFields: unknownFields(app, APP_KEYS) }]; + }); +} + +export function resolveReposDocument(document, { + configPath, + configSha256, + launchRoot, + homeDir = os.homedir(), +} = {}) { + const issues = []; + const absoluteLaunchRoot = path.resolve(launchRoot ?? process.cwd()); + let producer = null; + let rawRepositories; + let rawApps = []; + if (Array.isArray(document)) { + rawRepositories = document; + } else { + const root = ensureObject(document, "configuration", issues); + rawRepositories = root?.repos; + rawApps = root?.apps; + producer = root?.producer === undefined || root?.producer === null + ? null + : requireString(root.producer, "producer", issues); + if (root && !Array.isArray(rawRepositories)) issues.push("repos must be an array"); + } + if (Array.isArray(rawRepositories) && rawRepositories.length === 0) { + issues.push("repos must contain at least one repository"); + } + + const repositories = Array.isArray(rawRepositories) + ? rawRepositories.map((repo, index) => normalizeRepository(repo, index, { + launchRoot: absoluteLaunchRoot, + homeDir, + }, issues)).filter(Boolean) + : []; + const names = new Set(); + const repoIds = new Set(); + for (const [index, repository] of repositories.entries()) { + const nameKey = repository.name.toLowerCase(); + const idKey = repository.repoId.toLowerCase(); + if (names.has(nameKey)) issues.push(`repos[${index}].name duplicates another repository (case-insensitive)`); + if (repoIds.has(idKey)) issues.push(`repos[${index}].name collides after path sanitization`); + names.add(nameKey); + repoIds.add(idKey); + } + const apps = normalizeApps(rawApps, repositories, issues); + if (issues.length > 0) throw new ConfigValidationError(issues); + + return { + schemaVersion: 1, + configPath: path.resolve(configPath ?? path.join(absoluteLaunchRoot, ".github", "modernize", "repos.json")), + configSha256: configSha256 ?? crypto.createHash("sha256").update(JSON.stringify(document)).digest("hex"), + producer, + repositories, + apps, + unknownFields: Array.isArray(document) ? {} : unknownFields(document, ROOT_KEYS), + }; +} + +export function resolveReposFile(configPath, options = {}) { + const absoluteConfigPath = path.resolve(configPath); + const raw = fs.readFileSync(absoluteConfigPath); + let document; + try { + document = JSON.parse(raw.toString("utf8")); + } catch (error) { + throw new ConfigValidationError([`configuration is not valid JSON: ${error.message}`]); + } + return resolveReposDocument(document, { + ...options, + configPath: absoluteConfigPath, + configSha256: crypto.createHash("sha256").update(raw).digest("hex"), + }); +} + +function optionValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function emitResult(result) { + const output = optionValue("--output"); + if (!output) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + const outputPath = path.resolve(output); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + const temporaryPath = `${outputPath}.${process.pid}.${crypto.randomUUID()}.tmp`; + fs.writeFileSync(temporaryPath, `${JSON.stringify(result, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); + fs.renameSync(temporaryPath, outputPath); + process.stdout.write(`${JSON.stringify({ outputPath })}\n`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const result = resolveReposFile(optionValue("--config") ?? "", { + launchRoot: optionValue("--launch-root") ?? process.cwd(), + }); + emitResult(result); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/resolve-repos.test.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/resolve-repos.test.mjs new file mode 100644 index 0000000..98d96df --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/resolve-repos.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + ConfigValidationError, + normalizeRemoteIdentity, + resolveReposDocument, + resolveReposFile, + sanitizeGitUrl, +} from "./resolve-repos.mjs"; + +const launchRoot = path.resolve(os.tmpdir(), "batch-control-test-root"); + +test("v1 config resolves deterministic repository and root execution-unit identities", () => { + const result = resolveReposDocument([ + { name: "Orders API", url: "https://github.com/Contoso/orders.git" }, + { name: "billing", url: "git@github.com:Contoso/billing.git" }, + ], { launchRoot, configPath: path.join(launchRoot, "repos.json") }); + + assert.equal(result.producer, null); + assert.deepEqual(result.repositories.map((repo) => repo.repoId), ["Orders-API", "billing"]); + assert.equal(result.repositories[0].workspacePath, path.join(launchRoot, "repos", "Orders-API")); + assert.deepEqual(result.repositories[0].executionUnits.map((unit) => unit.executionUnitId), ["Orders-API"]); + assert.equal(result.repositories[1].input.url, "ssh://github.com/Contoso/billing.git"); +}); + +test("v2 config preserves unknown fields and creates separate include-path units", () => { + const result = resolveReposDocument({ + producer: "portfolio", + future_root: { enabled: true }, + repos: [{ + name: "orders", + path: path.join(launchRoot, "orders"), + branch: "ignored", + include_paths: ["services/api", "services/worker"], + project_id: "p1", + future_repo: { + callback: "http://user:secret@example.com/hook?token=x#fragment", + accessToken: "plain-secret", + }, + }], + apps: [{ + identifier: "commerce", + repos: ["ORDERS"], + output: { type: "local" }, + future_app: true, + }], + }, { launchRoot }); + + const repository = result.repositories[0]; + assert.deepEqual(repository.executionUnits.map((unit) => unit.executionUnitId), [ + "orders/services-api", + "orders/services-worker", + ]); + assert.equal(repository.executionUnits[0].workspacePath, path.join(launchRoot, "orders", "services", "api")); + assert.deepEqual(repository.unknownFields, { + project_id: "p1", + future_repo: { callback: "http://example.com/hook", accessToken: "" }, + }); + assert.deepEqual(result.unknownFields, { future_root: { enabled: true } }); + assert.deepEqual(result.apps[0], { + identifier: "commerce", + repoIds: ["orders"], + unknownFields: { output: { type: "local" }, future_app: true }, + }); + assert.doesNotMatch(JSON.stringify(result), /user|secret|token=x|fragment/); + assert.match(repository.warnings.join("\n"), /branch is ignored/); +}); + +test("repository IDs avoid cross-platform reserved filenames", () => { + const result = resolveReposDocument([ + { name: "CON", url: "https://example.com/con.git" }, + ], { launchRoot }); + assert.equal(result.repositories[0].repoId, "repo-CON"); +}); + +test("Git branch validation rejects traversal, lock, reflog, whitespace, and option forms", () => { + for (const branch of ["feature..next", "feature.lock", "@{bad}", "bad branch", "-danger"]) { + assert.throws( + () => resolveReposDocument([ + { name: `repo-${branch.length}`, url: "https://example.com/repo.git", branch }, + ], { launchRoot }), + /unsupported Git ref characters/, + branch, + ); + } +}); + +test("URL sanitization strips credentials, query, and fragment from persisted forms", () => { + const sanitized = sanitizeGitUrl("https://user:token@GitHub.com/Contoso/Orders.git?secret=yes#credential"); + assert.equal(sanitized, "https://github.com/Contoso/Orders.git"); + assert.equal(normalizeRemoteIdentity(sanitized), "github.com/contoso/orders"); + assert.equal(sanitizeGitUrl("git@GitHub.com:Contoso/Orders.git"), "ssh://github.com/Contoso/Orders.git"); +}); + +test("config rejects duplicate names, sanitized collisions, bad URLs, scopes, and apps", () => { + assert.throws( + () => resolveReposDocument({ + repos: [ + { name: "Orders API", url: "http://example.com/orders.git", include_paths: ["../escape"] }, + { name: "orders-api", path: "relative/path" }, + { name: "ORDERS API", url: "https://example.com/duplicate.git" }, + ], + apps: [{ identifier: "bad", repos: ["missing"] }], + }, { launchRoot }), + (error) => { + assert.ok(error instanceof ConfigValidationError); + assert.match(error.message, /protocol must be HTTPS or SSH/); + assert.match(error.message, /must stay inside the repository/); + assert.match(error.message, /must be absolute/); + assert.match(error.message, /duplicates another repository/); + assert.match(error.message, /collides after path sanitization/); + assert.match(error.message, /references unknown repository/); + return true; + }, + ); +}); + +test("include paths that sanitize to the same unit ID fail closed", () => { + assert.throws( + () => resolveReposDocument({ + repos: [{ + name: "orders", + path: path.join(launchRoot, "orders"), + include_paths: ["services/api", "services-api"], + }], + }, { launchRoot }), + /produces duplicate execution unit/, + ); +}); + +test("file loading hashes exact bytes and reports malformed JSON", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "batch-resolve-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const configPath = path.join(root, "repos.json"); + fs.writeFileSync(configPath, '[{"name":"orders","url":"https://example.com/orders.git"}]\n'); + const result = resolveReposFile(configPath, { launchRoot: root }); + assert.match(result.configSha256, /^[a-f0-9]{64}$/); + fs.writeFileSync(configPath, "{"); + assert.throws(() => resolveReposFile(configPath, { launchRoot: root }), /not valid JSON/); +}); \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/schema-validator.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/schema-validator.mjs new file mode 100644 index 0000000..6b06bff --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/schema-validator.mjs @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import path from "node:path"; + +function matchesType(value, expected) { + if (expected === "null") return value === null; + if (expected === "array") return Array.isArray(value); + if (expected === "integer") return Number.isInteger(value); + if (expected === "object") return value !== null && typeof value === "object" && !Array.isArray(value); + return typeof value === expected; +} + +function uniqueItems(values) { + const serialized = values.map((value) => JSON.stringify(value)); + return new Set(serialized).size === serialized.length; +} + +function loadReference(reference, schemaPath) { + if (reference.startsWith("#")) { + throw new Error(`Local fragment references are not supported by the batch schema validator: ${reference}`); + } + const referencePath = path.resolve(path.dirname(schemaPath), reference); + return { + schema: JSON.parse(fs.readFileSync(referencePath, "utf8")), + schemaPath: referencePath, + }; +} + +export function validateSchema(value, schema, schemaPath, valuePath = "$") { + if (schema.$ref) { + const referenced = loadReference(schema.$ref, schemaPath); + return validateSchema(value, referenced.schema, referenced.schemaPath, valuePath); + } + + if (schema.anyOf) { + const candidates = schema.anyOf.map((candidate) => + validateSchema(value, candidate, schemaPath, valuePath)); + return candidates.some((errors) => errors.length === 0) + ? [] + : [`${valuePath} does not match any allowed schema`]; + } + + if (schema.oneOf) { + const matches = schema.oneOf.filter((candidate) => + validateSchema(value, candidate, schemaPath, valuePath).length === 0); + return matches.length === 1 + ? [] + : [`${valuePath} matches ${matches.length} schemas; expected exactly one`]; + } + + const errors = []; + if (Object.hasOwn(schema, "const") && value !== schema.const) { + errors.push(`${valuePath} must equal ${JSON.stringify(schema.const)}`); + } + if (schema.enum && !schema.enum.some((entry) => entry === value)) { + errors.push(`${valuePath} is not in the allowed enum`); + } + + if (schema.type) { + const expectedTypes = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!expectedTypes.some((expected) => matchesType(value, expected))) { + errors.push(`${valuePath} must have type ${expectedTypes.join(" or ")}`); + return errors; + } + } + + if (typeof value === "string") { + if (schema.minLength !== undefined && value.length < schema.minLength) { + errors.push(`${valuePath} is shorter than ${schema.minLength}`); + } + if (schema.pattern && !new RegExp(schema.pattern).test(value)) { + errors.push(`${valuePath} does not match ${schema.pattern}`); + } + } + + if (typeof value === "number" && schema.minimum !== undefined && value < schema.minimum) { + errors.push(`${valuePath} is less than ${schema.minimum}`); + } + + if (Array.isArray(value)) { + if (schema.minItems !== undefined && value.length < schema.minItems) { + errors.push(`${valuePath} has fewer than ${schema.minItems} items`); + } + if (schema.uniqueItems && !uniqueItems(value)) { + errors.push(`${valuePath} contains duplicate items`); + } + if (schema.items) { + value.forEach((entry, index) => { + errors.push(...validateSchema(entry, schema.items, schemaPath, `${valuePath}[${index}]`)); + }); + } + } + + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + for (const required of schema.required ?? []) { + if (!Object.hasOwn(value, required)) { + errors.push(`${valuePath}.${required} is required`); + } + } + for (const [key, entry] of Object.entries(value)) { + if (schema.properties?.[key]) { + errors.push(...validateSchema(entry, schema.properties[key], schemaPath, `${valuePath}.${key}`)); + } else if (schema.additionalProperties === false) { + errors.push(`${valuePath}.${key} is not allowed`); + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + errors.push(...validateSchema(entry, schema.additionalProperties, schemaPath, `${valuePath}.${key}`)); + } + } + } + + return errors; +} \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/batch-modernization/scripts/validate-result.mjs b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/validate-result.mjs new file mode 100644 index 0000000..a571081 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/batch-modernization/scripts/validate-result.mjs @@ -0,0 +1,582 @@ +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { fileURLToPath } from "node:url"; + +import { assertSafePersistedValue } from "./batch-state.mjs"; +import { canonicalPath, isPathInside } from "./inspect-workspaces.mjs"; +import { validateSchema } from "./schema-validator.mjs"; +import { + FACT_SKILL_IDS, + SECURITY_SKILL_IDS, +} from "../../assessment/scripts/assessment-catalog.mjs"; + +const scriptRoot = path.dirname(fileURLToPath(import.meta.url)); +const schemaPath = path.resolve(scriptRoot, "..", "schemas", "attempt-result.schema.json"); +const normalizedAssessmentSchemaPath = path.resolve( + scriptRoot, + "..", + "schemas", + "normalized-assessment.v1.json", +); +const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); +const normalizedAssessmentSchema = JSON.parse(fs.readFileSync(normalizedAssessmentSchemaPath, "utf8")); +const SUCCESS_STATUSES = new Set(["completed", "completed_with_issues"]); +const TERMINAL_TASK_STATUSES = new Set(["completed", "failed", "skipped", "not_applicable"]); +const SUCCESS_CRITERIA_STATUSES = new Set(["passed", "exempt", "not_applicable"]); +const SECURITY_ENTRY_STATUSES = new Set(["FOUND", "NOT_FOUND"]); +const SECURITY_TASK_STATUSES = new Set(["success", "succeeded", "completed", "ok", "not_applicable"]); +const MINIMUM_HTML_BYTES = 10_000; +const SEVERITIES = ["critical", "high", "medium", "low", "info"]; +const ASSESSMENT_CONFIG_FIELDS = [ + "targetRuntime", + "targetComputeServices", + "enableContainerization", + "targetOS", + "minimumCveSeverity", + "cveScanScope", +]; + +function normalizedAssessmentConfig(value = {}) { + return Object.fromEntries(ASSESSMENT_CONFIG_FIELDS + .filter((name) => value[name] !== undefined) + .map((name) => [name, value[name]])); +} + +function readJsonArtifact(filePath, label, errors) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + errors.push(`${label} is not valid JSON: ${error.message}`); + return null; + } +} + +function validateIdentity(result, expected, errors) { + for (const field of [ + "batchId", + "invocationId", + "repoId", + "executionUnitId", + "phase", + "attempt", + ]) { + if (expected[field] !== undefined && result[field] !== expected[field]) { + errors.push(`${field} does not match the expected attempt`); + } + } +} + +function resolveArtifactPaths(result, { batchRoot, workspacePath }, errors) { + const roots = []; + for (const [label, rootPath] of [["batchRoot", batchRoot], ["workspacePath", workspacePath]]) { + try { + roots.push(canonicalPath(rootPath)); + } catch (error) { + errors.push(`${label} is unavailable: ${error.message}`); + } + } + const artifacts = {}; + for (const [name, artifactPath] of Object.entries(result.artifacts ?? {})) { + if (!path.isAbsolute(artifactPath)) { + errors.push(`artifact ${name} must use an absolute path`); + continue; + } + if (!fs.existsSync(artifactPath)) { + errors.push(`artifact ${name} does not exist`); + continue; + } + let canonicalArtifact; + try { + canonicalArtifact = canonicalPath(artifactPath); + } catch (error) { + errors.push(`artifact ${name} cannot be resolved: ${error.message}`); + continue; + } + if (!fs.statSync(canonicalArtifact).isFile()) { + errors.push(`artifact ${name} must be a file`); + continue; + } + if (!roots.some((root) => isPathInside(root, canonicalArtifact))) { + errors.push(`artifact ${name} escapes the batch and workspace roots`); + continue; + } + artifacts[name] = canonicalArtifact; + } + return artifacts; +} + +function requireArtifact(artifacts, name, errors) { + if (!artifacts[name]) errors.push(`required artifact ${name} is missing or invalid`); + return artifacts[name]; +} + +function reportIdForRunId(runId) { + const raw = String(runId); + const timestampParts = raw.match(/\d+/g) ?? []; + const timestamp = timestampParts.join("").slice(0, 14); + if (/^\d{14}$/.test(timestamp)) return timestamp; + if (/^\d+$/.test(raw)) return raw; + return raw.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "run"; +} + +function sameStringSet(actual, expected) { + if (!Array.isArray(actual) || !Array.isArray(expected)) return false; + const actualValues = [...new Set(actual)].sort(); + const expectedValues = [...new Set(expected)].sort(); + return JSON.stringify(actualValues) === JSON.stringify(expectedValues); +} + +function countBucketTotal(value, label, errors, requiredKeys) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + errors.push(`assessment HTML ${label} must be an object`); + return null; + } + const keys = Object.keys(value); + if (requiredKeys && !isDeepStrictEqual([...keys].sort(), [...requiredKeys].sort())) { + errors.push(`assessment HTML ${label} must contain exactly ${requiredKeys.join(", ")}`); + return null; + } + if (keys.some((key) => !key || !Number.isInteger(value[key]) || value[key] < 0)) { + errors.push(`assessment HTML ${label} must contain non-negative integer counts`); + return null; + } + return keys.reduce((total, key) => total + value[key], 0); +} + +function validateTopRecommendation(value, errors) { + if (!value || typeof value !== "object" || Array.isArray(value) + || typeof value.kind !== "string" || !value.kind + || typeof value.summary !== "string" || !value.summary + || ![null, "string"].includes(value.next_action === null ? null : typeof value.next_action) + || ![null, "string"].includes(value.prefilled_prompt === null ? null : typeof value.prefilled_prompt)) { + errors.push("assessment HTML top_recommendation is malformed"); + } +} + +function validateAssessmentPolicy(policy, errors) { + if (!policy || typeof policy !== "object") { + errors.push("assessment validation policy is required"); + return false; + } + if (typeof policy.runId !== "string" || !policy.runId) { + errors.push("assessment validation policy requires runId"); + } + if (!["java", "dotnet", "javascript", "typescript"].includes(policy.language)) { + errors.push("assessment validation policy requires a supported language"); + } + if (!Array.isArray(policy.domains) + || (policy.domains.length === 0 + && !["javascript", "typescript"].includes(policy.language))) { + errors.push("assessment validation policy requires domains"); + } + if (!["issue-only", "full"].includes(policy.analysisCoverage)) { + errors.push("assessment validation policy requires issue-only or full coverage"); + } + if (!path.isAbsolute(policy.attemptDirectory ?? "")) { + errors.push("assessment validation policy requires an absolute attemptDirectory"); + } + if (!path.isAbsolute(policy.workspacePath ?? "")) { + errors.push("assessment validation policy requires an absolute workspacePath"); + } + return errors.length === 0; +} + +function validateBatchCoverageIntent(intent, label, policy, errors) { + if (intent?.analysis_coverage !== policy.analysisCoverage) { + errors.push(`${label} analysis_coverage does not match the attempt request`); + } + if (intent?.coverage_source !== "approved-batch") { + errors.push(`${label} coverage_source must be approved-batch`); + } +} + +function validateNormalizedAssessment(report, policy, errors) { + if (!report || typeof report !== "object" || Array.isArray(report)) { + errors.push("normalized assessment must contain an object"); + return; + } + errors.push(...validateSchema( + report, + normalizedAssessmentSchema, + normalizedAssessmentSchemaPath, + ).map((error) => `normalized assessment ${error}`)); + const metadata = report.metadata ?? {}; + if (metadata.runId !== policy.runId) { + errors.push("normalized assessment runId does not match the attempt request"); + } + if (metadata.id !== reportIdForRunId(policy.runId)) { + errors.push("normalized assessment id does not match its runId"); + } + if (metadata.language !== policy.language) { + errors.push("normalized assessment language does not match the attempt request"); + } + if (!sameStringSet(metadata.domains, policy.domains)) { + errors.push("normalized assessment domains do not match the attempt request"); + } + validateBatchCoverageIntent(metadata.intent, "normalized assessment", policy, errors); + if (!isDeepStrictEqual( + normalizedAssessmentConfig(metadata.intent?.assessment_config), + normalizedAssessmentConfig(policy.assessmentConfig), + )) { + errors.push("normalized assessment config does not match the attempt request"); + } + if (Array.isArray(report.findings) && metadata.totalFindings !== report.findings.length) { + errors.push("normalized assessment totalFindings does not match findings[]"); + } + if (Number.isInteger(metadata.totalActionableFindings) + && Number.isInteger(metadata.totalFindings) + && metadata.totalActionableFindings < metadata.totalFindings) { + errors.push("normalized assessment totalActionableFindings is less than totalFindings"); + } + if (Number.isInteger(metadata.totalTrackedFindings) + && Number.isInteger(metadata.totalActionableFindings) + && metadata.totalTrackedFindings < metadata.totalActionableFindings) { + errors.push("normalized assessment totalTrackedFindings is less than totalActionableFindings"); + } +} + +function validateCanonicalReport(report, policy, errors) { + if (!report || typeof report !== "object" || Array.isArray(report)) { + errors.push("canonical report must contain an object"); + return; + } + if (report.version !== "1.0.0") { + errors.push("canonical report version must be 1.0.0"); + } + if (typeof report.producer !== "string" || !report.producer) { + errors.push("canonical report producer is required"); + } + if (!report.metadata || typeof report.metadata !== "object" || Array.isArray(report.metadata)) { + errors.push("canonical report metadata is required"); + return; + } + if (!report.summary || typeof report.summary !== "object" || Array.isArray(report.summary)) { + errors.push("canonical report summary is required"); + } + if (!Array.isArray(report.projects)) errors.push("canonical report projects must be an array"); + if (!report.rules || typeof report.rules !== "object" || Array.isArray(report.rules)) { + errors.push("canonical report rules must contain an object"); + } + if (!Array.isArray(report.security)) errors.push("canonical report security must be an array"); + if (report.metadata.id !== reportIdForRunId(policy.runId)) { + errors.push("canonical report id does not match the attempt request"); + } + if (String(report.metadata.status ?? "").toLowerCase() !== "completed") { + errors.push("canonical report status is not completed"); + } + if (!sameStringSet(report.metadata.domains, policy.domains)) { + errors.push("canonical report domains do not match the attempt request"); + } + if (report.metadata.mode !== policy.analysisCoverage) { + errors.push("canonical report coverage does not match the attempt request"); + } +} + +function validateAssessmentHtml(htmlPath, report, policy, errors) { + const html = fs.readFileSync(htmlPath, "utf8"); + if (Buffer.byteLength(html) <= MINIMUM_HTML_BYTES) { + errors.push("assessment HTML report is too small to contain the complete payload"); + } + if (/\{\{[A-Z0-9_]+\}\}/.test(html)) { + errors.push("assessment HTML report contains unresolved template placeholders"); + } + const match = html.match(/${"complete".repeat(2_000)}`, + ); + const artifacts = { report, normalizedAssessment, html }; + if (selectedDomains.some((domain) => domain !== "security")) { + const appcat = path.join(value.workspacePath, "assessment", "appcat.json"); + fs.writeFileSync(appcat, "{\"rules\":[],\"incidents\":[]}\n"); + artifacts.appcat = appcat; + } + if (analysisCoverage === "full") { + const factsDirectory = path.join(reportDirectory, "facts"); + fs.mkdirSync(factsDirectory); + for (const skillId of FACT_SKILL_IDS) { + fs.writeFileSync(path.join(factsDirectory, `${skillId}.md`), `# ${skillId}\n`); + } + } + if (selectedDomains.includes("security")) { + const securityDirectory = path.join( + value.attemptDirectory, + "scratch", + "engines", + "security", + "incoming", + ); + fs.mkdirSync(securityDirectory, { recursive: true }); + fs.writeFileSync(path.join(securityDirectory, "cve-known-vulnerabilities.json"), "[]\n"); + for (const skillId of SECURITY_CWE_SKILL_IDS) { + fs.writeFileSync( + path.join(securityDirectory, `${skillId}.json`), + `${JSON.stringify({ status: "success", result: { values: [{ status: "NOT_FOUND" }] } })}\n`, + ); + } + } + return { + artifacts, + reportValue, + normalizedAssessmentValue, + normalizedAssessment, + report, + html, + payload, + }; +} + +function baseResult(phase, artifacts, extra = {}) { + return { + schemaVersion: 1, + ...identity, + phase, + status: "completed", + artifacts, + evidence: { artifactValidation: "passed" }, + needsInput: null, + error: null, + completedAt: "2026-08-12T12:00:00.000Z", + ...extra, + }; +} + +function options(fixtureValue, phase, assessment = {}) { + const result = { + batchRoot: fixtureValue.batchRoot, + workspacePath: fixtureValue.workspacePath, + expected: { ...identity, phase }, + }; + if (phase === "assessment") { + result.assessment = { + runId, + language, + domains, + analysisCoverage: "issue-only", + attemptDirectory: fixtureValue.attemptDirectory, + workspacePath: fixtureValue.workspacePath, + ...assessment, + }; + } + return result; +} + +test("assessment completion binds validated JSON and HTML reports to the attempt", (t) => { + const value = fixture(t); + const { artifacts } = writeAssessmentArtifacts(value); + const result = baseResult("assessment", artifacts); + assert.deepEqual(validateAttemptResult(result, options(value, "assessment")), { + valid: true, + status: "completed", + errors: [], + artifacts, + }); + + const unverified = baseResult("assessment", artifacts, { + evidence: { artifactValidation: "not_run" }, + }); + assert.match( + validateAttemptResult(unverified, options(value, "assessment")).errors.join("\n"), + /artifactValidation to be passed/, + ); +}); + +test("assessment completion binds explicit config to JSON and HTML", (t) => { + const value = fixture(t); + const assessmentConfig = { + targetRuntime: "java-21", + targetComputeServices: ["azure-container-apps"], + enableContainerization: true, + targetOS: ["linux"], + minimumCveSeverity: "high", + cveScanScope: "all", + }; + const created = writeAssessmentArtifacts(value, { assessmentConfig }); + const result = baseResult("assessment", created.artifacts); + const policy = options(value, "assessment", { assessmentConfig }); + assert.deepEqual(validateAttemptResult(result, policy), { + valid: true, + status: "completed", + errors: [], + artifacts: created.artifacts, + }); + + fs.writeFileSync(created.normalizedAssessment, `${JSON.stringify({ + ...created.normalizedAssessmentValue, + metadata: { ...created.normalizedAssessmentValue.metadata, intent: {} }, + }, null, 2)}\n`); + assert.match(validateAttemptResult(result, policy).errors.join("\n"), /normalized assessment config/); + + fs.writeFileSync( + created.normalizedAssessment, + `${JSON.stringify(created.normalizedAssessmentValue, null, 2)}\n`, + ); + const tamperedPayload = { + ...created.payload, + intent: { + ...created.payload.intent, + assessment_config: { ...assessmentConfig, targetRuntime: "java-17" }, + }, + }; + fs.writeFileSync( + created.html, + `${"complete".repeat(2_000)}`, + ); + assert.match(validateAttemptResult(result, policy).errors.join("\n"), /HTML config/); +}); + +test("assessment completion rejects Single coverage provenance", (t) => { + const value = fixture(t); + const created = writeAssessmentArtifacts(value, { coverageSource: "default" }); + const result = baseResult("assessment", created.artifacts); + const errors = validateAttemptResult(result, options(value, "assessment")).errors.join("\n"); + assert.match(errors, /normalized assessment coverage_source must be approved-batch/); + assert.match(errors, /assessment HTML coverage_source must be approved-batch/); + + created.normalizedAssessmentValue.metadata.intent = { + analysis_coverage: "full", + coverage_source: "approved-batch", + }; + fs.writeFileSync( + created.normalizedAssessment, + `${JSON.stringify(created.normalizedAssessmentValue, null, 2)}\n`, + ); + assert.match( + validateAttemptResult(result, options(value, "assessment")).errors.join("\n"), + /normalized assessment analysis_coverage does not match/, + ); +}); + +test("assessment completion rejects incomplete Single summary semantics", (t) => { + const value = fixture(t); + const created = writeAssessmentArtifacts(value); + const malformedPayload = { + ...created.payload, + counts: { + ...created.payload.counts, + by_severity: { critical: 1, high: 0, medium: 0, low: 0, info: 0 }, + }, + }; + fs.writeFileSync( + created.html, + `${"complete".repeat(2_000)}`, + ); + assert.match( + validateAttemptResult(baseResult("assessment", created.artifacts), options(value, "assessment")).errors.join("\n"), + /severity counts do not sum/, + ); +}); + +test("assessment completion rejects malformed, stale, or fabricated reports", (t) => { + const value = fixture(t); + const created = writeAssessmentArtifacts(value); + const result = baseResult("assessment", created.artifacts); + + fs.writeFileSync(created.report, `${JSON.stringify({ + ...created.reportValue, + version: "1.1.0", + })}\n`); + assert.match( + validateAttemptResult(result, options(value, "assessment")).errors.join("\n"), + /canonical report version must be 1\.0\.0/, + ); + fs.writeFileSync(created.report, `${JSON.stringify(created.reportValue)}\n`); + + fs.writeFileSync(created.normalizedAssessment, "{}\n"); + assert.match( + validateAttemptResult(result, options(value, "assessment")).errors.join("\n"), + /normalized assessment.*schemaVersion is required/, + ); + + created.normalizedAssessmentValue.schemaVersion = 2; + created.normalizedAssessmentValue.metadata.runId = "stale-run"; + fs.writeFileSync( + created.normalizedAssessment, + `${JSON.stringify(created.normalizedAssessmentValue)}\n`, + ); + const staleErrors = validateAttemptResult(result, options(value, "assessment")).errors.join("\n"); + assert.match(staleErrors, /schemaVersion/); + assert.match(staleErrors, /runId does not match/); + + const valid = writeAssessmentArtifacts(value); + fs.writeFileSync(valid.html, `${"x".repeat(11_000)}`); + assert.match( + validateAttemptResult(baseResult("assessment", valid.artifacts), options(value, "assessment")).errors.join("\n"), + /no embedded report-data payload/, + ); +}); + +test("assessment completion verifies full facts and terminal security evidence", (t) => { + const value = fixture(t); + const selectedDomains = ["security"]; + const created = writeAssessmentArtifacts(value, { selectedDomains, analysisCoverage: "full" }); + const result = baseResult("assessment", created.artifacts); + const policy = options(value, "assessment", { + domains: selectedDomains, + analysisCoverage: "full", + }); + assert.equal(validateAttemptResult(result, policy).valid, true); + + fs.rmSync(path.join(path.dirname(created.report), "facts", `${FACT_SKILL_IDS[0]}.md`)); + assert.match(validateAttemptResult(result, policy).errors.join("\n"), /full coverage fact is missing/); + + fs.writeFileSync(path.join(path.dirname(created.report), "facts", `${FACT_SKILL_IDS[0]}.md`), "# restored\n"); + const pendingPath = path.join( + value.attemptDirectory, + "scratch", + "engines", + "security", + "incoming", + `${SECURITY_CWE_SKILL_IDS[0]}.json`, + ); + fs.writeFileSync(pendingPath, '{"status":"success","result":{"values":[{"status":"PENDING"}]}}\n'); + assert.match(validateAttemptResult(result, policy).errors.join("\n"), /not FOUND or NOT_FOUND/); + + fs.writeFileSync(pendingPath, '{"status":"partial","result":{"values":[]}}\n'); + assert.match(validateAttemptResult( + baseResult("assessment", created.artifacts, { status: "completed_with_issues" }), + policy, + ).errors.join("\n"), /partial security task.*failure evidence/); +}); + +test("JavaScript cloud assessment does not require an AppCAT artifact", (t) => { + const value = fixture(t); + const created = writeAssessmentArtifacts(value, { selectedLanguage: "javascript" }); + delete created.artifacts.appcat; + fs.rmSync(path.join(value.workspacePath, "assessment", "appcat.json")); + const validation = validateAttemptResult( + baseResult("assessment", created.artifacts), + options(value, "assessment", { language: "javascript" }), + ); + assert.equal(validation.valid, true, validation.errors.join("\n")); +}); + +test("Java cloud assessment discovers AppCAT at the request-bound run path", (t) => { + const value = fixture(t); + const created = writeAssessmentArtifacts(value); + delete created.artifacts.appcat; + fs.rmSync(path.join(value.workspacePath, "assessment", "appcat.json")); + const appcatPath = path.join( + value.workspacePath, + ".github", + "modernize", + ".memory", + "runs", + runId, + "appcat", + "report.json", + ); + fs.mkdirSync(path.dirname(appcatPath), { recursive: true }); + fs.writeFileSync(appcatPath, "{\"rules\":[],\"incidents\":[]}\n"); + const validation = validateAttemptResult( + baseResult("assessment", created.artifacts), + options(value, "assessment"), + ); + assert.equal(validation.valid, true, validation.errors.join("\n")); + assert.equal(validation.artifacts.appcat, appcatPath); +}); + +test("planning completion requires plan and a tasks array", (t) => { + const value = fixture(t); + const plan = path.join(value.workspacePath, "plan.md"); + const tasks = path.join(value.workspacePath, "tasks.json"); + fs.writeFileSync(plan, "# Plan\n"); + fs.writeFileSync(tasks, '{"tasks":[]}\n'); + assert.equal( + validateAttemptResult(baseResult("planning", { plan, tasks }), options(value, "planning")).valid, + true, + ); + fs.writeFileSync(tasks, "{}\n"); + assert.match( + validateAttemptResult(baseResult("planning", { plan, tasks }), options(value, "planning")).errors.join("\n"), + /tasks array/, + ); +}); + +test("execution completion requires terminal tasks and explicit build/test evidence", (t) => { + const value = fixture(t); + const summary = path.join(value.workspacePath, "summary.md"); + const taskStatus = path.join(value.batchRoot, "task-status.json"); + fs.writeFileSync(summary, "# Done\n"); + fs.writeFileSync(taskStatus, '{"tasks":[{"id":"T1","status":"completed"}]}\n'); + const result = baseResult("execution", { summary, taskStatus }, { + evidence: { + artifactValidation: "passed", + successCriteria: { build: "passed", tests: "exempt" }, + }, + }); + assert.equal(validateAttemptResult(result, options(value, "execution")).valid, true); + fs.writeFileSync(taskStatus, '{"tasks":[{"id":"T1","status":"running"}]}\n'); + assert.match( + validateAttemptResult(result, options(value, "execution")).errors.join("\n"), + /no supported terminal status/, + ); +}); + +test("schema, identity, status, and secret violations become protocol errors", (t) => { + const value = fixture(t); + const invalid = { + ...baseResult("assessment", {}), + invocationId: "wrong", + status: "completed", + needsInput: { requestId: "stale" }, + error: { code: "bad", message: "https://user:secret@example.com/repo.git?token=x", retryable: false }, + }; + delete invalid.executionUnitId; + const validation = validateAttemptResult(invalid, options(value, "assessment")); + assert.equal(validation.status, "protocol_error"); + assert.match(validation.errors.join("\n"), /executionUnitId is required/); + assert.match(validation.errors.join("\n"), /invocationId does not match/); + assert.match(validation.errors.join("\n"), /needsInput must be null/); + assert.match(validation.errors.join("\n"), /must not contain URL credentials/); +}); + +test("artifact paths reject missing, relative, outside, and symlink escapes", (t) => { + const value = fixture(t); + const outside = path.join(value.root, "outside.json"); + fs.writeFileSync(outside, "{}\n"); + const relative = baseResult("assessment", { report: "report.json", html: "missing.html" }); + assert.match(validateAttemptResult(relative, options(value, "assessment")).errors.join("\n"), /absolute path/); + const outsideResult = baseResult("assessment", { report: outside, html: outside }); + assert.match(validateAttemptResult(outsideResult, options(value, "assessment")).errors.join("\n"), /escapes/); + + const link = path.join(value.workspacePath, "linked-report.json"); + try { + fs.symlinkSync(outside, link, "file"); + const escaped = baseResult("assessment", { report: link, html: link }); + assert.match(validateAttemptResult(escaped, options(value, "assessment")).errors.join("\n"), /escapes/); + } catch (error) { + if (!["EPERM", "EACCES", "UNKNOWN"].includes(error.code)) throw error; + } +}); + +test("NeedsInput and failed results enforce their payload contracts without phase artifacts", (t) => { + const value = fixture(t); + const needsInput = baseResult("planning", {}, { + status: "needs_input", + needsInput: { + schemaVersion: 1, + requestId: "q1", + batchId: identity.batchId, + invocationId: identity.invocationId, + repoId: identity.repoId, + executionUnitId: identity.executionUnitId, + sourceAttempt: 1, + status: "pending", + questions: [{ + id: "target", + kind: "text", + prompt: "Target?", + required: true, + options: [], + }], + answers: [], + }, + }); + assert.equal(validateAttemptResult(needsInput, options(value, "planning")).valid, true); + const failed = baseResult("assessment", {}, { + status: "failed", + error: { code: "engine_failed", message: "failed", retryable: true }, + }); + assert.equal(validateAttemptResult(failed, options(value, "assessment")).valid, true); +}); + +test("missing and malformed result files fail closed", (t) => { + const value = fixture(t); + const resultPath = path.join(value.batchRoot, "result.json"); + assert.equal(validateAttemptResultFile(resultPath, options(value, "assessment")).status, "protocol_error"); + fs.writeFileSync(resultPath, "{"); + assert.equal(validateAttemptResultFile(resultPath, options(value, "assessment")).valid, false); +}); \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/clarifying-scenarios/SKILL.md b/plugins/github-copilot-modernization/skills/clarifying-scenarios/SKILL.md index 1109403..cfa00bd 100644 --- a/plugins/github-copilot-modernization/skills/clarifying-scenarios/SKILL.md +++ b/plugins/github-copilot-modernization/skills/clarifying-scenarios/SKILL.md @@ -1,7 +1,7 @@ --- name: clarifying-scenarios description: | - Evaluates whether a user's modernization/rewrite request provides enough scenario context to proceed (e.g., target component library, screenshots, design system for frontend; API contract policy, data migration strategy for backend). Produces a deterministic clarity score, asks the user for missing required fields via a structured form, and writes a canonical `clarification.md` artifact consumed by all downstream agents. + Evaluates whether a user's modernization/rewrite request provides enough scenario context to proceed (e.g., target component library, screenshots, design system for frontend; API contract policy, data migration strategy for backend). Produces a deterministic clarity score, emits the complete question set at once as an on-disk `clarification-questions.json` (the source of truth for the Q&A round; rendered externally into a webview/markdown, answers come back as a markdown document), and writes a canonical `clarification.md` artifact consumed by all downstream agents. Triggers: "clarification gate", "scenario clarification", "elicit missing context", "evaluate prompt completeness", "ask user for screenshots / target library / design system". NOT for: feature specification (use feature-inventory), planning (use creating-implementation-plan), implementation (use implementing-code), or resolving spec-time `[NEEDS CLARIFICATION]` markers (those remain owned by feature-inventory). --- @@ -12,7 +12,7 @@ Frontend rewrites in particular fail silently when the user prompt omits the tar 1. Evaluating the raw user prompt + project facts against three kits (frontend, backend, generic). 2. Computing a deterministic clarity score. -3. Asking the user — exactly once per round, in a single batched form — for missing required/recommended fields. +3. Emitting **all** clarification questions at once as a single on-disk JSON (`clarification-questions.json`) — no grouping, no interactive rounds. External tooling renders the JSON into a webview/markdown where the user answers; every select question carries an "Other" free-text choice. 4. Writing a canonical `clarification.md` consumed by Foundation/Design/Plan agents. ## Inputs @@ -29,22 +29,32 @@ You receive these from the coordinator (via task metadata or `dependencyArtifact ## Outputs -Only **one** file is ever written to disk: +Two files may be written to disk: ``` -{{BASE_PATH}}/clarification.md ← canonical artifact (written only on READY) +{{BASE_PATH}}/clarification-questions.json ← one-shot question set + answers (source of truth for the Q&A round; persisted by the clarify tool on path A, or by this skill on path B) +{{BASE_PATH}}/clarification.md ← canonical downstream artifact (written only on READY) ``` -The clarification **form is never persisted to disk**. When the gate needs user input, the skill renders the form inline (as the body of its return value) and the coordinator embeds it in the `[wait]` message shown to the user. The user's reply comes back through the next invocation's `userInput` / conversation context — not by editing a file. This eliminates a class of bugs where a stale form lingers on disk after clarification completes. +Questions are **never asked interactively and never printed as a chat form**. When the gate needs user input, the skill delivers the **complete** question set — every applicable field, all importance levels, no grouping, no rounds — as `clarification-questions.json`, via one of two paths (Step 4): preferably via the **tool pair** — **`appmod-rearchitecture-clarify`** validates and persists the question set, then **`appmod-rearchitecture-open-clarification`** opens the answering webview (and can re-open it at any time without touching the saved file); both return immediately — otherwise (tools unavailable, e.g. CLI runs) by writing the file itself. Either way the skill then returns `NEEDS_INPUT` and its turn ends — the user answers at their own pace and comes back with a short message (e.g. "continue"); the **next invocation resumes from disk (Step 0)**. The rendering (webview or fixed markdown template) shows every select question with a free-text choice; as the user clicks, the tooling persists each change — updating the JSON's `answer` fields (drafts carry `status: "answering"`) and regenerating the answers markdown at `{{BASE_PATH}}/clarification-answers.md`. The JSON stays on disk as the durable record of the question round, but it is maintained by the answering tooling — **this skill and the coordinator never read answers from the JSON**; the completed markdown is the only agent-facing answer contract. Decision tokens returned to the coordinator: -- `READY ` — clarification.md written, ready to proceed -- `NEEDS_INPUT` — the body of this return value IS the form markdown; coordinator shows it to the user verbatim inside `[wait]` -- `BLOCKED ` — inputs malformed or required info cannot be collected +- `READY ` — clarification.md written, ready to proceed. Normal outcome when the gate passes without questions, or of the resume invocation after the user submitted. +- `NEEDS_INPUT ` — the question round is open (webview already presented on the tool path; file written for external tooling on the fallback). The coordinator tells the user to fill in the form, submit, and reply — then `[wait]`s. +- `BLOCKED ` — inputs malformed or required info cannot be collected (e.g. a validation loop with the clarify tool) ## Workflow +### Step 0 — Resume check (the gate is disk-state driven and re-entrant) + +The gate's entire state lives on disk under `{{BASE_PATH}}`. Every invocation starts here, in order: + +1. `clarification.md` exists → the gate already completed. Return `READY {{BASE_PATH}}/clarification.md`. +2. `clarification-answers.md` exists AND its front-matter says `status: submitted` → the user has answered. Read it and jump directly to Step 5. +3. `clarification-questions.json` exists (question round in flight, no submitted answers yet) → the user has not submitted. If the current `userInput` is just a continuation signal ("continue", "done", …), return `NEEDS_INPUT {{BASE_PATH}}/clarification-questions.json` again so the coordinator reminds the user to submit the form. If the user reports the form was closed, lost, or never appeared, call `appmod-rearchitecture-open-clarification` to re-present the saved question set — do NOT regenerate the JSON (that would overwrite the user's draft answers). Only on the file-based fallback (no tools) re-run Steps 1–4 from the original ask (the question set is deterministic — same inputs produce the same questions). +4. None of these files exist → fresh run; proceed to Step 1. + ### Step 1 — Scope detection Apply the rules in `references/scoring-rubric.md` §"Step 1 — Scope Detection" to determine which kits apply: @@ -65,46 +75,147 @@ For each field in each applicable kit, scan the `userInput` and `project_facts` Apply `references/scoring-rubric.md` §"Step 2-3" to compute per-kit scores and the overall pass/fail. - **All applicable kits pass** → go to Step 6 (write `clarification.md`). -- **Any kit fails** → go to Step 4 (write form). - -### Step 4 — Generate structured questions - -1. Load `templates/clarification-form.md` to identify the fields that need answers. **Do not write anything to disk.** -2. **Remove fields** for kits that are not in scope. -3. **Pre-fill** any fields that already have evidence in `userInput` or `project_facts` — mark them as `prefilled: true` with the detected value so the coordinator can show them as pre-selected defaults. -4. Return `NEEDS_INPUT` with the body as a **JSON array** of question objects. Each object has: - ```json - { - "id": "F2", - "question": "What target component / UI library should the new frontend use?", - "importance": "required", - "options": ["shadcn/ui", "Material UI", "Ant Design", "Chakra UI"], - "default": null, - "prefilled": false, - "prefilled_value": null - } - ``` - - `id`: field identifier (F1–F10, B1–B5, G1–G3). - - `question`: human-readable question text (concise, one sentence). - - `importance`: `required` | `recommended` | `optional`. - - `options`: array of common choices (may be empty if free-text only). Include an "Other" option when applicable. - - `default`: the default value if skipped (null for required fields). - - `prefilled`: true if evidence was found in the user's input. - - `prefilled_value`: the detected value (null if not prefilled). -5. The coordinator will present these questions interactively (not as a printed form) and re-invoke this skill with the collected answers. - -### Step 5 — Re-invocation after user reply - -When re-invoked with the user's collected answers (formatted as `"F1: , F2: , ..."` or free-text): - -1. Parse the user's reply against the field list. Tolerate variations: key-value pairs, a bullet list, or sentence form — all map to the same fields by id (F1–F10, B1–B5, G1–G3). -2. For each field: - - If user provided an answer → `resolution: user`, value = answer. - - If user selected a default / accepted pre-fill → `resolution: user`, value = confirmed value. - - If answer is empty and field is not `required` → `resolution: default`, value = the field's `default-if-skipped`. - - If answer is empty and field IS `required` → `resolution: blocking`, no value, append to `blocking_gaps`. -3. Re-score. If all required fields are now answered (or marked blocking), proceed to Step 6. Otherwise, if `rounds < 2`, return `NEEDS_INPUT` again with only the still-missing fields as structured questions. -4. If `rounds == 2` and gaps remain, apply defaults to all non-required missing fields and mark unfilled required fields as `blocking_gaps`. Proceed to Step 6. +- **Any kit fails** → go to Step 4 (generate the question JSON). + +> `references/scoring-rubric.md` §Step 4 (output decision) and §Step 5 (round limit) are **superseded** by this SKILL's Step 4–5 and Round-limit sections. Apply the rubric only for scope detection (§Step 1) and scoring (§Step 2–3). + +### Step 4 — Generate the one-shot question JSON + +1. Load `templates/clarification-form.md` (the agent-internal field catalog) for the field set, ids, importance levels, and defaults. **Do not print it to the user.** +2. **Remove fields** for kits that are not in scope (and fields dropped by scope rules, e.g. `visual.screenshots` when no UI is in scope). +3. **Include every remaining field** — `required`, `recommended`, AND `optional`, prefilled or not. There is exactly one question round; nothing is deferred, grouped, or silently defaulted at this stage. **Always include G5 (`constraints.additional`)** — the fixed free-text "Additional Constraints" question — prefilled with its catalog default so the user can accept or override it. +4. **Author the narrative fields.** Document level: `title` (short scenario name, e.g. "WebSphere to Spring Boot" — rendered as "Clarifications - {title}"), `assessment_snapshot` (`intro` sentence + `bullets` listing the key scan findings), and `proposed_outcome` (one paragraph describing the proposed target state). Per question: a short `title` (card heading), an `impact` score 1–5 (how strongly the decision shapes the migration — rendered as stars), and `recommended` (the option value you would advise, flagged "(Recommended)" in the UI; `null` when there is no clear recommendation, always `null` for `text` questions). +5. **Pre-fill**: where `userInput` / `project_facts` contain evidence, set `prefilled: true` and `prefilled_value` so the renderer pre-selects the value for the user to confirm or override. Leave `answer: null` — only the answering side writes answers. +6. **Model dependencies** with `depends_on` (schema below) when a question's visibility or option list is decided by an earlier question's answer (e.g. component-library / state-management / routing options vary with the chosen target framework). A dependency may only reference a question that appears **earlier** in the `questions` array; cycles are forbidden. Questions without dependencies (the normal case) set `depends_on: null`. +7. Set `status: "awaiting_answers"` and every `answer` to `null`, then deliver the document via path A or B below. + +#### Delivery path A — clarification tools (preferred) + +If the **`appmod-rearchitecture-clarify`** / **`appmod-rearchitecture-open-clarification`** tool pair is available (IDE runs), use it — the first call validates and persists the document, the second presents the answering webview; both return immediately: + +1. Call `appmod-rearchitecture-clarify` with the complete document serialized as a JSON **string** in `questionsJson` (status `awaiting_answers`, every answer `null`). Do NOT write `clarification-questions.json` yourself — the tool persists it. +2. On `❌ VALIDATION FAILED` — fix ONLY the listed issues and re-call with the complete corrected JSON. After **3** failed attempts, return `BLOCKED validation loop: `. +3. On `✅ … saved` — call `appmod-rearchitecture-open-clarification` (no input) to present the form. +4. On `✅ … presented to the user` — the webview is open and the answers will land in `{{BASE_PATH}}/clarification-answers.md`. **End this invocation now**: return `NEEDS_INPUT {{BASE_PATH}}/clarification-questions.json`. Do NOT wait, poll, or ask the questions in chat — the next invocation enters at Step 0 and picks up the submitted answers from disk. +5. If the open tool reports the webview could not be opened — the JSON is already saved; return `NEEDS_INPUT` the same way. The form can be presented later by re-calling `appmod-rearchitecture-open-clarification`, which never regenerates or overwrites the saved questions or draft answers. + +#### Delivery path B — file-based fallback (tool unavailable, e.g. CLI runs) + +1. Write the document to `{{BASE_PATH}}/clarification-questions.json`. +2. Return `NEEDS_INPUT {{BASE_PATH}}/clarification-questions.json`. Do NOT put question text in the return body and do NOT ask anything in chat — rendering is owned by external tooling. + +#### `clarification-questions.json` schema (`clarification-questions/v1`) + +```json +{ + "schema": "clarification-questions/v1", + "generated_at": "", + "user_input": "", + "title": "AngularJS to React 18", + "assessment_snapshot": { + "intro": "The scan identifies an AngularJS single-page storefront with:", + "bullets": [ + "12 AngularJS controllers and 30 HTML templates", + "REST calls to a Java backend via $http services" + ] + }, + "proposed_outcome": "Rewrite the frontend to React 18 while preserving current behavior, URLs, and user journeys.", + "scope": ["frontend", "backend", "generic"], + "status": "awaiting_answers", + "questions": [ + { + "id": "F1", + "kit": "frontend", + "field": "target.framework", + "title": "Target frontend framework", + "question": "Which target frontend framework and version should the rewrite use?", + "importance": "required", + "impact": 5, + "type": "single_select", + "options": [ + { "value": "React 18", "label": "React 18" }, + { "value": "Vue 3", "label": "Vue 3" }, + { "value": "Angular 17", "label": "Angular 17" } + ], + "allow_other": true, + "recommended": "React 18", + "default": null, + "prefilled": true, + "prefilled_value": "React 18", + "answer": null, + "depends_on": null + }, + { + "id": "F2", + "kit": "frontend", + "field": "target.component_library", + "title": "Component library", + "question": "What target component / UI library should the new frontend use?", + "importance": "required", + "impact": 4, + "type": "single_select", + "options": [ + { "value": "shadcn/ui", "label": "shadcn/ui" }, + { "value": "MUI v5", "label": "Material UI v5" } + ], + "allow_other": true, + "recommended": "shadcn/ui", + "default": null, + "prefilled": false, + "prefilled_value": null, + "answer": null, + "depends_on": { + "question_id": "F1", + "visible_when": null, + "options_when": { + "React 18": [ + { "value": "shadcn/ui", "label": "shadcn/ui" }, + { "value": "MUI v5", "label": "Material UI v5" }, + { "value": "Ant Design v5", "label": "Ant Design v5" } + ], + "Vue 3": [ + { "value": "Vuetify 3", "label": "Vuetify 3" }, + { "value": "PrimeVue", "label": "PrimeVue" } + ] + } + } + } + ] +} +``` + +**Field rules:** + +- Document level: `title` — short scenario name shown as the page heading ("Clarifications - {title}"); `assessment_snapshot` — `{ intro, bullets[] }` summarizing the scan findings; `proposed_outcome` — one paragraph describing the proposed target state. All three are required. +- `id` — field identifier from the catalog (F1–F10, B1–B5, G1–G5). Stable across regenerations. `G5` is the fixed "Additional Constraints" question: always included, always `type: "text"`, prefilled with its catalog default. +- `title` — short card heading (a few words); the full wording goes in `question`. +- `type` — `single_select` | `multi_select` | `text`. Use `text` for inherently free-form fields (e.g. screenshot paths/URLs). +- `impact` — integer 1–5: how strongly this decision shapes the migration outcome. Rendered as a 1–5 star "Potential Impact" rating. +- `recommended` — the option value the agent recommends; the renderer flags it "(Recommended)". `null` when no clear recommendation exists; always `null` for `text` questions. +- `options[].value` — stable machine value used in answers and `options_when` keys; `label` is display-only. +- `allow_other` — MUST be `true` on every `single_select` / `multi_select` question. The renderer appends a free-text "Enter your answer" choice. `text` questions omit it (free text is inherent). +- `default` — the catalog's default-if-skipped value; `null` for required fields with no default. +- `prefilled` / `prefilled_value` — evidence detected at generation time; the renderer shows it pre-selected for confirmation. +- `answer` — `null` at generation. Filled by the answering side as `{ "value": , "source": "user" | "default" | "skipped" }`. A free-text answer is simply `source: "user"` with a value not present in `options`. `multi_select` answers use a string array. +- `depends_on` — `null`, or an object with: + - `question_id` — an **earlier** question's `id`. + - `visible_when` — array of the driver's option values; the question is shown only when the driver's answer is in the list. `null` = always visible. A question hidden at submit time is treated as skipped (its `default` applies). + - `options_when` — map from driver option value → replacement option list. When the driver's answer matches no key (including free-text answers or unanswered), the question's own `options` array is the fallback. + +**Renderer contract** (informational — the fixed JSON→markdown/webview template is implemented outside this skill): render the page header (title, assessment snapshot, proposed outcome), then questions in array order grouped by `kit`, each card showing the star `impact` rating, the full `question`, and the option list with the `recommended` option flagged; pre-select `prefilled_value` (or `default`), always offer the free-text choice, apply `depends_on` reactively as the user answers, persist every user change immediately (update the JSON's `answer` fields AND regenerate the answers markdown keyed by question `id`), and on submit mark the markdown complete and hand it back to the agent as the final result. + +### Step 5 — Ingest the completed answers markdown + +The completed markdown reaches this step in one of two ways: **read from `{{BASE_PATH}}/clarification-answers.md` at Step 0** (front-matter `status: submitted`) after the user returns — the normal tool-path flow; or, on the file-based fallback, supplied by the coordinator as the re-invocation's `userInput` (inline content or a path). The answering tooling persists the user's choices as they are made; only a **submitted** document may be consumed. + +> **Answer-source rule**: the answers markdown is the ONLY input this skill reads for answers. Do NOT read answers from `clarification-questions.json` — the JSON is webview-internal state maintained by the external tooling, not an agent-facing interface. + +1. Parse the answers markdown tolerantly — key-value pairs, a bullet list, a table, or sentence form — mapping answers to questions by `id` (F1–F10, B1–B5, G1–G5). If the markdown carries a completion marker (e.g. front-matter `status`), only accept a submitted/complete document; if no marker exists, treat the provided markdown as final. +2. For each catalog field in scope, derive the resolution from the markdown: + - a value is present (listed option or free-text "Other") → `resolution: user` + - empty or absent (including questions hidden by `visible_when` at submit time), and the field has a `default-if-skipped` → `resolution: default`, value = the default + - empty or absent, `required`, no default → `resolution: blocking`, append to `blocking_gaps` +3. Proceed to Step 6. There is **no second question round** — never return `NEEDS_INPUT` twice in a session. If the markdown contains no parseable answers, return `BLOCKED `. ### Step 6 — Write `clarification.md` @@ -119,7 +230,7 @@ When re-invoked with the user's collected answers (formatted as `"F1: , 3. Fill the body sections with the resolved values. Remove sections for out-of-scope kits. 4. Return `READY `. -> **No cleanup step needed.** Because the form was never written to disk, there is nothing to delete. This is the single source-of-truth file for clarification state. +> **No cleanup step.** `clarification-questions.json` intentionally stays on disk as the durable record of the question round (questions + live answers, maintained by the external tooling) — do NOT delete it, and do NOT read answers from it. `clarification.md` is the canonical artifact consumed downstream; the completed answers markdown is the only answer input agents consume. ## Non-interactive / CLI mode @@ -132,11 +243,11 @@ When `interactive == false` (no TTY, `--yes` flag, CI run): ## Round limit -Maximum **2** clarification rounds per session. After round 2, defaults are applied automatically — the user is not asked a third time. +Exactly **one** question round per session. The webview presents the complete question set (all importance levels, prefills and defaults pre-selected), so the user reviews everything before submitting and the agent never re-asks. Gaps remaining after ingestion resolve via `default-if-skipped`; required fields with no default become `blocking_gaps`. ## Determinism guarantee -Given the same `userInput`, `classification`, and `project_facts`, this skill **must** produce the same `clarity_score` and the same set of asked fields. Do not introduce randomness or model-driven judgement at the scoring step — only at the evidence-extraction step (where light interpretation of natural language is unavoidable). +Given the same `userInput`, `classification`, and `project_facts`, this skill **must** produce the same `clarity_score` and the same question set in `clarification-questions.json`. Do not introduce randomness or model-driven judgement at the scoring step — only at the evidence-extraction step (where light interpretation of natural language is unavoidable). ## Resources @@ -147,5 +258,5 @@ Given the same `userInput`, `classification`, and `project_facts`, this skill ** - `references/scoring-rubric.md` — scoring algorithm and pass thresholds ### Templates -- `templates/clarification-form.md` — user-facing questionnaire +- `templates/clarification-form.md` — agent-internal field catalog (ids, importance, defaults) — source for question generation - `templates/clarification.md` — canonical artifact schema (clarification/v1) diff --git a/plugins/github-copilot-modernization/skills/clarifying-scenarios/references/kit-generic.md b/plugins/github-copilot-modernization/skills/clarifying-scenarios/references/kit-generic.md index 115e859..99cc4bc 100644 --- a/plugins/github-copilot-modernization/skills/clarifying-scenarios/references/kit-generic.md +++ b/plugins/github-copilot-modernization/skills/clarifying-scenarios/references/kit-generic.md @@ -34,3 +34,23 @@ Applied to **all** brownfield and greenfield tasks regardless of scope (frontend - `"partial: "` — specific test suites must pass (e.g., "E2E must pass, unit tests can be rewritten") - **Default if skipped**: `"must pass"` (safe default; ImplementationAgent classifies failures as migration-caused vs pre-existing) - **Why it matters**: determines the BUILD GATE criteria in Phase 5 and whether failing tests block batch completion + +--- + +### target.output_location +- **Importance**: required — but **only when `assessment.change_type` is `rewrite` or `extract`**; omit the field entirely for `upgrade` (in-place is inherent). Same conditional-inclusion mechanism as `visual.screenshots`. +- **Label**: Rewrite output location — where should the rewritten code go? +- **Accepted evidence**: explicit output directory in the prompt (e.g., "Output all generated code into a new top-level folder named `petclinic-new`"), `"in-place"`, or an explicit relative/absolute target path +- **Default if skipped**: `"new sibling directory: -new"` +- **Why it matters**: decides the scaffold target directory and the working directory for all build/test/validation commands, and guarantees the original source tree stays untouched during a rewrite. Collected here so the coordinator never has to interrupt the run with a separate interactive question. +- **Question generation note**: render as `single_select` with concrete option values — `-new` sibling directory (pre-select), `in-place` — plus the automatic free-text row for a custom path. + +--- + +### constraints.additional +- **Importance**: optional — but the question itself is **always included** in the question set (fixed G5), regardless of scope or evidence. +- **Label**: Additional Constraints — requirements, exclusions, dependencies, compliance rules, or operational constraints not covered above +- **Accepted evidence**: none — this field is never scored and never counts toward the clarity gate; it exists purely to catch constraints the catalog has no field for. +- **Default if skipped**: `"None beyond the decisions listed in this specification."` +- **Why it matters**: gives the user one guaranteed free-text outlet for compliance rules, hard dependencies, or exclusions that would otherwise surface late as blocking review feedback. +- **Question generation note**: render as `text`, prefilled with the default so the user can accept it unchanged or overwrite it with their own constraints. diff --git a/plugins/github-copilot-modernization/skills/clarifying-scenarios/templates/clarification-form.md b/plugins/github-copilot-modernization/skills/clarifying-scenarios/templates/clarification-form.md index c6da4df..587ae2b 100644 --- a/plugins/github-copilot-modernization/skills/clarifying-scenarios/templates/clarification-form.md +++ b/plugins/github-copilot-modernization/skills/clarifying-scenarios/templates/clarification-form.md @@ -2,7 +2,7 @@ > ⚠️ **Agent-only reference. DO NOT print this template to the user.** > -> This file is the canonical list of fields the `clarifying-scenarios` skill may ask about. It is **never written to disk** and **never shown to the user as a form**. Per `SKILL.md`, when fields are missing the skill returns `NEEDS_INPUT` with a structured JSON question array, and the coordinator presents questions **interactively** (one structured prompt with options/defaults) — not as a markdown form for the user to fill in and send back. +> This file is the canonical list of fields the `clarifying-scenarios` skill may ask about. It is **never shown to the user as a form**. Per `SKILL.md`, when fields are missing the skill writes the **complete one-shot question set** to `{{BASE_PATH}}/clarification-questions.json` (every applicable field across all importance levels; every select question carries an "Other" free-text choice) and returns `NEEDS_INPUT `. External tooling renders that JSON into a webview/markdown; the user's answers come back as an answers markdown. Questions are never asked interactively in chat, never printed as a form, and never grouped into rounds. > > Use this file to: > - Identify the field set per scope (frontend / backend / generic). @@ -55,5 +55,7 @@ | G1 | Definition of "done" | ❗ Required | `` | feature parity with current system | | G2 | Explicit out-of-scope items | Recommended | `` | agent infers from project structure | | G3 | Existing test suite policy | Recommended | `` | must pass | +| G4 | Rewrite output location (`rewrite`/`extract` only — omit for `upgrade`) | ❗ Required | `` | new sibling directory: `-new` | +| G5 | Additional constraints (always included; fixed free-text question) | Optional | `` | None beyond the decisions listed in this specification. | diff --git a/plugins/github-copilot-modernization/skills/clarifying-scenarios/templates/clarification.md b/plugins/github-copilot-modernization/skills/clarifying-scenarios/templates/clarification.md index 4caf456..d26bebd 100644 --- a/plugins/github-copilot-modernization/skills/clarifying-scenarios/templates/clarification.md +++ b/plugins/github-copilot-modernization/skills/clarifying-scenarios/templates/clarification.md @@ -55,6 +55,8 @@ blocking_gaps: [] # required fields with no answer and no default — surfac - **Success definition**: - **Out of scope**: - **Existing test posture**: +- **Output location**: +- **Additional constraints**: --- diff --git a/plugins/github-copilot-modernization/skills/create-modernization-plan/supported-patterns-dotnet.md b/plugins/github-copilot-modernization/skills/create-modernization-plan/supported-patterns-dotnet.md index 2015033..dbf966e 100644 --- a/plugins/github-copilot-modernization/skills/create-modernization-plan/supported-patterns-dotnet.md +++ b/plugins/github-copilot-modernization/skills/create-modernization-plan/supported-patterns-dotnet.md @@ -1,6 +1,6 @@ ## Supported Task Patterns -The following are the task patterns supported by the modernize CLI. These patterns are used to identify the modernization tasks that need to be performed based on the user's input. +The following task patterns are owned by this plugin. They identify modernization tasks from the user's input without invoking an external modernization CLI. The patterns are categorized into two groups, and they should be treated differently if picked: diff --git a/plugins/github-copilot-modernization/skills/create-modernization-plan/supported-patterns-java.md b/plugins/github-copilot-modernization/skills/create-modernization-plan/supported-patterns-java.md index 6ae2dee..41f1579 100644 --- a/plugins/github-copilot-modernization/skills/create-modernization-plan/supported-patterns-java.md +++ b/plugins/github-copilot-modernization/skills/create-modernization-plan/supported-patterns-java.md @@ -1,6 +1,6 @@ ## Supported Task Patterns -The following are the task patterns supported by the modernize CLI. These patterns are used to identify the modernization tasks that need to be performed based on the user's input. +The following task patterns are owned by this plugin. They identify modernization tasks from the user's input without invoking an external modernization CLI. The patterns are categorized into two groups, and they should be treated differently if picked: diff --git a/plugins/github-copilot-modernization/skills/creating-implementation-plan/SKILL.md b/plugins/github-copilot-modernization/skills/creating-implementation-plan/SKILL.md index 3406be7..794244d 100644 --- a/plugins/github-copilot-modernization/skills/creating-implementation-plan/SKILL.md +++ b/plugins/github-copilot-modernization/skills/creating-implementation-plan/SKILL.md @@ -49,7 +49,7 @@ checkpoints/plan-to-tasks.yaml ← plan item → task traceability 2. **Fill plan template** (`templates/plan-template.md`): - Technical Context: derive HOW decisions from design artifacts + constitution + guidelines. - Constitution Check from constitution principles. -3. **Integrate guidelines**: Scan `skills/guidelines/` for matching tech patterns. Document under "Applied Guidelines". +3. **Integrate guidelines**: Load the `guidelines` lookup skill, then select matching top-level guideline skills. Document them under "Applied Guidelines". 4. **Map every plan item** to at least one REQ-XXX. 5. **Tag plan items with G-groups**: If a project topology was loaded in Step 1.5, every Implementation Step that maps to a specific module group MUST include the G-group label in its title (e.g., `### Step 3: [G1] Payment gateway integration`). Cross-cutting steps that span all groups use `[Cross-cutting]` instead. This enables downstream DAG generation to extract group membership directly from the plan. 6. **Write `plan.md`** section by section (write each section before moving to the next): @@ -139,7 +139,7 @@ Report completion with: Before generating the plan, check for applicable guidelines: 1. Identify technologies from feature spec and design artifacts. -2. Search `skills/guidelines/` for matching patterns. +2. Use the `guidelines` lookup contract to find matching top-level guideline skills. 3. For each match: extract relevant rules, integrate into plan, document in "Applied Guidelines" section. ## General Rules diff --git a/plugins/github-copilot-modernization/skills/creating-implementation-plan/templates/plan-template.md b/plugins/github-copilot-modernization/skills/creating-implementation-plan/templates/plan-template.md index 261c55f..217beb1 100644 --- a/plugins/github-copilot-modernization/skills/creating-implementation-plan/templates/plan-template.md +++ b/plugins/github-copilot-modernization/skills/creating-implementation-plan/templates/plan-template.md @@ -24,7 +24,7 @@ ## Applied Guidelines -[List any matching migration/transformation guidelines from `skills/guidelines/` and how they influence the plan.] +[List any matching top-level migration/transformation skills discovered through the `guidelines` lookup skill and how they influence the plan.] ## Implementation Steps diff --git a/plugins/github-copilot-modernization/skills/cve-known-vulnerabilities/SKILL.md b/plugins/github-copilot-modernization/skills/cve-known-vulnerabilities/SKILL.md new file mode 100644 index 0000000..8c12ceb --- /dev/null +++ b/plugins/github-copilot-modernization/skills/cve-known-vulnerabilities/SKILL.md @@ -0,0 +1,107 @@ +--- +name: cve-known-vulnerabilities +description: Detect known CVEs in Maven, NuGet, or npm dependencies using the GitHub Security Advisories API +user-invocable: false +--- + +# Local CVE Dependency Assessment + +Audit dependencies without modifying application files. Do not call an assessment or CVE MCP tool. + +## Inputs + +- `workspace-path`: Absolute project root. +- `output-path`: Required JSON result path supplied by the assessment catalog. +- `scan-scope`: `direct` (default) or `all`. + +## 1. Detect Ecosystem + +- Maven/Gradle project → advisory ecosystem `maven`. +- `.sln`, `.slnx`, or project files → `nuget`. +- `package.json` → `npm`. +- If none apply, write `[]` to `output-path` and stop. + +## 2. Collect Dependencies + +Prefer project wrappers and structured command output. Never edit manifests or lock files. + +### Maven/Gradle + +- Maven: run `./mvnw dependency:list -DoutputAbsoluteArtifactId=true`; add `-DexcludeTransitive=true` for direct scope. Fall back to `mvn`, then static `pom.xml` parsing. +- Gradle: run `./gradlew dependencies --configuration runtimeClasspath`; fall back to `gradle`, then static `build.gradle`/`build.gradle.kts` parsing. +- Coordinate format: `groupId:artifactId:version`. + +### NuGet + +- Run `dotnet list package --format json`; add `--include-transitive` for all scope. +- If no solution exists at the root, target each discovered project explicitly. +- Fall back to `PackageReference`, `Directory.Packages.props`, and `packages.config` parsing. +- Coordinate format: `PackageName@version`. + +### npm + +- Run `npm list --json`; add `--depth=0` for direct scope. +- Fall back to `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, then `package.json`. +- Coordinate format: `package-name@version`. + +For each dependency retain its manifest path and 1-based declaration line when directly declared; use line `0` for transitive dependencies. + +## 3. Query GitHub Advisories + +Batch coordinates in groups of at most 30. Use `gh api` when available: + +```text +/advisories?ecosystem=&affects=&per_page=100 +``` + +Follow pagination. If `gh` is unavailable, use the GitHub REST API with `GITHUB_TOKEN` when present. Never use general web search for vulnerability conclusions. + +If one batch fails, record the error and continue remaining batches. A failed query must not silently become a clean scan: return a wrapper with `status: partial` and the failed batch details instead of `[]`. + +## 4. Normalize Results + +- Ignore withdrawn advisories. +- Use `cve_id`, falling back to `ghsa_id`. +- Deduplicate the same advisory across dependency batches. +- Map severity: critical/high → `mandatory`; medium → `optional`; low/unknown → `potential`. +- Group all affected dependencies for one advisory into one finding. +- Include advisory URL, vulnerable range, first patched version, and manifest evidence. + +## Output + +On a complete scan, write a flat JSON array to `output-path`; use `[]` only when every query succeeded and no vulnerability was found: + +```json +[ + { + "id": "CVE-2024-0001", + "name": "Advisory summary", + "status": "FOUND", + "category": "CVE", + "severity": "mandatory", + "storyPoint": 1, + "evidence": { + "files": ["pom.xml:42"], + "explanation": "Advisory URL, affected coordinates, vulnerable range, and patched version" + } + } +] +``` + +When any advisory batch fails, write: + +```json +{ + "input_name": "CVE dependency assessment", + "analysis_method": "GitHub Security Advisories API", + "status": "partial", + "result": { + "finding": "CVE scan completed with one or more failed advisory batches", + "confidence": "low", + "evidence": [""], + "values": [""] + } +} +``` + +Write the output exactly once using a temporary file followed by atomic rename. Return only the output path and concise counts to the parent assessment. \ No newline at end of file diff --git a/plugins/github-copilot-modernization/skills/cwe-code-quality/SKILL.md b/plugins/github-copilot-modernization/skills/cwe-code-quality/SKILL.md new file mode 100644 index 0000000..0188adb --- /dev/null +++ b/plugins/github-copilot-modernization/skills/cwe-code-quality/SKILL.md @@ -0,0 +1,154 @@ +--- +name: cwe-code-quality +description: Assess codebase for CWE code quality vulnerabilities (CWE-130, CWE-456, CWE-457, CWE-477, CWE-570, CWE-571, CWE-606, CWE-665, CWE-681, CWE-682, CWE-772, CWE-775, CWE-783, CWE-789, CWE-835, CWE-1057) +user-invocable: false +--- + +# CWE Security Assessment: Code Quality + +## Role + +You are an expert **code security reviewer** specializing in CWE vulnerability detection. + +> **Important:** You are an auditor, NOT an implementation developer. Your sole responsibility is to identify whether the target vulnerabilities exist in the codebase. Do NOT suggest fixes or improvements. + +## Objective + +Analyze the application codebase for each of the 16 CWE rules listed below. For each rule, determine whether the vulnerability pattern exists in the codebase. + +## CWE Rules to Assess + +### CWE-130: Improper Handling of Length Parameter Inconsistency +- **Severity:** potential | **Story Points:** 3 +- **Description:** The product parses a formatted message or structure, but it does not handle or incorrectly handles a length field that is inconsistent with the actual length of the associated data. + +### CWE-456: Missing Initialization of a Variable +- **Severity:** potential | **Story Points:** 2 +- **Description:** The product does not initialize critical variables, which causes the execution environment to use unexpected values. + +### CWE-457: Use of Uninitialized Variable +- **Severity:** potential | **Story Points:** 2 +- **Description:** The code uses a variable that has not been initialized, leading to unpredictable or unintended results. + +### CWE-477: Use of Obsolete Function +- **Severity:** optional | **Story Points:** 1 +- **Description:** The code uses deprecated or obsolete functions, which suggests that the code has not been actively reviewed or maintained. + +### CWE-570: Expression is Always False +- **Severity:** optional | **Story Points:** 1 +- **Description:** The product contains an expression that will always evaluate to false. + +### CWE-571: Expression is Always True +- **Severity:** optional | **Story Points:** 1 +- **Description:** The product contains an expression that will always evaluate to true. + +### CWE-606: Unchecked Input for Loop Condition +- **Severity:** potential | **Story Points:** 3 +- **Description:** The product does not properly check inputs that are used for loop conditions, potentially leading to a denial of service or other consequences because of excessive looping. + +### CWE-665: Improper Initialization +- **Severity:** potential | **Story Points:** 3 +- **Description:** The product does not initialize or incorrectly initializes a resource, which might leave the resource in an unexpected state when it is accessed or used. + +### CWE-681: Incorrect Conversion between Numeric Types +- **Severity:** potential | **Story Points:** 3 +- **Description:** When converting from one data type to another, such as long to integer, data can be omitted or translated in a way that produces unexpected values. If the resulting values are used in a sensitive context, then dangerous behaviors may occur. + +### CWE-682: Incorrect Calculation +- **Severity:** potential | **Story Points:** 5 +- **Description:** The product performs a calculation that generates incorrect or unintended results that are later used in security-critical decisions or resource management. + +### CWE-772: Missing Release of Resource after Effective Lifetime +- **Severity:** potential | **Story Points:** 3 +- **Description:** The product does not release a resource after its effective lifetime has ended, i.e., after the resource is no longer needed. + +### CWE-775: Missing Release of File Descriptor or Handle after Effective Lifetime +- **Severity:** potential | **Story Points:** 3 +- **Description:** The product does not release a file descriptor or handle after its effective lifetime has ended, i.e., after the file descriptor/handle is no longer needed. + +### CWE-783: Operator Precedence Logic Error +- **Severity:** optional | **Story Points:** 1 +- **Description:** The product uses an expression in which operator precedence causes incorrect logic to be used. + +### CWE-789: Memory Allocation with Excessive Size Value +- **Severity:** potential | **Story Points:** 5 +- **Description:** The product allocates memory based on an untrusted, large size value, but it does not ensure that the size is within expected limits, allowing arbitrary amounts of memory to be allocated. + +### CWE-835: Loop with Unreachable Exit Condition ('Infinite Loop') +- **Severity:** potential | **Story Points:** 3 +- **Description:** The product contains an iteration or loop with an exit condition that cannot be reached, i.e., an infinite loop. + +### CWE-1057: Data Access Operations Outside of Expected Data Manager Component +- **Severity:** potential | **Story Points:** 5 +- **Description:** The product uses a dedicated, central data manager component as required by design, but it contains code that performs data-access operations that do not use this data manager. + +## Instructions + +1. **Iterate through each CWE rule** listed above +2. **Systematically scan** the application source code for patterns matching each rule +3. **For each rule:** Stop scanning as soon as you find the FIRST confirmed match +4. **Continue to the next rule** after finding a match or exhausting the search +5. **Report findings** for ALL rules (both FOUND and NOT_FOUND) + +### Search Strategy + +- Start with common vulnerability patterns: user input handling, external data processing, resource management +- Check configuration files, API endpoints, data access layers, and utility classes +- Consider both direct patterns and indirect/transitive vulnerability paths +- Focus on source files (e.g., `.java`, `.py`, `.cs`, `.js`, `.ts`) — skip test files and generated code + +## Output Format + +Return the following complete JSON structure to the parent assessment. The parent writes it to the current run incoming directory and invokes the Node `record-result` contract: + +```json +{ + "input_name": "CWE - Code Quality", + "analysis_method": "LLM", + "status": "success", + "result": { + "finding": "Assessed 16 CWE rules in Code Quality: X FOUND, Y NOT_FOUND", + "confidence": "high", + "evidence": ["Scanned application source files for code quality patterns"], + "values": [ + { + "id": "", + "name": "", + "status": "FOUND", + "category": "Code Quality", + "severity": "", + "storyPoint": "", + "description": "", + "evidence": { + "files": ["src/path/to/File.java"], + "explanation": "Description of the vulnerability found, including class/method and line reference" + } + }, + { + "id": "", + "name": "", + "status": "NOT_FOUND", + "category": "Code Quality", + "severity": "", + "storyPoint": "", + "description": "", + "evidence": { + "files": [], + "explanation": "" + } + } + ] + }, + "execution_time_seconds": 0, + "timestamp": "" +} +``` + +### Evidence Rules + +- **FOUND**: `files` must contain workspace-relative file paths. `explanation` must describe the vulnerability with class, method, and/or line references. +- **NOT_FOUND**: `files` must be an empty array `[]`. `explanation` must be an empty string `""`. +- **Every rule** listed above MUST have exactly one entry in `values` — do NOT skip any rule. +- **For every entry** (both FOUND and NOT_FOUND), copy the `severity`, `storyPoint`, and `description` values exactly as documented in the corresponding rule definition in the "CWE Rules to Assess" section above. +- Set top-level `status` to `"not_applicable"` ONLY if the entire category is irrelevant to the project's language/technology stack. +- Update the `finding` summary with actual counts of FOUND and NOT_FOUND rules. diff --git a/plugins/github-copilot-modernization/skills/cwe-concurrency-synchronization/SKILL.md b/plugins/github-copilot-modernization/skills/cwe-concurrency-synchronization/SKILL.md new file mode 100644 index 0000000..06e35b0 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/cwe-concurrency-synchronization/SKILL.md @@ -0,0 +1,114 @@ +--- +name: cwe-concurrency-synchronization +description: Assess codebase for CWE concurrency & synchronization vulnerabilities (CWE-543, CWE-567, CWE-662, CWE-667, CWE-820, CWE-821) +user-invocable: false +--- + +# CWE Security Assessment: Concurrency & Synchronization + +## Role + +You are an expert **code security reviewer** specializing in CWE vulnerability detection. + +> **Important:** You are an auditor, NOT an implementation developer. Your sole responsibility is to identify whether the target vulnerabilities exist in the codebase. Do NOT suggest fixes or improvements. + +## Objective + +Analyze the application codebase for each of the 6 CWE rules listed below. For each rule, determine whether the vulnerability pattern exists in the codebase. + +## CWE Rules to Assess + +### CWE-543: Use of Singleton Pattern Without Synchronization in a Multithreaded Context +- **Severity:** potential | **Story Points:** 5 +- **Description:** The product uses the singleton pattern when creating a resource within a multithreaded environment. + +### CWE-567: Unsynchronized Access to Shared Data in a Multithreaded Context +- **Severity:** potential | **Story Points:** 5 +- **Description:** The product does not properly synchronize shared data, such as static variables across threads, which can lead to undefined behavior and unpredictable data changes. + +### CWE-662: Improper Synchronization +- **Severity:** potential | **Story Points:** 8 +- **Description:** The product utilizes multiple threads or processes to allow temporary access to a shared resource that can only be exclusive to one process at a time, but it does not properly synchronize these actions, which might cause simultaneous accesses of this resource by multiple threads or processes. + +### CWE-667: Improper Locking +- **Severity:** potential | **Story Points:** 8 +- **Description:** The product does not properly acquire or release a lock on a resource, leading to unexpected resource state changes and behaviors. + +### CWE-820: Missing Synchronization +- **Severity:** potential | **Story Points:** 8 +- **Description:** The product utilizes a shared resource in a concurrent manner but does not attempt to synchronize access to the resource. + +### CWE-821: Incorrect Synchronization +- **Severity:** potential | **Story Points:** 8 +- **Description:** The product utilizes a shared resource in a concurrent manner, but it does not correctly synchronize access to the resource. + +## Instructions + +1. **Iterate through each CWE rule** listed above +2. **Systematically scan** the application source code for patterns matching each rule +3. **For each rule:** Stop scanning as soon as you find the FIRST confirmed match +4. **Continue to the next rule** after finding a match or exhausting the search +5. **Report findings** for ALL rules (both FOUND and NOT_FOUND) + +### Search Strategy + +- Start with common vulnerability patterns: user input handling, external data processing, resource management +- Check configuration files, API endpoints, data access layers, and utility classes +- Consider both direct patterns and indirect/transitive vulnerability paths +- Focus on source files (e.g., `.java`, `.py`, `.cs`, `.js`, `.ts`) — skip test files and generated code + +## Output Format + +Return the following complete JSON structure to the parent assessment. The parent writes it to the current run incoming directory and invokes the Node `record-result` contract: + +```json +{ + "input_name": "CWE - Concurrency & Synchronization", + "analysis_method": "LLM", + "status": "success", + "result": { + "finding": "Assessed 6 CWE rules in Concurrency & Synchronization: X FOUND, Y NOT_FOUND", + "confidence": "high", + "evidence": ["Scanned application source files for concurrency & synchronization patterns"], + "values": [ + { + "id": "", + "name": "", + "status": "FOUND", + "category": "Concurrency & Synchronization", + "severity": "", + "storyPoint": "", + "description": "", + "evidence": { + "files": ["src/path/to/File.java"], + "explanation": "Description of the vulnerability found, including class/method and line reference" + } + }, + { + "id": "", + "name": "", + "status": "NOT_FOUND", + "category": "Concurrency & Synchronization", + "severity": "", + "storyPoint": "", + "description": "", + "evidence": { + "files": [], + "explanation": "" + } + } + ] + }, + "execution_time_seconds": 0, + "timestamp": "" +} +``` + +### Evidence Rules + +- **FOUND**: `files` must contain workspace-relative file paths. `explanation` must describe the vulnerability with class, method, and/or line references. +- **NOT_FOUND**: `files` must be an empty array `[]`. `explanation` must be an empty string `""`. +- **Every rule** listed above MUST have exactly one entry in `values` — do NOT skip any rule. +- **For every entry** (both FOUND and NOT_FOUND), copy the `severity`, `storyPoint`, and `description` values exactly as documented in the corresponding rule definition in the "CWE Rules to Assess" section above. +- Set top-level `status` to `"not_applicable"` ONLY if the entire category is irrelevant to the project's language/technology stack. +- Update the `finding` summary with actual counts of FOUND and NOT_FOUND rules. diff --git a/plugins/github-copilot-modernization/skills/cwe-credentials-secrets/SKILL.md b/plugins/github-copilot-modernization/skills/cwe-credentials-secrets/SKILL.md new file mode 100644 index 0000000..57334f5 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/cwe-credentials-secrets/SKILL.md @@ -0,0 +1,110 @@ +--- +name: cwe-credentials-secrets +description: Assess codebase for CWE credentials & secrets vulnerabilities (CWE-259, CWE-321, CWE-732, CWE-778, CWE-798) +user-invocable: false +--- + +# CWE Security Assessment: Credentials & Secrets + +## Role + +You are an expert **code security reviewer** specializing in CWE vulnerability detection. + +> **Important:** You are an auditor, NOT an implementation developer. Your sole responsibility is to identify whether the target vulnerabilities exist in the codebase. Do NOT suggest fixes or improvements. + +## Objective + +Analyze the application codebase for each of the 5 CWE rules listed below. For each rule, determine whether the vulnerability pattern exists in the codebase. + +## CWE Rules to Assess + +### CWE-259: Use of Hard-coded Password +- **Severity:** optional | **Story Points:** 5 +- **Description:** The product contains a hard-coded password, which it uses for its own inbound authentication or for outbound communication to external components. + +### CWE-321: Use of Hard-coded Cryptographic Key +- **Severity:** potential | **Story Points:** 5 +- **Description:** The product uses a hard-coded, unchangeable cryptographic key. + +### CWE-732: Incorrect Permission Assignment for Critical Resource +- **Severity:** optional | **Story Points:** 5 +- **Description:** The product specifies permissions for a security-critical resource in a way that allows that resource to be read or modified by unintended actors. + +### CWE-778: Insufficient Logging +- **Severity:** potential | **Story Points:** 3 +- **Description:** When a security-critical event occurs, the product either does not record the event or omits important details about the event when logging it. + +### CWE-798: Use of Hard-coded Credentials +- **Severity:** optional | **Story Points:** 5 +- **Description:** The product contains hard-coded credentials, such as a password or cryptographic key. + +## Instructions + +1. **Iterate through each CWE rule** listed above +2. **Systematically scan** the application source code for patterns matching each rule +3. **For each rule:** Stop scanning as soon as you find the FIRST confirmed match +4. **Continue to the next rule** after finding a match or exhausting the search +5. **Report findings** for ALL rules (both FOUND and NOT_FOUND) + +### Search Strategy + +- Start with common vulnerability patterns: user input handling, external data processing, resource management +- Check configuration files, API endpoints, data access layers, and utility classes +- Consider both direct patterns and indirect/transitive vulnerability paths +- Focus on source files (e.g., `.java`, `.py`, `.cs`, `.js`, `.ts`) — skip test files and generated code + +## Output Format + +Return the following complete JSON structure to the parent assessment. The parent writes it to the current run incoming directory and invokes the Node `record-result` contract: + +```json +{ + "input_name": "CWE - Credentials & Secrets", + "analysis_method": "LLM", + "status": "success", + "result": { + "finding": "Assessed 5 CWE rules in Credentials & Secrets: X FOUND, Y NOT_FOUND", + "confidence": "high", + "evidence": ["Scanned application source files for credentials & secrets patterns"], + "values": [ + { + "id": "", + "name": "", + "status": "FOUND", + "category": "Credentials & Secrets", + "severity": "", + "storyPoint": "", + "description": "", + "evidence": { + "files": ["src/path/to/File.java"], + "explanation": "Description of the vulnerability found, including class/method and line reference" + } + }, + { + "id": "", + "name": "", + "status": "NOT_FOUND", + "category": "Credentials & Secrets", + "severity": "", + "storyPoint": "", + "description": "", + "evidence": { + "files": [], + "explanation": "" + } + } + ] + }, + "execution_time_seconds": 0, + "timestamp": "" +} +``` + +### Evidence Rules + +- **FOUND**: `files` must contain workspace-relative file paths. `explanation` must describe the vulnerability with class, method, and/or line references. +- **NOT_FOUND**: `files` must be an empty array `[]`. `explanation` must be an empty string `""`. +- **Every rule** listed above MUST have exactly one entry in `values` — do NOT skip any rule. +- **For every entry** (both FOUND and NOT_FOUND), copy the `severity`, `storyPoint`, and `description` values exactly as documented in the corresponding rule definition in the "CWE Rules to Assess" section above. +- Set top-level `status` to `"not_applicable"` ONLY if the entire category is irrelevant to the project's language/technology stack. +- Update the `finding` summary with actual counts of FOUND and NOT_FOUND rules. diff --git a/plugins/github-copilot-modernization/skills/cwe-file-path-security/SKILL.md b/plugins/github-copilot-modernization/skills/cwe-file-path-security/SKILL.md new file mode 100644 index 0000000..e0a9e87 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/cwe-file-path-security/SKILL.md @@ -0,0 +1,110 @@ +--- +name: cwe-file-path-security +description: Assess codebase for CWE file & path security vulnerabilities (CWE-22, CWE-23, CWE-36, CWE-434, CWE-611) +user-invocable: false +--- + +# CWE Security Assessment: File & Path Security + +## Role + +You are an expert **code security reviewer** specializing in CWE vulnerability detection. + +> **Important:** You are an auditor, NOT an implementation developer. Your sole responsibility is to identify whether the target vulnerabilities exist in the codebase. Do NOT suggest fixes or improvements. + +## Objective + +Analyze the application codebase for each of the 5 CWE rules listed below. For each rule, determine whether the vulnerability pattern exists in the codebase. + +## CWE Rules to Assess + +### CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') +- **Severity:** optional | **Story Points:** 8 +- **Description:** The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. + +### CWE-23: Relative Path Traversal +- **Severity:** optional | **Story Points:** 5 +- **Description:** The product uses external input to construct a pathname that should be within a restricted directory, but it does not properly neutralize sequences such as .. that can resolve to a location that is outside of that directory. + +### CWE-36: Absolute Path Traversal +- **Severity:** optional | **Story Points:** 5 +- **Description:** The product uses external input to construct a pathname that should be within a restricted directory, but it does not properly neutralize absolute path sequences such as /abs/path that can resolve to a location that is outside of that directory. + +### CWE-434: Unrestricted Upload of File with Dangerous Type +- **Severity:** mandatory | **Story Points:** 8 +- **Description:** The product allows the upload or transfer of dangerous file types that are automatically processed within its environment. + +### CWE-611: Improper Restriction of XML External Entity Reference +- **Severity:** optional | **Story Points:** 5 +- **Description:** The product processes an XML document that can contain XML entities with URIs that resolve to documents outside of the intended sphere of control, causing the product to embed incorrect documents into its output. + +## Instructions + +1. **Iterate through each CWE rule** listed above +2. **Systematically scan** the application source code for patterns matching each rule +3. **For each rule:** Stop scanning as soon as you find the FIRST confirmed match +4. **Continue to the next rule** after finding a match or exhausting the search +5. **Report findings** for ALL rules (both FOUND and NOT_FOUND) + +### Search Strategy + +- Start with common vulnerability patterns: user input handling, external data processing, resource management +- Check configuration files, API endpoints, data access layers, and utility classes +- Consider both direct patterns and indirect/transitive vulnerability paths +- Focus on source files (e.g., `.java`, `.py`, `.cs`, `.js`, `.ts`) — skip test files and generated code + +## Output Format + +Return the following complete JSON structure to the parent assessment. The parent writes it to the current run incoming directory and invokes the Node `record-result` contract: + +```json +{ + "input_name": "CWE - File & Path Security", + "analysis_method": "LLM", + "status": "success", + "result": { + "finding": "Assessed 5 CWE rules in File & Path Security: X FOUND, Y NOT_FOUND", + "confidence": "high", + "evidence": ["Scanned application source files for file & path security patterns"], + "values": [ + { + "id": "", + "name": "", + "status": "FOUND", + "category": "File & Path Security", + "severity": "", + "storyPoint": "", + "description": "", + "evidence": { + "files": ["src/path/to/File.java"], + "explanation": "Description of the vulnerability found, including class/method and line reference" + } + }, + { + "id": "", + "name": "", + "status": "NOT_FOUND", + "category": "File & Path Security", + "severity": "", + "storyPoint": "", + "description": "", + "evidence": { + "files": [], + "explanation": "" + } + } + ] + }, + "execution_time_seconds": 0, + "timestamp": "" +} +``` + +### Evidence Rules + +- **FOUND**: `files` must contain workspace-relative file paths. `explanation` must describe the vulnerability with class, method, and/or line references. +- **NOT_FOUND**: `files` must be an empty array `[]`. `explanation` must be an empty string `""`. +- **Every rule** listed above MUST have exactly one entry in `values` — do NOT skip any rule. +- **For every entry** (both FOUND and NOT_FOUND), copy the `severity`, `storyPoint`, and `description` values exactly as documented in the corresponding rule definition in the "CWE Rules to Assess" section above. +- Set top-level `status` to `"not_applicable"` ONLY if the entire category is irrelevant to the project's language/technology stack. +- Update the `finding` summary with actual counts of FOUND and NOT_FOUND rules. diff --git a/plugins/github-copilot-modernization/skills/cwe-injection-attacks/SKILL.md b/plugins/github-copilot-modernization/skills/cwe-injection-attacks/SKILL.md new file mode 100644 index 0000000..a544446 --- /dev/null +++ b/plugins/github-copilot-modernization/skills/cwe-injection-attacks/SKILL.md @@ -0,0 +1,138 @@ +--- +name: cwe-injection-attacks +description: Assess codebase for CWE injection attacks vulnerabilities (CWE-77, CWE-78, CWE-79, CWE-88, CWE-89, CWE-90, CWE-91, CWE-99, CWE-502, CWE-564, CWE-643, CWE-652) +user-invocable: false +--- + +# CWE Security Assessment: Injection Attacks + +## Role + +You are an expert **code security reviewer** specializing in CWE vulnerability detection. + +> **Important:** You are an auditor, NOT an implementation developer. Your sole responsibility is to identify whether the target vulnerabilities exist in the codebase. Do NOT suggest fixes or improvements. + +## Objective + +Analyze the application codebase for each of the 12 CWE rules listed below. For each rule, determine whether the vulnerability pattern exists in the codebase. + +## CWE Rules to Assess + +### CWE-77: Improper Neutralization of Special Elements used in a Command ('Command Injection') +- **Severity:** mandatory | **Story Points:** 13 +- **Description:** The product constructs all or part of a command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended command when it is sent to a downstream component. + +### CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') +- **Severity:** mandatory | **Story Points:** 13 +- **Description:** The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component. + +### CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') +- **Severity:** optional | **Story Points:** 8 +- **Description:** The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. + +### CWE-88: Improper Neutralization of Argument Delimiters in a Command ('Argument Injection') +- **Severity:** optional | **Story Points:** 5 +- **Description:** The product constructs a string for a command to be executed by a separate component in another control sphere, but it does not properly delimit the intended arguments, options, or switches within that command string. + +### CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection') +- **Severity:** mandatory | **Story Points:** 13 +- **Description:** The product constructs all or part of an SQL command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended SQL command when it is sent to a downstream component. Without sufficient removal or quoting of SQL syntax in user-controllable inputs, the generated SQL query can cause those inputs to be interpreted as SQL instead of ordinary user data. + +### CWE-90: Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection') +- **Severity:** optional | **Story Points:** 5 +- **Description:** The product constructs all or part of an LDAP query using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended LDAP query when it is sent to a downstream component. + +### CWE-91: XML Injection (aka Blind XPath Injection) +- **Severity:** optional | **Story Points:** 5 +- **Description:** The product does not properly neutralize special elements that are used in XML, allowing attackers to modify the syntax, content, or commands of the XML before it is processed by an end system. + +### CWE-99: Improper Control of Resource Identifiers ('Resource Injection') +- **Severity:** potential | **Story Points:** 3 +- **Description:** The product receives input from an upstream component, but it does not restrict or incorrectly restricts the input before it is used as an identifier for a resource that may be outside the intended sphere of control. + +### CWE-502: Deserialization of Untrusted Data +- **Severity:** mandatory | **Story Points:** 13 +- **Description:** The product deserializes untrusted data without sufficiently ensuring that the resulting data will be valid. + +### CWE-564: SQL Injection: Hibernate +- **Severity:** mandatory | **Story Points:** 8 +- **Description:** Using Hibernate to execute a dynamic SQL statement built with user-controlled input can allow an attacker to modify the statement's meaning or to execute arbitrary SQL commands. + +### CWE-643: Improper Neutralization of Data within XPath Expressions ('XPath Injection') +- **Severity:** optional | **Story Points:** 5 +- **Description:** The product uses external input to dynamically construct an XPath expression used to retrieve data from an XML database, but it does not neutralize or incorrectly neutralizes that input. This allows an attacker to control the structure of the query. + +### CWE-652: Improper Neutralization of Data within XQuery Expressions ('XQuery Injection') +- **Severity:** optional | **Story Points:** 5 +- **Description:** The product uses external input to dynamically construct an XQuery expression used to retrieve data from an XML database, but it does not neutralize or incorrectly neutralizes that input. This allows an attacker to control the structure of the query. + +## Instructions + +1. **Iterate through each CWE rule** listed above +2. **Systematically scan** the application source code for patterns matching each rule +3. **For each rule:** Stop scanning as soon as you find the FIRST confirmed match +4. **Continue to the next rule** after finding a match or exhausting the search +5. **Report findings** for ALL rules (both FOUND and NOT_FOUND) + +### Search Strategy + +- Start with common vulnerability patterns: user input handling, external data processing, resource management +- Check configuration files, API endpoints, data access layers, and utility classes +- Consider both direct patterns and indirect/transitive vulnerability paths +- Focus on source files (e.g., `.java`, `.py`, `.cs`, `.js`, `.ts`) — skip test files and generated code + +## Output Format + +Return the following complete JSON structure to the parent assessment. The parent writes it to the current run incoming directory and invokes the Node `record-result` contract: + +```json +{ + "input_name": "CWE - Injection Attacks", + "analysis_method": "LLM", + "status": "success", + "result": { + "finding": "Assessed 12 CWE rules in Injection Attacks: X FOUND, Y NOT_FOUND", + "confidence": "high", + "evidence": ["Scanned application source files for injection attacks patterns"], + "values": [ + { + "id": "", + "name": "", + "status": "FOUND", + "category": "Injection Attacks", + "severity": "", + "storyPoint": "", + "description": "", + "evidence": { + "files": ["src/path/to/File.java"], + "explanation": "Description of the vulnerability found, including class/method and line reference" + } + }, + { + "id": "", + "name": "", + "status": "NOT_FOUND", + "category": "Injection Attacks", + "severity": "", + "storyPoint": "", + "description": "", + "evidence": { + "files": [], + "explanation": "" + } + } + ] + }, + "execution_time_seconds": 0, + "timestamp": "" +} +``` + +### Evidence Rules + +- **FOUND**: `files` must contain workspace-relative file paths. `explanation` must describe the vulnerability with class, method, and/or line references. +- **NOT_FOUND**: `files` must be an empty array `[]`. `explanation` must be an empty string `""`. +- **Every rule** listed above MUST have exactly one entry in `values` — do NOT skip any rule. +- **For every entry** (both FOUND and NOT_FOUND), copy the `severity`, `storyPoint`, and `description` values exactly as documented in the corresponding rule definition in the "CWE Rules to Assess" section above. +- Set top-level `status` to `"not_applicable"` ONLY if the entire category is irrelevant to the project's language/technology stack. +- Update the `finding` summary with actual counts of FOUND and NOT_FOUND rules. diff --git a/plugins/github-copilot-modernization/skills/cwe-memory-safety/SKILL.md b/plugins/github-copilot-modernization/skills/cwe-memory-safety/SKILL.md new file mode 100644 index 0000000..f02122d --- /dev/null +++ b/plugins/github-copilot-modernization/skills/cwe-memory-safety/SKILL.md @@ -0,0 +1,150 @@ +--- +name: cwe-memory-safety +description: Assess codebase for CWE memory safety vulnerabilities (CWE-119, CWE-120, CWE-123, CWE-125, CWE-415, CWE-416, CWE-672, CWE-786, CWE-787, CWE-788, CWE-805, CWE-822, CWE-823, CWE-824, CWE-825) +user-invocable: false +--- + +# CWE Security Assessment: Memory Safety + +## Role + +You are an expert **code security reviewer** specializing in CWE vulnerability detection. + +> **Important:** You are an auditor, NOT an implementation developer. Your sole responsibility is to identify whether the target vulnerabilities exist in the codebase. Do NOT suggest fixes or improvements. + +## Objective + +Analyze the application codebase for each of the 15 CWE rules listed below. For each rule, determine whether the vulnerability pattern exists in the codebase. + +## CWE Rules to Assess + +### CWE-119: Improper Restriction of Operations within the Bounds of a Memory Buffer +- **Severity:** mandatory | **Story Points:** 21 +- **Description:** The product performs operations on a memory buffer, but it reads from or writes to a memory location outside the buffer's intended boundary. This may result in read or write operations on unexpected memory locations that could be linked to other variables, data structures, or internal program data. + +### CWE-120: Buffer Copy without Checking Size of Input ('Classic Buffer Overflow') +- **Severity:** mandatory | **Story Points:** 13 +- **Description:** The product copies an input buffer to an output buffer without verifying that the size of the input buffer is less than the size of the output buffer. + +### CWE-123: Write-what-where Condition +- **Severity:** mandatory | **Story Points:** 13 +- **Description:** Any condition where the attacker has the ability to write an arbitrary value to an arbitrary location, often as the result of a buffer overflow. + +### CWE-125: Out-of-bounds Read +- **Severity:** optional | **Story Points:** 8 +- **Description:** The product reads data past the end, or before the beginning, of the intended buffer. + +### CWE-415: Double Free +- **Severity:** optional | **Story Points:** 8 +- **Description:** The product calls free() twice on the same memory address. + +### CWE-416: Use After Free +- **Severity:** mandatory | **Story Points:** 13 +- **Description:** The product reuses or references memory after it has been freed. At some point afterward, the memory may be allocated again and saved in another pointer, while the original pointer references a location somewhere within the new allocation. Any operations using the original pointer are no longer valid because the memory belongs to the code that operates on the new pointer. + +### CWE-672: Operation on a Resource after Expiration or Release +- **Severity:** potential | **Story Points:** 5 +- **Description:** The product uses, accesses, or otherwise operates on a resource after that resource has been expired, released, or revoked. + +### CWE-786: Access of Memory Location Before Start of Buffer +- **Severity:** optional | **Story Points:** 8 +- **Description:** The product reads or writes to a buffer using an index or pointer that references a memory location prior to the beginning of the buffer. + +### CWE-787: Out-of-bounds Write +- **Severity:** mandatory | **Story Points:** 13 +- **Description:** The product writes data past the end, or before the beginning, of the intended buffer. + +### CWE-788: Access of Memory Location After End of Buffer +- **Severity:** optional | **Story Points:** 8 +- **Description:** The product reads or writes to a buffer using an index or pointer that references a memory location after the end of the buffer. + +### CWE-805: Buffer Access with Incorrect Length Value +- **Severity:** optional | **Story Points:** 8 +- **Description:** The product uses a sequential operation to read or write a buffer, but it uses an incorrect length value that causes it to access memory that is outside of the bounds of the buffer. + +### CWE-822: Untrusted Pointer Dereference +- **Severity:** optional | **Story Points:** 8 +- **Description:** The product obtains a value from an untrusted source, converts this value to a pointer, and dereferences the resulting pointer. + +### CWE-823: Use of Out-of-range Pointer Offset +- **Severity:** optional | **Story Points:** 8 +- **Description:** The product performs pointer arithmetic on a valid pointer, but it uses an offset that can point outside of the intended range of valid memory locations for the resulting pointer. + +### CWE-824: Access of Uninitialized Pointer +- **Severity:** optional | **Story Points:** 5 +- **Description:** The product accesses or uses a pointer that has not been initialized. + +### CWE-825: Expired Pointer Dereference +- **Severity:** optional | **Story Points:** 8 +- **Description:** The product dereferences a pointer that contains a location for memory that was previously valid, but is no longer valid. + +## Instructions + +1. **Iterate through each CWE rule** listed above +2. **Systematically scan** the application source code for patterns matching each rule +3. **For each rule:** Stop scanning as soon as you find the FIRST confirmed match +4. **Continue to the next rule** after finding a match or exhausting the search +5. **Report findings** for ALL rules (both FOUND and NOT_FOUND) + +### Search Strategy + +- Start with common vulnerability patterns: user input handling, external data processing, resource management +- Check configuration files, API endpoints, data access layers, and utility classes +- Consider both direct patterns and indirect/transitive vulnerability paths +- Focus on source files (e.g., `.java`, `.py`, `.cs`, `.js`, `.ts`) — skip test files and generated code + +## Output Format + +Return the following complete JSON structure to the parent assessment. The parent writes it to the current run incoming directory and invokes the Node `record-result` contract: + +```json +{ + "input_name": "CWE - Memory Safety", + "analysis_method": "LLM", + "status": "success", + "result": { + "finding": "Assessed 15 CWE rules in Memory Safety: X FOUND, Y NOT_FOUND", + "confidence": "high", + "evidence": ["Scanned application source files for memory safety patterns"], + "values": [ + { + "id": "", + "name": "", + "status": "FOUND", + "category": "Memory Safety", + "severity": "", + "storyPoint": "", + "description": "", + "evidence": { + "files": ["src/path/to/File.java"], + "explanation": "Description of the vulnerability found, including class/method and line reference" + } + }, + { + "id": "", + "name": "", + "status": "NOT_FOUND", + "category": "Memory Safety", + "severity": "", + "storyPoint": "", + "description": "", + "evidence": { + "files": [], + "explanation": "" + } + } + ] + }, + "execution_time_seconds": 0, + "timestamp": "" +} +``` + +### Evidence Rules + +- **FOUND**: `files` must contain workspace-relative file paths. `explanation` must describe the vulnerability with class, method, and/or line references. +- **NOT_FOUND**: `files` must be an empty array `[]`. `explanation` must be an empty string `""`. +- **Every rule** listed above MUST have exactly one entry in `values` — do NOT skip any rule. +- **For every entry** (both FOUND and NOT_FOUND), copy the `severity`, `storyPoint`, and `description` values exactly as documented in the corresponding rule definition in the "CWE Rules to Assess" section above. +- Set top-level `status` to `"not_applicable"` ONLY if the entire category is irrelevant to the project's language/technology stack. +- Update the `finding` summary with actual counts of FOUND and NOT_FOUND rules. diff --git a/plugins/github-copilot-modernization/skills/dag-generation/references/model-selection.md b/plugins/github-copilot-modernization/skills/dag-generation/references/model-selection.md deleted file mode 100644 index 7080c39..0000000 --- a/plugins/github-copilot-modernization/skills/dag-generation/references/model-selection.md +++ /dev/null @@ -1,64 +0,0 @@ -# Model Selection by Task Phase - -When dispatching workers, set the `model` parameter based on the task's tier to optimize cost vs quality. - -## Guiding Principle - -- **Plan phase** needs deep reasoning, architectural analysis → strongest available model within budget -- **Execute phase** needs reliable code generation and tool use → cost-efficient -- **Review phase** needs deep understanding of architecture and security → strong reasoning -- **Test phase** needs test execution and verification → prefer free/low-cost models -- **Cost ceiling**: prefer models at 3x multiplier or below. Avoid 7.5x+ models unless no alternative. - -## Tier Fallback Lists - -Priority-ordered. Pick the **first available**. 0x = included/free. - -### Tier 1 — Deep Reasoning (Plan phase, architecture, quality gates) - -1. Claude Opus 4.6 (3x) -2. GPT-5.4 (1x) -3. GPT-5.2 (1x) -4. GPT-4.1 (0x) - -### Tier 2 — Backend Implementation (Execute phase, backend) - -1. Claude Sonnet 4.6 (1x) -2. GPT-5.3-Codex (1x) -3. Claude Sonnet 4.5 (1x) -4. GPT-5.4 mini (0.33x) -5. GPT-4.1 (0x) - -### Tier 3 — Frontend & UI (Execute phase, frontend/UX) - -1. GPT-5.4 (1x) -2. Claude Sonnet 4.6 (1x) -3. Claude Sonnet 4.5 (1x) -4. GPT-5.4 mini (0.33x) -5. GPT-4.1 (0x) - -### Tier 4 — Code Review (Architecture review, security audit) - -1. Claude Opus 4.6 (3x) -2. Claude Sonnet 4.6 (1x) -3. GPT-5.4 (1x) -4. GPT-4.1 (0x) - -### Tier 5 — Testing (Runtime validation, conformance, test generation) - -1. GPT-5 mini (0x) -2. GPT-4.1 (0x) -3. Claude Haiku 4.5 (0.33x) -4. GPT-5.4 mini (0.33x) - -## How the Coordinator Uses This - -1. At first dispatch, resolve each tier to the first available model from the fallback list -2. Set `model` on each worker based on the task's tier field -3. If dispatch fails with "model not available", try the next in the list - -## Notes - -- Model name format varies by platform — use whatever the platform requires. -- 0x models don't consume premium quota — tier-5 defaults to these. -- Recommendations based on May 2026 benchmarks. Update periodically. diff --git a/plugins/github-copilot-modernization/skills/guidelines/SKILL.md b/plugins/github-copilot-modernization/skills/guidelines/SKILL.md index 5efb005..2aa2f82 100644 --- a/plugins/github-copilot-modernization/skills/guidelines/SKILL.md +++ b/plugins/github-copilot-modernization/skills/guidelines/SKILL.md @@ -3,7 +3,7 @@ name: guidelines description: | Collection of framework-to-framework migration rules and transformation patterns (e.g., Struts→Spring MVC, JSP→Thymeleaf, EJB→Spring Boot). Triggers: "check migration guidelines", "look up transformation rules", "find Struts-to-Spring patterns", "apply migration conventions", "conversion rules for X→Y". - Also consumed automatically by breaking-down-tasks and implementing-code during migration workflows. + Also consumed automatically by planning and implementation skills during migration workflows. NOT for: direct execution — other skills scan this directory. --- @@ -19,23 +19,27 @@ Guidelines are curated, domain-specific knowledge bases providing: - **Transformation rules**: Concrete mappings and conversion templates - **Checklists**: Step-by-step validation criteria -## Directory Structure +## Skill Structure ``` -skills/guidelines/ -├── SKILL.md # This file - lookup mechanism -├── struts-to-spring/ # Struts 2 → Spring Boot 3.x -│ └── SKILL.md -└── {domain}/ # Future guideline domains - └── SKILL.md +skills/ +├── guidelines/ +│ └── SKILL.md # This lookup contract +├── struts-to-spring/ +│ ├── SKILL.md # Independent Struts 2 → Spring Boot 3.x skill +│ └── SKILL-*.md # Supporting rule documents +└── {domain}/ + └── SKILL.md # Future independent guideline skill ``` +Every directory containing a `SKILL.md` is a direct child of `skills/`. Never nest one skill inside another skill. + ## Lookup Mechanism When other skills need guideline lookup: 1. **Context Analysis**: Extract technology keywords from spec/plan/code. -2. **Guideline Discovery**: Search `skills/guidelines/` subdirectories for matching patterns. +2. **Guideline Discovery**: Search top-level `skills/*/SKILL.md` metadata for matching source and target technologies. 3. **Application**: Load matching SKILL.md, extract applicable rules for current phase. ## Integration Points @@ -50,7 +54,7 @@ When other skills need guideline lookup: ## Creating New Guidelines -1. Create directory: `skills/guidelines/{domain}/` +1. Create a top-level directory: `skills/{domain}/` 2. Create `SKILL.md` with: - Metadata header (name, description, triggers) - Rules organized by migration step @@ -65,5 +69,5 @@ When guidelines are applied, document in the relevant artifact: ## Applied Guidelines - **Guideline**: struts-to-spring - **Rules Used**: convert-action-to-controller, convert-validation -- **Reference**: skills/guidelines/struts-to-spring/SKILL.md +- **Reference**: skills/struts-to-spring/SKILL.md ``` diff --git a/plugins/github-copilot-modernization/skills/implementing-code/SKILL.md b/plugins/github-copilot-modernization/skills/implementing-code/SKILL.md index 733def5..8a482b4 100644 --- a/plugins/github-copilot-modernization/skills/implementing-code/SKILL.md +++ b/plugins/github-copilot-modernization/skills/implementing-code/SKILL.md @@ -120,7 +120,7 @@ For tasks with `[Source:]`, follow `references/source-anchored-rewrite.md`. **Guideline-Based Transformation:** For tasks marked `[GUIDELINE:skill-name]`: -1. Load the guideline from `skills/guidelines/` +1. Load each top-level guideline skill named in the plan 2. Apply transformation rules, import changes, method mappings 3. Use before/after examples as reference diff --git a/plugins/github-copilot-modernization/skills/list-plans/SKILL.md b/plugins/github-copilot-modernization/skills/list-plans/SKILL.md index bf3a3cc..decd072 100644 --- a/plugins/github-copilot-modernization/skills/list-plans/SKILL.md +++ b/plugins/github-copilot-modernization/skills/list-plans/SKILL.md @@ -3,7 +3,7 @@ name: list-plans description: | Discovers valid migration plans in the workspace and returns the selected plan path. A valid plan is a subdirectory of .github/modernize/ that contains plan.md AND tasks.json (tasks.json may be in the plan folder or in a .metadata subfolder). - Handles 0, 1, or multiple plans and prompts the user when a choice is needed. + Handles 0, 1, or multiple plans and returns structured NEEDS_INPUT when a choice is needed. Triggers: "list plans", "find plans", "select plan", "list-and-select-plan", "discover plans". --- @@ -77,16 +77,18 @@ Return its path immediately — no user prompt needed: ### Multiple plans found 1. For each plan, read `.github/modernize//plan.md`, extract the first heading, strip the leading `# ` and any prefix of the form ` Plan: ` (e.g. `Modernization Plan: `, `Migration Plan: `), and use the remainder as the title. -2. Print: `Searched for plan.md and tasks.json files... There are multiple plans in this repository.` -3. Ask the user to select a plan: - - `header`: `plan-selection` - - `question`: `Which plan would you like to execute?` - - `allowFreeformInput`: `false` - - `options`: one entry per plan — `label` = folder name, `description` = plan title from step 1 +2. Do not invoke a question tool. Return exactly these two lines to `planning-coordinator`, with compact JSON on the second line and no surrounding prose or Markdown fence: + +```text +NEEDS_INPUT +{"schemaVersion":1,"coordinator":"planning-coordinator","requestType":"plan-selection","questions":[{"header":"plan-selection","question":"Which plan would you like to execute?","allowFreeformInput":false,"options":[{"label":"","description":""}]}],"resumeContext":{"plans":[{"label":"","planPath":".github/modernize//plan.md"}]}} +``` + +Include one option and one matching `resumeContext.plans` entry per discovered plan, preserving discovery order. The option label is the folder name and its description is the title from step 1. Never select the first plan by default. ## Step 3 — Return -Return the selected plan path to the caller: +After `planning-coordinator` receives the top-level structured answer, it resolves the selected label through `resumeContext.plans` and returns that path: ``` .github/modernize//plan.md diff --git a/plugins/github-copilot-modernization/skills/guidelines/spring-boot-scaffolding/SKILL.md b/plugins/github-copilot-modernization/skills/spring-boot-scaffolding/SKILL.md similarity index 100% rename from plugins/github-copilot-modernization/skills/guidelines/spring-boot-scaffolding/SKILL.md rename to plugins/github-copilot-modernization/skills/spring-boot-scaffolding/SKILL.md diff --git a/plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL-action.md b/plugins/github-copilot-modernization/skills/struts-to-spring/SKILL-action.md similarity index 100% rename from plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL-action.md rename to plugins/github-copilot-modernization/skills/struts-to-spring/SKILL-action.md diff --git a/plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL-config.md b/plugins/github-copilot-modernization/skills/struts-to-spring/SKILL-config.md similarity index 100% rename from plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL-config.md rename to plugins/github-copilot-modernization/skills/struts-to-spring/SKILL-config.md diff --git a/plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL-interceptor.md b/plugins/github-copilot-modernization/skills/struts-to-spring/SKILL-interceptor.md similarity index 100% rename from plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL-interceptor.md rename to plugins/github-copilot-modernization/skills/struts-to-spring/SKILL-interceptor.md diff --git a/plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL-test.md b/plugins/github-copilot-modernization/skills/struts-to-spring/SKILL-test.md similarity index 100% rename from plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL-test.md rename to plugins/github-copilot-modernization/skills/struts-to-spring/SKILL-test.md diff --git a/plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL-view.md b/plugins/github-copilot-modernization/skills/struts-to-spring/SKILL-view.md similarity index 100% rename from plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL-view.md rename to plugins/github-copilot-modernization/skills/struts-to-spring/SKILL-view.md diff --git a/plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL-xml.md b/plugins/github-copilot-modernization/skills/struts-to-spring/SKILL-xml.md similarity index 100% rename from plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL-xml.md rename to plugins/github-copilot-modernization/skills/struts-to-spring/SKILL-xml.md diff --git a/plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL.md b/plugins/github-copilot-modernization/skills/struts-to-spring/SKILL.md similarity index 97% rename from plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL.md rename to plugins/github-copilot-modernization/skills/struts-to-spring/SKILL.md index be8d598..37cc979 100644 --- a/plugins/github-copilot-modernization/skills/guidelines/struts-to-spring/SKILL.md +++ b/plugins/github-copilot-modernization/skills/struts-to-spring/SKILL.md @@ -1,3 +1,8 @@ +--- +name: struts-to-spring +description: Migration guideline for transforming Apache Struts 2 applications to Spring Boot 3.x and Spring MVC. +--- + # Struts 2 to Spring Boot 3.x Migration Guideline ## Metadata