diff --git a/extensions/java-modernization-studio/.gitignore b/extensions/java-modernization-studio/.gitignore new file mode 100644 index 000000000..46c7eb988 --- /dev/null +++ b/extensions/java-modernization-studio/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +artifacts/ diff --git a/extensions/java-modernization-studio/README.md b/extensions/java-modernization-studio/README.md new file mode 100644 index 000000000..8677964ae --- /dev/null +++ b/extensions/java-modernization-studio/README.md @@ -0,0 +1,92 @@ +# Java Modernization Studio + +An interactive GitHub Copilot **canvas** that turns the [GitHub Copilot App Modernization for Java](https://learn.microsoft.com/en-us/azure/developer/java/migration/migrate-github-copilot-app-modernization-for-java) CLI workflow into a visible, steerable dashboard — assess a legacy Java app, drive a prioritized remediation plan, run validation gates, and dispatch Microsoft predefined tasks, all grounded in the repo's real artifacts. + +The Copilot App Modernization for Java tooling stays the engine. This canvas is the cockpit on top of it: it reads what the workflow produces and turns each step into an agent-driving button. + +## What it does + +- **Overview** — at-a-glance modernization status (phase, % complete, finding counts) scanned from the repo. +- **Readiness (Environment Doctor)** — checks JDK, Maven (or `./mvnw`), Git, Docker, and Azure CLI on PATH and flags what's missing before you start. +- **Assessment** — renders structured findings from `.appmod/assessment.json` (stack summary, severity-ordered findings, strengths), each with a one-click action. +- **Plan & Progress** — renders `plan.md` / `progress.md` as live checklists (`- [ ]` / `- [x]`). +- **Validation** — runs the workflow's quality gates (CVE validation, test generation) before and after changes. +- **Tasks** — dispatches Microsoft predefined modernization tasks (managed identity for DB, secrets → Key Vault, message-broker → Service Bus, S3 → Blob, cache → Redis, Entra ID auth, and more) relevant to the detected stack. +- **Summary** — surfaces `summary.md` when the run is complete. +- **Autopilot** — an optional phase-ordered, hands-free loop that advances assessment → remediation → validation and updates the dashboard as the agent makes progress. + +Buttons don't execute logic in the canvas — they dispatch a grounded prompt to your Copilot agent (action kinds: `run_task`, `generate_plan`, `run_cve`, `generate_tests`, `fix_finding`). The agent does the work; the canvas reflects the result. + +## Prerequisites + +- **GitHub Copilot app** (the canvas host). +- **GitHub Copilot App Modernization for Java** tooling — the underlying workflow this canvas drives. +- **JDK 17+** and **Maven** (or a `./mvnw` wrapper) for the Java project you're modernizing. +- *Optional:* **Azure CLI** (`az`) for cloud-readiness and Azure migration tasks; **Docker** for container checks. + +## Install + +This is an in-repo canvas extension. Copy the `java-modernization-studio/` folder into one of: + +- `~/.copilot/extensions/` — **user scope** (just you), or +- `.github/extensions/` — **project scope** (shared with your repo's team). + +Then reload extensions (or restart the app) so Copilot discovers it. No build step is required — the Copilot CLI resolves `@github/copilot-sdk` automatically. + +## Usage + +Point the canvas at a Java repository and let the agent drive it: + +```text +Open the Java Modernization Studio canvas for /path/to/my-java-app and run a readiness check. +``` + +The canvas resolves the target repo from its `repoPath` input, falling back to the session's working directory. From there: + +1. **Readiness first** — resolve any missing JDK/Maven/Azure CLI the Doctor flags. +2. **Assess** — generate `.appmod/assessment.json` + a prioritized `plan.md` / `progress.md`. +3. **Remediate** — work findings in severity order (P0 first), using task buttons and "Help me fix this". +4. **Validate** — run the CVE and test-generation gates. +5. **Ship** — when the work is genuinely complete, write `summary.md`. + +### Suggested agent instructions + +```text +When a user modernizes a Java project with the Java Modernization Studio canvas: +1) Open the canvas pointed at the repo (repoPath) and run the Environment Doctor first. +2) Run an assessment; write findings to .appmod/assessment.json and a prioritized plan.md / progress.md. + Start plan.md, progress.md, and summary.md with the exact first line so the + canvas recognizes them as modernization artifacts. +3) Work findings in severity order (P0 first); run the validation gates (CVE scan, test generation) + before and after code changes. +4) Keep plan.md / progress.md updated as - [ ] / - [x] checklists. Only write summary.md when the + work is truly complete. +``` + +## How it stays grounded + +The canvas renders **real repo state**, never invented status: + +- Structured findings come from `.appmod/assessment.json`. +- Plan/progress/summary come from root `plan.md` / `progress.md` / `summary.md`. +- To avoid mistaking an unrelated repo's `plan.md` for modernization output, root markdown is trusted **only** when `.appmod/` exists **or** the file's first line is the provenance marker ``. +- Stack detection (build tool, Java version, framework, container) is parsed from `pom.xml` / Gradle / Dockerfile and drives which tasks are shown. + +## Agent-callable actions + +| Action | Description | +|---|---| +| `get_state` | Return the current modernization snapshot scanned from the repo (assessment, plan/progress, gates, tasks). | +| `refresh` | Re-scan the repo and push a fresh snapshot to the open canvas. | + +## Development + +The grounding/parsing logic is pure and unit-tested independently of the canvas runtime: + +```bash +node --test test/cockpit.test.mjs +``` + +## License + +MIT diff --git a/extensions/java-modernization-studio/assets/preview.png b/extensions/java-modernization-studio/assets/preview.png new file mode 100644 index 000000000..7aac2e926 Binary files /dev/null and b/extensions/java-modernization-studio/assets/preview.png differ diff --git a/extensions/java-modernization-studio/autopilot.mjs b/extensions/java-modernization-studio/autopilot.mjs new file mode 100644 index 000000000..60287a6e6 --- /dev/null +++ b/extensions/java-modernization-studio/autopilot.mjs @@ -0,0 +1,162 @@ +// autopilot.mjs — Sequencing engine for "Run on autopilot". +// +// Given the live plan, Autopilot drives the agent through the checklist one +// step at a time: pick the next eligible step (respecting phase ordering), +// hand it to the agent, wait for the turn to finish, re-scan, and repeat — +// streaming progress to the canvas after every step. +// +// All I/O is injected (`snapshot`, `runTurn`, `buildStepPrompt`, `onProgress`) +// so the loop is unit-testable without a live session or HTTP server. The pure +// helpers below are the same selection logic the renderer uses for "Continue +// here", which keeps the visible recommendation and the automated run in sync. + +// How long to wait for a single step's turn to go idle. Generous: one +// modernization step can involve edits, a build, and tests. The wait does not +// abort in-flight agent work; it only bounds how long Autopilot blocks before +// treating the step as failed. +export const AUTOPILOT_TURN_TIMEOUT_MS = 30 * 60 * 1000; + +export const AUTOPILOT_MAX_STEPS = 25; + +/** The checklist Autopilot follows: progress.md when present, else plan.md. */ +export function currentSteps(state) { + if (!state) return []; + if (state.progress && state.progress.steps && state.progress.steps.length) return state.progress.steps; + if (state.plan && state.plan.steps) return state.plan.steps; + return []; +} + +/** Stable identity for a step across re-scans (phase + title). */ +export function stepKey(step) { + if (!step) return ""; + return (step.section || "") + "::" + (step.title || ""); +} + +/** + * The next step Autopilot should run: the first not-done step in the active + * phase, falling back to the first not-done step overall. Mirrors the renderer's + * "Continue here" so the automated run never jumps ahead of the safe next step. + * @returns {object|null} + */ +export function selectNextStep(state) { + const steps = currentSteps(state); + if (!steps.length) return null; + const ord = (state && state.ordering) || { activeRank: null }; + const inPhase = steps.find( + (x) => x.status !== "done" && (ord.activeRank == null || x.rank === ord.activeRank) + ); + return inPhase || steps.find((x) => x.status !== "done") || null; +} + +/** Whether the given step is now checked off in a freshly scanned state. */ +export function isStepDone(state, step) { + const k = stepKey(step); + const match = currentSteps(state).find((s) => stepKey(s) === k); + return !!(match && match.status === "done"); +} + +/** Construct the mutable, serializable run record broadcast to the canvas. */ +export function makeRun({ scope, maxSteps, startRank } = {}) { + return { + running: true, + cancelled: false, + scope: scope === "all" ? "all" : "phase", + maxSteps: maxSteps || AUTOPILOT_MAX_STEPS, + startRank: startRank == null ? null : startRank, + status: "running", + current: null, + completed: [], + startedAt: Date.now(), + finishedAt: null, + }; +} + +/** + * Drive the run to completion. Mutates `run` in place and calls + * `deps.onProgress(run, state|null)` whenever something changes so the caller + * can broadcast. Resolves with the final `run`. + * + * @param {object} run from makeRun() + * @param {{ + * snapshot: () => Promise, + * runTurn: (prompt: string) => Promise, + * buildStepPrompt: (step: object) => string, + * onProgress: (run: object, state: object|null) => void, + * log?: Function, + * }} deps + */ +export async function runAutopilot(run, deps) { + const onProgress = deps.onProgress || (() => {}); + let lastKey = null; + try { + while (true) { + if (run.cancelled) { + run.status = "cancelled"; + break; + } + if (run.completed.length >= run.maxSteps) { + run.status = "capped"; + break; + } + const state = await deps.snapshot(); + const step = selectNextStep(state); + if (!step) { + run.status = "completed"; + break; + } + // Phase scope: stop once the active phase advances past where we began. + if (run.scope === "phase" && run.startRank != null && step.rank != null && step.rank > run.startRank) { + run.status = "phase_done"; + break; + } + const key = stepKey(step); + // Selecting the same step twice running means the previous attempt did + // not check it off — the agent is stuck or waiting on a decision. Stop + // and hand control back rather than loop on it. + if (key === lastKey) { + run.status = "stuck"; + run.stuck = step.title; + break; + } + lastKey = key; + + run.current = { title: step.title, section: step.section || null }; + onProgress(run, state); + if (deps.log) deps.log("Autopilot → " + step.title, { ephemeral: true }); + + let stepError = null; + try { + await deps.runTurn(deps.buildStepPrompt(step)); + } catch (e) { + stepError = (e && e.message) || String(e); + } + + const after = await deps.snapshot(); + const done = isStepDone(after, step); + run.completed.push({ + title: step.title, + section: step.section || null, + done, + error: stepError, + at: Date.now(), + }); + run.current = null; + onProgress(run, after); + + if (stepError) { + run.status = "error"; + run.error = stepError; + break; + } + } + } catch (e) { + run.status = "error"; + run.error = (e && e.message) || String(e); + } finally { + if (run.status === "running") run.status = "completed"; + run.running = false; + run.finishedAt = Date.now(); + onProgress(run, null); + } + return run; +} diff --git a/extensions/java-modernization-studio/canvas.json b/extensions/java-modernization-studio/canvas.json new file mode 100644 index 000000000..ec31d1c71 --- /dev/null +++ b/extensions/java-modernization-studio/canvas.json @@ -0,0 +1,29 @@ +{ + "id": "appmod-cockpit", + "name": "Java Modernization Studio", + "description": "Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts.", + "version": "1.0.0", + "author": { + "name": "Ayan Gupta", + "url": "https://github.com/ayangupt" + }, + "keywords": [ + "app-modernization", + "assessment-dashboard", + "azure-migration", + "java-modernization", + "legacy-java", + "modernization-cockpit", + "validation-gates" + ], + "screenshots": { + "icon": { + "path": "assets/preview.png", + "type": "image/png" + }, + "gallery": { + "path": "assets/preview.png", + "type": "image/png" + } + } +} \ No newline at end of file diff --git a/extensions/java-modernization-studio/catalog.mjs b/extensions/java-modernization-studio/catalog.mjs new file mode 100644 index 000000000..da2fa6b3f --- /dev/null +++ b/extensions/java-modernization-studio/catalog.mjs @@ -0,0 +1,175 @@ +// catalog.mjs — Static catalog of GitHub Copilot App Modernization for Java +// predefined tasks and supported Java upgrade paths. Sourced from Microsoft Learn: +// /azure/developer/java/migration/migrate-github-copilot-app-modernization-for-java-predefined-tasks +// /azure/developer/github-copilot-app-modernization/tools +// +// `detect` lists dependency-signature keys (see scan.mjs DEP_SIGNATURES). When a +// repo's build files match one of those keys, the task is flagged "relevant". + +export const PREDEFINED_TASKS = [ + { + id: "rabbitmq-to-servicebus", + name: "RabbitMQ to Azure Service Bus", + category: "Messaging", + summary: + "Convert RabbitMQ usage (Spring AMQP, Spring JMS, or Jakarta EE over AMQP) to Azure Service Bus, preserving messaging semantics with secure auth by default.", + detect: ["rabbitmq"], + }, + { + id: "activemq-to-servicebus", + name: "ActiveMQ to Azure Service Bus", + category: "Messaging", + summary: + "Convert ActiveMQ producers, consumers, connection factories, and queue/topic interactions to Azure Service Bus equivalents.", + detect: ["activemq"], + }, + { + id: "aws-sqs-to-servicebus", + name: "AWS SQS to Azure Service Bus", + category: "Messaging", + summary: + "Translate AWS SQS queue operations and message handling to Azure Service Bus, preserving at-least-once delivery, batching, and visibility-timeout behaviors.", + detect: ["awsSqs"], + }, + { + id: "managed-identity-db", + name: "Managed Identities for Database migration", + category: "Identity & Secrets", + summary: + "Prepare your codebase for Managed Identity authentication when moving from a local database to Azure SQL, MySQL, PostgreSQL, or Cosmos DB.", + detect: ["jdbc"], + }, + { + id: "managed-identity-credentials", + name: "Managed Identities for Credential Migration", + category: "Identity & Secrets", + summary: + "Replace connection strings / shared access signatures for messaging services (Event Hubs, Service Bus) with Azure Managed Identity authentication.", + detect: ["rabbitmq", "activemq", "jms"], + }, + { + id: "secrets-to-keyvault", + name: "Secrets & Certificate Management to Azure Key Vault", + category: "Identity & Secrets", + summary: + "Move hardcoded secrets and local TLS/mTLS certificates (Java KeyStores) to Azure Key Vault, retrieving them at runtime via the JCA provider.", + detect: ["keystore"], + }, + { + id: "crypto-to-keyvault", + name: "Cryptography operations to Azure Key Vault", + category: "Identity & Secrets", + summary: + "Migrate local signing, verification, encryption, and decryption to Azure Key Vault so keys never leave the vault.", + detect: ["crypto"], + }, + { + id: "aws-secrets-to-keyvault", + name: "AWS Secret Manager to Azure Key Vault", + category: "Identity & Secrets", + summary: + "Reconfigure secret creation, retrieval, update, and deletion from AWS Secret Manager to Azure Key Vault.", + detect: ["awsSecrets"], + }, + { + id: "entra-id-auth", + name: "User authentication to Microsoft Entra ID", + category: "Identity & Secrets", + summary: + "Transition LDAP-based user authentication to Microsoft Entra ID authentication.", + detect: ["ldap"], + }, + { + id: "aws-s3-to-blob", + name: "AWS S3 to Azure Storage Blob", + category: "Storage", + summary: + "Convert code that interacts with AWS S3 into Azure Storage Blob logic while maintaining the same semantics.", + detect: ["awsS3"], + }, + { + id: "file-io-to-fileshare", + name: "Local file I/O to Azure Storage File share mounts", + category: "Storage", + summary: + "Convert local file reads/writes to unified mount-path access so an Azure Storage File share can persist data across replicas.", + detect: [], + }, + { + id: "logging-to-console", + name: "Logging to local file → console (Azure Monitor)", + category: "Observability", + summary: + "Convert file-based logging to console-based logging so Azure hosting integrates it with Azure Monitor automatically.", + detect: ["filelog"], + }, + { + id: "javamail-to-acs", + name: "Java Mail to Azure Communication Services", + category: "Email", + summary: + "Convert SMTP-based mail sending to Azure Communication Services, which works in Azure hosting environments that block port 25.", + detect: ["javamail"], + }, + { + id: "databases-to-azure", + name: "On-prem databases to Azure database offerings", + category: "Database", + summary: + "Migrate Oracle, IBM Db2, Informix, or Sybase ASE to Azure Database for PostgreSQL or Azure SQL with passwordless Entra ID auth, reconciling SQL dialect differences.", + detect: ["oracle", "db2", "sybase", "informix"], + }, + { + id: "cache-to-redis", + name: "Cache solutions to Azure Managed Redis", + category: "Cache", + summary: + "Migrate in-memory or distributed caches (Infinispan, SwarmCache, Memcached, etc.) to Azure Managed Redis with passwordless Entra ID auth.", + detect: ["cache"], + }, + { + id: "ant-eclipse-to-maven", + name: "Ant / Eclipse project to Maven", + category: "Build", + summary: + "Convert Ant or Eclipse IDE projects to Maven so the project builds consistently from any environment.", + detect: ["ant"], + }, +]; + +// Java runtime upgrade ladder targeted by App Modernization (8 → 11 → 17 → 21 → 25), +// with particular focus on Spring Boot apps. +export const JAVA_UPGRADE_TARGETS = [11, 17, 21, 25]; + +// Day-to-day Java utilities you can invoke in Copilot Chat with the `#` prefix. +export const APPMOD_TOOLS = { + cve: { + id: "appmod-validate-cves-for-java", + label: "Validate CVEs", + summary: + "Scan the project for known Java CVEs and validate that critical vulnerabilities are addressed.", + }, + tests: { + id: "appmod-generate-tests-for-java", + label: "Generate unit tests", + summary: "Use AI code understanding to generate unit tests for the Java code.", + }, +}; + +// The five validation gates App Modernization runs after code transformation. +export const VALIDATION_GATES = [ + { key: "build", label: "Build" }, + { key: "tests", label: "Unit Tests" }, + { key: "cve", label: "CVE Check" }, + { key: "consistency", label: "Consistency" }, + { key: "completeness", label: "Completeness" }, +]; + +/** Return the catalog with a `relevant` flag set per task based on detected deps. */ +export function catalogWithRelevance(detectedKeys) { + const set = new Set(detectedKeys || []); + return PREDEFINED_TASKS.map((t) => ({ + ...t, + relevant: t.detect.some((d) => set.has(d)), + })); +} diff --git a/extensions/java-modernization-studio/doctor.mjs b/extensions/java-modernization-studio/doctor.mjs new file mode 100644 index 000000000..d121dc860 --- /dev/null +++ b/extensions/java-modernization-studio/doctor.mjs @@ -0,0 +1,412 @@ +// doctor.mjs — Environment & workflow readiness checks for the App Modernization +// Cockpit. Probes the local CLI toolchain (JDK, Maven/Gradle, git, Docker, Azure +// CLI / azd, Node) and combines those results with repo facts from scan.mjs so a +// user can confirm their machine is ready *before* they start migrating — and get +// clear, actionable remediation when something is missing. +// +// Split into a pure builder (`buildDoctorReport`) and an impure runner +// (`runDoctor`) so the decision logic is unit-testable without shelling out. + +import { execFile } from "node:child_process"; +import { platform } from "node:os"; +import { readdirSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +// One probe per tool. Many CLIs print their version to stderr (notably `java`), +// so probes capture both streams. +export const TOOL_PROBES = [ + { key: "java", cmd: "java", args: ["-version"] }, + { key: "mvn", cmd: "mvn", args: ["-v"] }, + { key: "gradle", cmd: "gradle", args: ["-v"] }, + { key: "git", cmd: "git", args: ["--version"] }, + { key: "docker", cmd: "docker", args: ["--version"] }, + { key: "az", cmd: "az", args: ["--version"] }, + { key: "azd", cmd: "azd", args: ["version"] }, + { key: "node", cmd: "node", args: ["--version"] }, +]; + +/** Friendly OS label for remediation hints. */ +export function osLabel() { + const p = platform(); + if (p === "darwin") return "macOS"; + if (p === "win32") return "Windows"; + if (p === "linux") return "Linux"; + return p; +} + +/** Extract a short version string from a tool's --version output. Pure. */ +export function parseToolVersion(key, text) { + if (!text) return null; + const t = String(text); + if (key === "java") { + const m = t.match(/version\s+"?([\d._]+)"?/i); + if (m) return m[1]; + } + if (key === "node") { + const m = t.match(/v?(\d+\.\d+\.\d+)/); + if (m) return m[1]; + } + const g = t.match(/(\d+\.\d+(?:\.\d+)?)/); + if (g) return g[1]; + const first = t.split(/\r?\n/)[0].trim(); + return first ? first.slice(0, 40) : null; +} + +function defaultExec(cmd, args) { + return makeExec(augmentedEnv(process.env, platform()))(cmd, args); +} + +/** + * Build a child-process env whose PATH also covers the usual places dev tools + * live. A GUI-launched app (the Copilot app) inherits a minimal launchd PATH + * (/usr/bin:/bin:/usr/sbin:/sbin), so tools installed via Homebrew, SDKMAN, + * asdf, or a keg-only JDK aren't found even though the user's terminal sees + * them fine. Prepending these locations makes the probes match reality without + * touching the user's actual environment. Pure given an injected directory + * lister (so it's unit-testable without hitting the filesystem). + */ +export function augmentedEnv(env, plat, lister) { + const base = Object.assign({}, env); + if (plat === "win32") return base; // Windows PATH semantics differ; leave as-is. + const ls = lister || ((d) => { try { return readdirSync(d); } catch (e) { return []; } }); + const extra = ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin"]; + if (base.HOME) { + extra.push(base.HOME + "/.sdkman/candidates/java/current/bin"); + extra.push(base.HOME + "/.asdf/shims"); + } + if (base.JAVA_HOME) extra.push(base.JAVA_HOME + "/bin"); + // Keg-only Homebrew JDKs aren't symlinked into .../bin, so enumerate them. + for (const root of ["/opt/homebrew/opt", "/usr/local/opt"]) { + for (const name of ls(root)) { + if (/^openjdk(@\d+)?$/.test(name)) extra.push(root + "/" + name + "/libexec/openjdk.jdk/Contents/Home/bin"); + } + } + const seen = {}; + const prefix = extra.filter((p) => { if (seen[p]) return false; seen[p] = 1; return true; }); + base.PATH = prefix.join(":") + (base.PATH ? ":" + base.PATH : ""); + return base; +} + +/** Build an exec function bound to a specific environment. */ +export function makeExec(env) { + return (cmd, args) => + new Promise((resolve) => { + execFile(cmd, args, { timeout: 3500, windowsHide: true, env }, (error, stdout, stderr) => { + resolve({ error: error || null, stdout: String(stdout || ""), stderr: String(stderr || "") }); + }); + }); +} + +/** + * Expand a shell-style JAVA_HOME value from an rc assignment into a concrete path, + * WITHOUT executing anything. Handles surrounding quotes, an inline comment on + * unquoted values, and `~` / `$HOME` / `${HOME}` expansion. Returns null when the + * value still contains something only a shell could resolve (command substitution, + * other variables) — we'd rather report nothing than a bogus path. + */ +export function expandJavaHomeValue(raw, env) { + if (typeof raw !== "string") return null; + let v = raw.trim(); + if (!v) return null; + if (v[0] !== '"' && v[0] !== "'") { + const c = v.indexOf(" #"); + if (c >= 0) v = v.slice(0, c).trim(); + } + if (v.length >= 2 && ((v[0] === '"' && v[v.length - 1] === '"') || (v[0] === "'" && v[v.length - 1] === "'"))) { + v = v.slice(1, -1); + } + const home = (env && env.HOME) || ""; + if (v === "~") v = home; + else if (v.startsWith("~/")) v = home + v.slice(1); + v = v.replace(/\$\{HOME\}/g, home).replace(/\$HOME(?=\/|$)/g, home); + if (!v || /[`]|\$\(|\$\w/.test(v)) return null; // unresolved shell expansion + return v; +} + +/** + * Statically parse a shell rc file's text for the last `JAVA_HOME=` assignment. + * Pure and side-effect-free. Returns the expanded path or null. + */ +export function parseJavaHomeFromRc(text, env) { + if (typeof text !== "string" || !text) return null; + const re = /^[ \t]*(?:export[ \t]+)?JAVA_HOME[ \t]*=[ \t]*(.+?)[ \t]*$/gm; + let m; + let last = null; + while ((m = re.exec(text)) !== null) { + const val = expandJavaHomeValue(m[1], env); + if (val) last = val; // later assignments win, mirroring shell evaluation order + } + return last; +} + +/** + * Best-effort discovery of the JAVA_HOME the user's interactive shell is + * configured with, so the readiness report reflects the JDK their terminal + * actually uses (e.g. a pinned LTS) rather than whichever JDK happens to sort + * first on the augmented PATH. + * + * IMPORTANT: this only *reads and statically parses* the shell rc files — it never + * sources or executes them. That keeps the readiness probe honest with the UI + * promise ("only reads version numbers; nothing is installed or changed") and + * avoids running arbitrary user startup code. A JAVA_HOME defined via command + * substitution (e.g. `$(/usr/libexec/java_home)`) can't be resolved statically, so + * we simply fall back to PATH-based probing in that case. The rc reader is + * injectable for tests. + */ +export async function discoverJavaHome(env, plat, readRc) { + const e = env || {}; + if (e.JAVA_HOME) return e.JAVA_HOME; // already explicit in this process + if (plat === "win32") return null; // not a $JAVA_HOME/rc world + const home = e.HOME || ""; + if (!home) return null; + const shell = e.SHELL || (plat === "darwin" ? "/bin/zsh" : "/bin/bash"); + const names = /zsh/.test(shell) + ? [".zshenv", ".zprofile", ".zshrc"] + : /bash/.test(shell) + ? [".bash_profile", ".bashrc", ".profile"] + : [".profile"]; + const read = + readRc || + (async (p) => { + try { + return await readFile(p, "utf8"); + } catch { + return null; + } + }); + let found = null; + for (const name of names) { + let text = null; + try { + text = await read(join(home, name)); + } catch { + text = null; + } + const val = parseJavaHomeFromRc(text, e); + if (val) found = val; // later rc files (e.g. .zshrc) win + } + return found || null; +} + +/** Probe a single tool. Returns { key, found, version }. */ +export async function probeOne(def, exec) { + const run = exec || defaultExec; + let r; + try { + r = await run(def.cmd, def.args); + } catch (e) { + r = { error: e, stdout: "", stderr: "" }; + } + const text = ((r.stdout || "") + "\n" + (r.stderr || "")).trim(); + // ENOENT means the binary isn't on PATH at all. + if (r.error && r.error.code === "ENOENT") return { key: def.key, found: false, version: null }; + if (r.error && !text) return { key: def.key, found: false, version: null }; + const version = parseToolVersion(def.key, text); + if (r.error) { + // Non-zero exit. Genuine `--version` calls exit 0, so only trust this if a + // real version number came back. This rejects the macOS `java` stub, which + // exits 1 with "Unable to locate a Java Runtime" when no JDK is installed. + if (!(version && /\d/.test(version))) return { key: def.key, found: false, version: null }; + } + return { key: def.key, found: true, version }; +} + +/** Probe every tool and build the readiness report. Impure (spawns processes). */ +export async function runDoctor(scan, opts = {}) { + let exec = opts.exec; + if (!exec) { + const env = augmentedEnv(process.env, platform()); + // Prefer the JDK the user's shell is configured with so the report matches + // their terminal (and so a dependency-pulled JDK doesn't shadow their pin). + // Static rc parse — no shell sourcing. + const jh = await discoverJavaHome(process.env, platform()); + if (jh && !env.JAVA_HOME) { + env.JAVA_HOME = jh; + env.PATH = jh + "/bin:" + env.PATH; + } + exec = makeExec(env); + } + const probesArr = await Promise.all(TOOL_PROBES.map((d) => probeOne(d, exec))); + const probes = {}; + for (const p of probesArr) probes[p.key] = p; + return buildDoctorReport({ probes, scan: scan || {} }); +} + +function envAction(tool, detail, fix) { + return { kind: "fix_env", payload: { tool, detail, fix }, label: "Help me set up " + tool }; +} + +/** + * Turn probe results + scan facts into grouped readiness checks. Pure. + * @returns {{ overall:"ready"|"caution"|"blocked", generatedAt:string, groups:Array }} + */ +export function buildDoctorReport({ probes, scan }) { + const get = (k) => (probes && probes[k]) || { found: false, version: null }; + const a = (scan && scan.assessment) || {}; + const buildTool = a.buildTool || null; + + const buildRun = []; + + // --- JDK ----------------------------------------------------------------- + const java = get("java"); + buildRun.push( + java.found + ? { id: "jdk", label: "Java Development Kit", status: "ok", detail: "Java " + (java.version || "detected") } + : { + id: "jdk", + label: "Java Development Kit", + status: "fail", + detail: "java not found on PATH", + fix: "Install a JDK 17 or newer (Microsoft Build of OpenJDK or Eclipse Temurin) and make sure `java` is on your PATH.", + action: envAction("a JDK", "java not found on PATH", "Install JDK 17+ (Microsoft OpenJDK / Temurin) and add it to PATH."), + } + ); + + // --- Build tool ---------------------------------------------------------- + if (buildTool === "Maven") { + const mvn = get("mvn"); + const ok = mvn.found || a.hasMavenWrapper; + buildRun.push( + ok + ? { + id: "build", + label: "Maven", + status: "ok", + detail: mvn.found ? "Maven " + (mvn.version || "detected") : "Using the project Maven wrapper (./mvnw)", + } + : { + id: "build", + label: "Maven", + status: "fail", + detail: "mvn not found and no ./mvnw wrapper in the repo", + fix: "Install Apache Maven, or add the Maven wrapper (`mvn -N wrapper:wrapper`) so the build runs anywhere.", + action: envAction("Maven", "mvn not found and no ./mvnw wrapper", "Install Apache Maven or add the ./mvnw wrapper."), + } + ); + } else if (buildTool === "Gradle") { + const gr = get("gradle"); + const ok = gr.found || a.hasGradleWrapper; + buildRun.push( + ok + ? { + id: "build", + label: "Gradle", + status: "ok", + detail: gr.found ? "Gradle " + (gr.version || "detected") : "Using the project Gradle wrapper (./gradlew)", + } + : { + id: "build", + label: "Gradle", + status: "fail", + detail: "gradle not found and no ./gradlew wrapper in the repo", + fix: "Install Gradle, or add the Gradle wrapper (`gradle wrapper`) so the build runs anywhere.", + action: envAction("Gradle", "gradle not found and no ./gradlew wrapper", "Install Gradle or add the ./gradlew wrapper."), + } + ); + } else { + buildRun.push({ + id: "build", + label: "Build tool", + status: "info", + detail: "No Maven or Gradle build file detected yet.", + }); + } + + // --- git ----------------------------------------------------------------- + const git = get("git"); + buildRun.push( + git.found + ? { id: "git", label: "Git", status: "ok", detail: git.version ? "git " + git.version : "installed" } + : { + id: "git", + label: "Git", + status: "fail", + detail: "git not found on PATH", + fix: "Install Git — the modernization workflow uses a branch and a pull request.", + action: envAction("Git", "git not found on PATH", "Install Git and ensure it is on PATH."), + } + ); + + // --- containerize / deploy (optional) ------------------------------------ + const deploy = []; + const docker = get("docker"); + if (docker.found) { + deploy.push({ id: "docker", label: "Docker", status: "ok", detail: docker.version ? "Docker " + docker.version : "installed" }); + } else if (a.hasDockerfile) { + deploy.push({ + id: "docker", + label: "Docker", + status: "warn", + detail: "A Dockerfile is present but Docker isn't installed.", + fix: "Install Docker Desktop (or the Docker engine) to build and run the container image locally.", + action: envAction("Docker", "Dockerfile present but Docker not installed", "Install Docker Desktop / engine."), + }); + } else { + deploy.push({ id: "docker", label: "Docker", status: "info", detail: "Not installed — only needed to build container images." }); + } + const az = get("az"); + deploy.push( + az.found + ? { id: "az", label: "Azure CLI", status: "ok", detail: az.version ? "az " + az.version : "installed" } + : { id: "az", label: "Azure CLI", status: "info", detail: "Optional — needed to provision Azure resources and set up passwordless auth." } + ); + const azd = get("azd"); + deploy.push( + azd.found + ? { id: "azd", label: "Azure Developer CLI", status: "ok", detail: azd.version ? "azd " + azd.version : "installed" } + : { id: "azd", label: "Azure Developer CLI", status: "info", detail: "Optional — `azd` deploys the app to Azure once it's modernized." } + ); + + // --- workflow readiness (from scan, no probes) --------------------------- + const flow = []; + const g = scan && scan.git; + if (!g) { + flow.push({ id: "branch", label: "Source control", status: "info", detail: "Git branch state unavailable." }); + } else if (g.isMigrationBranch) { + flow.push({ id: "branch", label: "Working branch", status: "ok", detail: "On modernization branch '" + g.branch + "'." }); + } else { + flow.push({ + id: "branch", + label: "Working branch", + status: "info", + detail: "On '" + (g.branch || "unknown") + "' — consider a dedicated modernization branch before making changes.", + }); + } + const hasAssessment = !!( + (scan && scan.report && scan.report.findings && scan.report.findings.length) || + (scan && scan.plan && scan.plan.exists) || + (scan && scan.progress && scan.progress.exists) + ); + flow.push( + hasAssessment + ? { id: "assessed", label: "Assessment", status: "ok", detail: "Assessment artifacts found — you're underway." } + : { + id: "assessed", + label: "Assessment", + status: "warn", + detail: "No assessment yet. Start here to map your modernization work.", + action: { kind: "start_assessment", payload: {}, label: "Run assessment" }, + } + ); + const nSkills = (scan && scan.skills && scan.skills.length) || 0; + flow.push({ + id: "skills", + label: "Custom skills", + status: "info", + detail: nSkills ? nSkills + " custom skill" + (nSkills > 1 ? "s" : "") + " discovered." : "No custom skills yet (optional).", + }); + + const groups = [ + { id: "buildRun", name: "Build & run", checks: buildRun }, + { id: "deploy", name: "Containerize & deploy to Azure (optional)", checks: deploy }, + { id: "flow", name: "Workflow readiness", checks: flow }, + ]; + + let overall = "ready"; + const all = groups.reduce((acc, grp) => acc.concat(grp.checks), []); + if (all.some((c) => c.status === "fail")) overall = "blocked"; + else if (all.some((c) => c.status === "warn")) overall = "caution"; + + return { overall, generatedAt: new Date().toISOString(), groups }; +} diff --git a/extensions/java-modernization-studio/extension.mjs b/extensions/java-modernization-studio/extension.mjs new file mode 100644 index 000000000..0f05774ce --- /dev/null +++ b/extensions/java-modernization-studio/extension.mjs @@ -0,0 +1,122 @@ +// extension.mjs — Java Modernization Studio (user-scoped canvas). Wiring only. +// +// Declares the canvas and bridges the SDK to the testable modules: +// scan.mjs repo + markdown parsing -> grounded state +// catalog.mjs Microsoft predefined task catalog +// prompts.mjs action -> crafted agent prompt +// server.mjs per-instance loopback HTTP server + action dispatch +// renderer.mjs cockpit UI +// +// Button clicks POST /action; agent actions are forwarded to session.send. When a +// turn finishes (session.idle) every open cockpit re-scans and pushes fresh state. + +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; +import { basename } from "node:path"; +import { scanRepo } from "./scan.mjs"; +import { createInstanceServer, pushState, resolveRepoPath } from "./server.mjs"; +import { AUTOPILOT_TURN_TIMEOUT_MS } from "./autopilot.mjs"; + +const instances = new Map(); // instanceId -> rec { server, url, repoPath, sseClients } +let sessionRef = null; +let lastWorkingDir = null; + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "appmod-cockpit", + displayName: "Java Modernization Studio", + description: + "Drive the GitHub Copilot App Modernization for Java workflow: assessment, plan/progress, validation gates, and task/skill runs grounded in the repo's real artifacts.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + repoPath: { type: "string", description: "Absolute path to the Java repo to inspect." }, + initialTab: { type: "string", enum: ["overview", "readiness", "assessment", "plan", "validation", "tasks", "summary"] }, + }, + }, + actions: [ + { + name: "get_state", + description: + "Return the current modernization state snapshot scanned from the repo (assessment, plan/progress, gates, skills, tasks).", + handler: async (ctx) => { + const rec = instances.get(ctx.instanceId); + const repoPath = rec ? rec.repoPath : resolveRepoPath(ctx, lastWorkingDir); + return await scanRepo(repoPath); + }, + }, + { + name: "refresh", + description: "Re-scan the repo and push a fresh snapshot to the open cockpit.", + handler: async (ctx) => { + const rec = instances.get(ctx.instanceId); + if (!rec) throw new CanvasError("not_open", "Cockpit instance is not open."); + await pushState(rec, logFn); + return { ok: true, repoPath: rec.repoPath }; + }, + }, + ], + open: async (ctx) => { + const repoPath = resolveRepoPath(ctx, lastWorkingDir); + if (repoPath) lastWorkingDir = repoPath; // never clobber a good target with null + const initialTab = ctx.input && ctx.input.initialTab ? ctx.input.initialTab : "overview"; + let rec = instances.get(ctx.instanceId); + if (!rec) { + rec = await createInstanceServer({ + instanceId: ctx.instanceId, + repoPath, + initialTab, + sendPrompt: (prompt) => sessionRef.send({ prompt }), + runTurn: (prompt) => sessionRef.sendAndWait({ prompt }, AUTOPILOT_TURN_TIMEOUT_MS), + log: logFn, + }); + instances.set(ctx.instanceId, rec); + } else if (rec.repoPath !== repoPath) { + rec.repoPath = repoPath; // refresh target on re-open + rec.doctor = null; // env readiness depends on repo facts — recompute for the new target + } + return { + title: "Java Modernization Studio", + status: repoPath ? basename(repoPath) : "no repo", + url: rec.url, + }; + }, + onClose: async (ctx) => { + const rec = instances.get(ctx.instanceId); + if (rec) { + instances.delete(ctx.instanceId); + for (const res of rec.sseClients) { + try { + res.end(); + } catch { + /* ignore */ + } + } + await new Promise((resolve) => rec.server.close(() => resolve())); + } + }, + }), + ], +}); + +sessionRef = session; +function logFn(message, opts) { + try { + return sessionRef.log(message, opts); + } catch { + /* logging is best-effort */ + } +} + +if (session.workspacePath && !lastWorkingDir) lastWorkingDir = session.workspacePath; + +// A finished turn may have changed plan.md/progress.md/summary.md or the working +// tree — re-scan and push fresh state to every open cockpit. +session.on("session.idle", async () => { + for (const rec of instances.values()) { + await pushState(rec, logFn); + } +}); + +await session.log("Java Modernization Studio ready", { ephemeral: true }); diff --git a/extensions/java-modernization-studio/package.json b/extensions/java-modernization-studio/package.json new file mode 100644 index 000000000..4924f0784 --- /dev/null +++ b/extensions/java-modernization-studio/package.json @@ -0,0 +1,23 @@ +{ + "name": "java-modernization-studio", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "author": { + "name": "Ayan Gupta", + "url": "https://github.com/ayangupt" + }, + "dependencies": { + "@github/copilot-sdk": "1.0.4" + }, + "description": "Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts.", + "keywords": [ + "java-modernization", + "app-modernization", + "azure-migration", + "legacy-java", + "assessment-dashboard", + "validation-gates", + "modernization-cockpit" + ] +} diff --git a/extensions/java-modernization-studio/prompts.mjs b/extensions/java-modernization-studio/prompts.mjs new file mode 100644 index 000000000..ffd5a4f55 --- /dev/null +++ b/extensions/java-modernization-studio/prompts.mjs @@ -0,0 +1,155 @@ +// prompts.mjs — Maps each cockpit button action to a crafted agent instruction. +// Pure (no I/O, no session) so it can be unit-tested in isolation. + +import { PREDEFINED_TASKS } from "./catalog.mjs"; + +export const APPMOD_PREAMBLE = + "You are running the GitHub Copilot App Modernization for Java workflow. " + + "Keep plan.md and progress.md up to date as a checklist (use - [ ] / - [x]) and, when the work is complete, write summary.md. " + + "Start each of these files (plan.md, progress.md, summary.md) with the exact line as the very first line so the cockpit recognizes them as modernization artifacts."; + +export const ACTION_LABELS = { + start_assessment: "Assessment", + generate_plan: "Generate plan", + run_task: "Run task", + run_skill: "Run skill", + run_cve: "CVE scan", + generate_tests: "Generate tests", + run_build_tests: "Build & tests", + run_consistency: "Consistency check", + run_completeness: "Completeness check", + create_skill: "Create skill", + open_pr: "Open PR", + fix_finding: "Resolve finding", + work_step: "Work on step", + fix_env: "Environment setup", +}; + +/** + * Build the agent prompt for a cockpit action. + * @returns {string|null} prompt text, or null for unknown actions / unknown task ids. + */ +export function buildPrompt(kind, payload, repoPath) { + const p = payload || {}; + switch (kind) { + case "start_assessment": + return ( + APPMOD_PREAMBLE + + "\n\nRun an App Modernization assessment of this Java project at " + repoPath + ". " + + "Report the current Java runtime, build tool, framework versions, outdated/vulnerable dependencies, " + + "and cloud-readiness issues for Azure. Summarize findings and write a prioritized plan.md of remediation steps. Do not change code yet." + + "\n\nAlso write the findings as structured JSON to .appmod/assessment.json so the cockpit can render them. Use this shape:\n" + + "{\n" + + ' "generatedAt": "",\n' + + ' "headline": "",\n' + + ' "summary": "<2-3 sentence plain-language verdict>",\n' + + ' "stack": { "buildTool": "", "java": "", "framework": "", "database": "", "container": "" },\n' + + ' "findings": [\n' + + ' { "id": "kebab-id", "severity": "P0|P1|P2|P3", "title": "", "detail": "", "files": ["path"],\n' + + ' "action": { "kind": "run_task|generate_plan|run_cve|generate_tests|fix_finding", "payload": {}, "label": "" } }\n' + + " ],\n" + + ' "strengths": [""]\n' + + "}\n" + + "Order findings by severity (P0 first). For action.kind, use run_task with payload {\"taskId\":\"...\"} when a Microsoft predefined task fits, " + + "generate_plan/run_cve/generate_tests when those fit, otherwise use fix_finding (the cockpit turns it into a 'Help me fix this' button). Omit action only if there is genuinely nothing to do." + ); + case "generate_plan": + return ( + APPMOD_PREAMBLE + + "\n\nCreate a step-by-step modernization plan to upgrade this project to Java " + (p.targetJava || 21) + ". " + + "Cover build-file changes, removed/replaced APIs, dependency upgrades, and required test updates. " + + "Write the plan as a checklist in plan.md and create an empty progress.md mirroring those steps. Do not change code yet." + ); + case "run_task": { + const t = PREDEFINED_TASKS.find((x) => x.id === p.taskId); + if (!t) return null; + return ( + APPMOD_PREAMBLE + + "\n\nApply the App Modernization predefined task \"" + t.name + "\": " + t.summary + "\n" + + "Steps: (1) record the plan in plan.md and progress.md; (2) check out a migration branch; " + + "(3) make the code changes for this task; (4) validate by building, running unit tests, and a CVE check; " + + "(5) write a summary.md of what changed. Pause for my confirmation before committing." + ); + } + case "run_skill": + if (!p.folder) return null; + return ( + APPMOD_PREAMBLE + + "\n\nRun the custom modernization skill in .github/skills/" + p.folder + "/SKILL.md. " + + "Follow its instructions and referenced resources exactly, updating plan.md and progress.md as you go, " + + "and write summary.md when finished." + ); + case "run_cve": + return "Check this Java project for known CVE issues using #appmod-validate-cves-for-java and report which dependencies need upgrading, then update progress.md."; + case "generate_tests": + return "Generate unit tests for this Java project using #appmod-generate-tests-for-java, then run them and record the result in progress.md."; + case "run_build_tests": + return "Build this project and run its unit tests. Report failures with root-cause analysis and update the Build and Unit Tests entries in progress.md."; + case "run_consistency": + return "Run a consistency check comparing the modernized code against the original behavior (APIs, SQL, wire format). List any behavioral differences and update progress.md."; + case "run_completeness": + return "Run a completeness check: verify every step in plan.md has been implemented. List anything missing and update progress.md."; + case "create_skill": + return ( + "Help me create a custom App Modernization skill. Create .github/skills//SKILL.md with Skill Name, " + + "Description, and Content sections following the Agent Skills specification, plus any resource files it references. Ask me what migration this skill should capture." + ); + case "open_pr": + return "Summarize the modernization changes on the current branch and open a pull request with a clear title and a body that lists the plan steps completed and validation results."; + case "work_step": { + const title = (p.title || "").trim(); + if (!title) return null; + const phase = p.section ? " (phase: " + p.section + ")" : ""; + return ( + APPMOD_PREAMBLE + + "\n\nWork on this single step from the modernization checklist" + phase + ":\n\"" + title + "\"\n\n" + + "Do just this step: tell me what you'll change, make the code/config changes, then check it off in progress.md " + + "(change its - [ ] to - [x]) and add a one-line note of what changed. If the step needs a decision from me or is " + + "ambiguous, ask before proceeding. Pause for my confirmation before committing." + ); + } + case "auto_step": { + const title = (p.title || "").trim(); + if (!title) return null; + const phase = p.section ? " (phase: " + p.section + ")" : ""; + return ( + APPMOD_PREAMBLE + + "\n\nYou are running in AUTOPILOT, working the modernization checklist on your own. " + + "Do exactly ONE step now" + phase + ":\n\"" + title + "\"\n\n" + + "For this single step: briefly state what you will change, make the necessary code/config changes, " + + "then check it off in progress.md (change its - [ ] to - [x]) and add a one-line note of what changed. " + + "Do NOT commit, and do NOT start any other step. " + + "If this step genuinely needs a decision from me, or is blocked or ambiguous, do NOT guess: leave it unchecked, " + + "add a short note in progress.md describing exactly what you need, and stop so I can take over." + ); + } + case "fix_finding": { + const title = (p.title || "").trim(); + if (!title) return null; + const detail = (p.detail || "").trim(); + const files = Array.isArray(p.files) && p.files.length ? "\nAffected files: " + p.files.join(", ") + "." : ""; + const sev = p.severity ? " (" + p.severity + ")" : ""; + return ( + APPMOD_PREAMBLE + + "\n\nHelp me resolve this App Modernization assessment finding" + sev + ":\n" + + title + (detail ? " — " + detail : "") + "." + files + + "\n\nExplain the fix, make the change, and update the matching checklist items in plan.md and progress.md. " + + "Pause for my confirmation before committing." + ); + } + case "fix_env": { + const tool = (p.tool || "").trim(); + if (!tool) return null; + const detail = p.detail ? "\nWhat the readiness check found: " + p.detail + "." : ""; + const fix = p.fix ? "\nSuggested fix: " + p.fix : ""; + return ( + "I'm preparing my local environment for the GitHub Copilot App Modernization for Java workflow.\n" + + "The readiness check flagged a missing or misconfigured tool: " + tool + "." + detail + fix + + "\n\nWalk me through installing and configuring " + tool + " on my machine, including the exact commands for my OS " + + "and how to verify it's on PATH at the right version for this project. This is environment setup only — do not modify my application code." + ); + } + default: + return null; + } +} diff --git a/extensions/java-modernization-studio/renderer.mjs b/extensions/java-modernization-studio/renderer.mjs new file mode 100644 index 000000000..56fd94e44 --- /dev/null +++ b/extensions/java-modernization-studio/renderer.mjs @@ -0,0 +1,1038 @@ +// renderer.mjs — Single-page cockpit UI served by each instance's loopback server. +// The client script is authored as a real function (clientMain) and serialized to +// a string at render time, so its own template literals / ${} are NOT evaluated by +// this module's template literal. Only CSS and a boot blob are interpolated. + +const CSS = ` +:root { color-scheme: light dark; } +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--background-color-default, #ffffff); + color: var(--text-color-default, #1f2328); + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); + font-size: var(--text-body-medium, 14px); + line-height: var(--leading-body-medium, 20px); +} +a { color: var(--true-color-blue, #0969da); } +.muted { color: var(--text-color-muted, #656d76); } +header.top { + position: sticky; top: 0; z-index: 5; + background: var(--background-color-default, #fff); + border-bottom: 1px solid var(--border-color-default, #d0d7de); + padding: 14px 18px 0; +} +.title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +h1 { + font-size: var(--text-title-medium, 18px); + font-weight: var(--font-weight-semibold, 600); + margin: 0; letter-spacing: .2px; +} +.spacer { flex: 1; } +.repo { font-family: var(--font-mono, monospace); font-size: 12px; } +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 12px; } +.chip { + display: inline-flex; align-items: center; gap: 5px; + border: 1px solid var(--border-color-default, #d0d7de); + border-radius: 999px; padding: 3px 10px; font-size: 12px; + background: var(--background-color-muted, #f6f8fa); +} +.chip b { font-weight: 600; } +.badge { border-radius: 999px; padding: 2px 9px; font-size: 11px; font-weight: 600; border: 1px solid transparent; white-space: nowrap; } +.b-green { color: #fff; background: var(--true-color-green, #1a7f37); } +.b-amber { color: #fff; background: var(--true-color-orange, #9a6700); } +.b-red { color: #fff; background: var(--true-color-red, #cf222e); } +.b-gray { color: var(--text-color-default,#1f2328); background: var(--background-color-muted,#eaeef2); border-color: var(--border-color-default,#d0d7de); } +.b-blue { color: #fff; background: var(--true-color-blue, #0969da); } +nav.tabs { display: flex; gap: 2px; margin-top: 6px; flex-wrap: wrap; } +nav.tabs button { + background: none; border: none; border-bottom: 2px solid transparent; + padding: 8px 12px; font-size: 13px; cursor: pointer; color: var(--text-color-muted,#656d76); + font-family: inherit; +} +nav.tabs button.active { color: var(--text-color-default,#1f2328); border-bottom-color: var(--true-color-blue,#0969da); font-weight: 600; } +nav.tabs button:hover { color: var(--text-color-default,#1f2328); border-bottom-color: var(--border-color-default,#d0d7de); } +nav.tabs button.active:hover { border-bottom-color: var(--true-color-blue,#0969da); } +nav.tabs button:focus-visible { outline: 2px solid var(--color-focus-outline, var(--true-color-blue,#0969da)); outline-offset: -2px; border-radius: 4px; } +nav.tabs button .count { font-size: 11px; color: var(--text-color-muted,#656d76); } +main { padding: 16px 18px 40px; max-width: 980px; } +.card { + border: 1px solid var(--border-color-default, #d0d7de); + border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; + background: var(--background-color-default, #fff); +} +.card h2 { font-size: 14px; margin: 0 0 10px; font-weight: 600; display: flex; align-items: center; gap: 8px; } +.card.relevant { border-color: var(--true-color-blue, #0969da); box-shadow: 0 0 0 1px var(--true-color-blue-muted, rgba(9,105,218,.25)); } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px,1fr)); gap: 10px; } +.kv { display: flex; flex-direction: column; gap: 2px; } +.kv .k { font-size: 11px; text-transform: uppercase; letter-spacing: .4px; color: var(--text-color-muted,#656d76); } +.kv .v { font-size: 14px; font-weight: 600; } +button.btn { + font-family: inherit; font-size: 12.5px; cursor: pointer; + border: 1px solid var(--border-color-default, #d0d7de); + background: var(--background-color-muted, #f6f8fa); + color: var(--text-color-default, #1f2328); + border-radius: 7px; padding: 6px 12px; +} +button.btn:not(.primary):not(:disabled):hover { + background: var(--background-color-muted, #eaeef2); + border-color: var(--border-color-muted, #afb8c1); + box-shadow: inset 0 0 0 999px rgba(140,149,159,.14); +} +button.btn.primary { background: var(--true-color-blue, #0969da); color: #fff; border-color: transparent; } +button.btn.primary:not(:disabled):hover { filter: brightness(1.08); box-shadow: 0 1px 2px rgba(31,35,40,.18); } +button.btn:focus-visible { outline: 2px solid var(--color-focus-outline, var(--true-color-blue, #0969da)); outline-offset: 1px; } +button.btn:disabled { opacity: .55; cursor: default; } +.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; } +.progress { height: 8px; border-radius: 999px; background: var(--background-color-muted,#eaeef2); overflow: hidden; } +.progress > span { display: block; height: 100%; background: var(--true-color-green,#1a7f37); } +ul.steps { list-style: none; margin: 0; padding: 0; } +ul.steps li { display: flex; align-items: flex-start; gap: 9px; padding: 6px 0; border-bottom: 1px solid var(--border-color-muted, #eaeef2); } +ul.steps li:last-child { border-bottom: none; } +.dot { width: 16px; height: 16px; border-radius: 50%; flex: 0 0 auto; margin-top: 2px; border: 2px solid; } +.dot.done { background: var(--true-color-green,#1a7f37); border-color: var(--true-color-green,#1a7f37); } +.dot.in_progress { background: var(--true-color-orange,#9a6700); border-color: var(--true-color-orange,#9a6700); } +.dot.failed { background: var(--true-color-red,#cf222e); border-color: var(--true-color-red,#cf222e); } +.dot.pending { background: transparent; border-color: var(--border-color-default,#8c959f); } +.task { display: flex; align-items: flex-start; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--border-color-muted,#eaeef2); } +.task:last-child { border-bottom: none; } +.task .body { flex: 1; } +.task .name { font-weight: 600; font-size: 13px; } +.task .sum { font-size: 12.5px; color: var(--text-color-muted,#656d76); margin-top: 2px; } +.cat { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-color-muted,#656d76); margin: 14px 0 4px; font-weight: 600; } +.empty { text-align: center; padding: 26px 16px; color: var(--text-color-muted,#656d76); } +.empty .big { font-size: 15px; color: var(--text-color-default,#1f2328); margin-bottom: 6px; font-weight: 600; } +.toast { + position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); + background: var(--true-color-blue,#0969da); color: #fff; padding: 9px 16px; + border-radius: 8px; font-size: 13px; opacity: 0; transition: opacity .2s; pointer-events: none; z-index: 20; + max-width: 90%; +} +.toast.show { opacity: 1; } +.banner { background: var(--true-color-blue-muted, #ddf4ff); border: 1px solid var(--true-color-blue,#0969da); color: var(--text-color-default,#1f2328); padding: 8px 12px; border-radius: 8px; font-size: 12.5px; margin-bottom: 12px; display: flex; gap: 8px; align-items: center; } +.md { font-size: 13px; line-height: 1.55; } +.md h1,.md h2,.md h3 { font-size: 15px; margin: 14px 0 6px; } +.md code { font-family: var(--font-mono,monospace); background: var(--background-color-muted,#f6f8fa); padding: 1px 4px; border-radius: 4px; font-size: 12px; } +.md ul { padding-left: 20px; } +.spin { width:13px; height:13px; border:2px solid currentColor; border-right-color:transparent; border-radius:50%; display:inline-block; animation: sp .7s linear infinite; } +@keyframes sp { to { transform: rotate(360deg); } } + +/* journey stepper */ +.stepper { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; } +.stepper .step { display: flex; align-items: center; gap: 7px; } +.stepper .ix { width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--border-color-default,#8c959f); color: var(--text-color-muted,#656d76); display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; flex: 0 0 auto; } +.stepper .lbl { font-size: 12.5px; color: var(--text-color-muted,#656d76); } +.stepper .step.done .ix { background: var(--true-color-green,#1a7f37); border-color: var(--true-color-green,#1a7f37); color: #fff; } +.stepper .step.done .lbl { color: var(--text-color-default,#1f2328); } +.stepper .step.current .ix { border-color: var(--true-color-blue,#0969da); color: var(--true-color-blue,#0969da); } +.stepper .step.current .lbl { color: var(--text-color-default,#1f2328); font-weight: 600; } +.stepper .bar { width: 24px; height: 2px; background: var(--border-color-default,#d0d7de); margin: 0 5px; flex: 0 0 auto; } +.stepper .bar.done { background: var(--true-color-green,#1a7f37); } + +/* hero next-step */ +.hero { border: 1px solid var(--true-color-blue,#0969da); background: var(--true-color-blue-muted,#ddf4ff); border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; } +.hero .eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; font-weight: 700; color: var(--true-color-blue,#0969da); } +.hero .htitle { font-size: 16px; font-weight: 700; margin: 3px 0 4px; } +.hero .hbody { font-size: 13px; color: var(--text-color-default,#1f2328); margin: 0 0 10px; } + +/* findings */ +.sevrow { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } +.finding { border: 1px solid var(--border-color-default,#d0d7de); border-left-width: 4px; border-radius: 8px; padding: 11px 13px; margin-bottom: 9px; } +.finding.sev-P0 { border-left-color: var(--true-color-red,#cf222e); } +.finding.sev-P1 { border-left-color: var(--true-color-orange,#9a6700); } +.finding.sev-P2 { border-left-color: var(--true-color-blue,#0969da); } +.finding.sev-P3 { border-left-color: var(--border-color-default,#8c959f); } +.finding .ftitle { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 8px; } +.finding .fdetail { font-size: 12.5px; color: var(--text-color-default,#1f2328); margin: 6px 0 0; } +.finding .ffiles { font-size: 11.5px; color: var(--text-color-muted,#656d76); margin-top: 7px; display: flex; flex-wrap: wrap; gap: 4px; } +.finding .ffiles code { font-family: var(--font-mono,monospace); background: var(--background-color-muted,#f6f8fa); padding: 1px 5px; border-radius: 4px; word-break: break-all; } + +/* plan & progress steps */ +.legend { display: flex; gap: 16px; margin-top: 11px; font-size: 11.5px; color: var(--text-color-muted,#656d76); } +.legend > span { display: inline-flex; align-items: center; } +.legend .dot { width: 12px; height: 12px; margin: 0 5px 0 0; } +ul.steps li { align-items: center; } +ul.steps li .stext { flex: 1; } +ul.steps li .srhs { flex: 0 0 auto; margin-left: 10px; } +.smuted { font-size: 11.5px; color: var(--text-color-muted,#656d76); white-space: nowrap; } +ul.steps li.locked .stext { opacity: .55; } +ul.steps li.locked .dot { opacity: .5; } +.card.locked { border-style: dashed; } +.card.locked > h2 { color: var(--text-color-muted,#656d76); } +.finding.locked { opacity: .72; } +.finding.locked .ftitle { opacity: .8; } +/* environment doctor / readiness checks */ +ul.checks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 9px; } +ul.checks li { display: flex; gap: 10px; align-items: flex-start; } +ul.checks .ci { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex: 0 0 auto; margin-top: 1px; } +.chk.ok .ci { background: rgba(26,127,55,.15); color: var(--true-color-green,#1a7f37); } +.chk.warn .ci { background: rgba(154,103,0,.18); color: #9a6700; } +.chk.fail .ci { background: rgba(207,34,46,.15); color: var(--true-color-red,#cf222e); } +.chk.info .ci { background: var(--background-color-muted,#eaeef2); color: var(--text-color-muted,#656d76); } +ul.checks .cbody { display: flex; flex-direction: column; gap: 1px; flex: 1 1 auto; min-width: 0; } +ul.checks .clabel { font-weight: 600; } +ul.checks .cdetail { font-size: 12px; color: var(--text-color-muted,#656d76); } +ul.checks .cfix { font-size: 12px; color: #9a6700; margin-top: 2px; } +ul.checks .crhs { flex: 0 0 auto; } +.chk.fail .clabel { color: var(--true-color-red,#cf222e); } +.banner-red { border-color: var(--true-color-red,#cf222e); background: rgba(207,34,46,.08); } +nav.tabs button .count.c-red { color: var(--true-color-red,#cf222e); font-weight: 700; } +nav.tabs button .count.c-amber { color: #9a6700; font-weight: 700; } +.lockwhy { margin-right: 8px; } +/* recommended-approach guide */ +.card.guide { background: var(--background-color-muted, #f6f8fa); } +.flow { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; } +.flow .fstep { font-size: 12px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--border-color-default,#d0d7de); background: var(--background-color-default,#fff); color: var(--text-color-muted,#656d76); } +.flow .fstep.on { border-color: var(--true-color-blue,#0969da); color: var(--true-color-blue,#0969da); font-weight: 600; } +.flow .fstep.past { color: var(--true-color-green,#1a7f37); border-color: var(--true-color-green,#1a7f37); } +.flow .farrow { color: var(--text-color-muted,#8c959f); font-size: 12px; } +/* autopilot */ +.banner-auto { border-color: var(--true-color-blue,#0969da); background: var(--true-color-blue-muted,#ddf4ff); align-items: center; } +.card.autopilot { border-color: var(--true-color-blue,#0969da); } +.badge.b-blue { background: var(--true-color-blue-muted,#ddf4ff); color: var(--true-color-blue,#0969da); } +.apc { font-size: 11.5px; color: var(--text-color-muted,#656d76); margin-left: 6px; } +ul.aplog { list-style: none; margin: 8px 0 12px; padding: 8px 12px; border: 1px solid var(--border-color-default,#d0d7de); border-radius: 8px; background: var(--background-color-muted,#f6f8fa); display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; } +ul.aplog li { display: block; } +.apok { color: var(--true-color-green,#1a7f37); font-weight: 700; } +.apno { color: var(--true-color-red,#cf222e); font-weight: 700; } +`; + +function clientMain() { + const boot = window.__APPMOD__ || {}; + // The host embeds a per-instance token in the iframe URL; echo it back on every + // request so the loopback server accepts us. Empty in unit harnesses (no guard). + const apiToken = boot.token || ""; + const tokenQuery = apiToken ? "?token=" + encodeURIComponent(apiToken) : ""; + let state = null; + let activeTab = boot.initialTab || "overview"; + let pending = null; + let autopilotRunning = false; + + const $ = (sel) => document.querySelector(sel); + const esc = (s) => + String(s == null ? "" : s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) + ); + + function toast(msg) { + const t = $("#toast"); + t.textContent = msg; + t.classList.add("show"); + clearTimeout(toast._t); + toast._t = setTimeout(() => t.classList.remove("show"), 2600); + } + + function statusBadge(status) { + const map = { + completed: ["b-green", "Completed"], + in_progress: ["b-amber", "In progress"], + not_started: ["b-gray", "Not started"], + done: ["b-green", "Done"], + pending: ["b-gray", "Pending"], + failed: ["b-red", "Failed"], + not_run: ["b-gray", "Not run"], + pass: ["b-green", "Pass"], + }; + const [cls, label] = map[status] || ["b-gray", status || "—"]; + return '' + esc(label) + ""; + } + + function btn(label, kind, payload, opts) { + opts = opts || {}; + const cls = "btn" + (opts.primary ? " primary" : ""); + // While Autopilot runs, freeze manual actions so a stray click can't inject + // a competing prompt into the same session — except Stop and tab navigation. + const frozen = autopilotRunning && kind !== "autopilot_stop" && kind !== "autopilot_dismiss" && String(kind).indexOf("goto:") !== 0; + const dis = pending || frozen ? " disabled" : ""; + return ( + '" + ); + } + + // ---- tab renderers ------------------------------------------------------- + + function overviewTab(s) { + let html = ""; + + // Environment readiness nudge — surfaces a blocker before the user starts. + const d = s.doctor; + if (d && d.overall === "blocked") { + html += '"; + } else if (d && d.overall === "caution") { + html += '"; + } + + // Journey stepper — always shows where you are in the workflow. + html += '

Your modernization journey

' + journeyHtml(s) + "
"; + + // One adaptive "do this next" hero so the next click is never ambiguous. + const hero = heroNext(s); + html += + '
' + esc(hero.eyebrow) + "
" + + '
' + esc(hero.title) + "
" + + '
' + hero.body + "
" + + '
' + hero.actions + "
"; + + // Autopilot nudge — once there's a checklist with work left, offer to run it + // hands-free. Hidden while a run is already active (the strip covers that). + const planSteps = (s.progress && s.progress.steps.length ? s.progress.steps : (s.plan ? s.plan.steps : [])) || []; + const pendingSteps = planSteps.filter((x) => x.status !== "done").length; + const envBlocked = d && d.overall === "blocked"; + if (pendingSteps > 0 && !(s.autopilot && s.autopilot.running)) { + const cta = envBlocked + ? btn("Open Readiness →", "goto:readiness") + : btn("▶ Run on autopilot", "autopilot_start", { scope: "phase" }, { primary: true }) + btn("See in Plan →", "goto:plan"); + html += + '

⚡ Run on autopilot beta

' + + '

Instead of clicking each step, let Copilot work the ' + pendingSteps + + " remaining step(s) in order and update this dashboard live. It pauses at the end of the phase and does not commit.

" + + '
' + cta + "
"; + } + + // Findings snapshot (only once an assessment has been run). + const rep = s.report; + if (rep && rep.findings && rep.findings.length) { + const by = sevCounts(rep.findings); + html += '

Assessment findings ' + btn("Open Assessment →", "goto:assessment") + "

"; + if (rep.summary) html += '

' + esc(rep.summary) + "

"; + html += '
' + sevChip("P0", by.P0) + sevChip("P1", by.P1) + sevChip("P2", by.P2) + sevChip("P3", by.P3) + "
"; + } + + // Stack facts. + const a = s.assessment || {}; + const jv = a.javaVersion ? "Java " + a.javaVersion : "Java (unknown)"; + html += + '

Stack

' + + kv("Build tool", a.buildTool || "—") + + kv("Java version", esc(jv)) + + kv("Spring Boot", a.springBoot ? "Yes" + (a.springVersion ? " " + esc(a.springVersion) : "") : "No") + + kv("Container", a.hasDockerfile ? "Dockerfile ✓" : "None") + + kv("Git branch", s.git && s.git.branch ? esc(s.git.branch) : "—") + + kv("Working tree", s.git ? (s.git.dirty ? s.git.changedFiles + " changed" : "clean") : "—") + + "
"; + + const rel = (s.tasks || []).filter((t) => t.relevant); + if (rel.length) { + html += '

Detected in this repo ' + rel.length + "

"; + rel.forEach((t) => (html += taskRow(t))); + html += "
"; + } + return html; + } + + function assessmentTab(s) { + const rep = s.report; + if (!rep || !(rep.findings && rep.findings.length)) { + return emptyState( + "No assessment yet", + "Run an assessment to scan this repo for its Java runtime, dependencies, vulnerabilities, and Azure cloud-readiness gaps. Findings land here as a prioritized, clickable checklist — nothing in your code changes.", + btn("Run assessment", "start_assessment", {}, { primary: true }) + ); + } + const by = sevCounts(rep.findings); + let html = '

Assessment results'; + if (rep.generatedAt) html += ' ' + esc(fmtDate(rep.generatedAt)) + ""; + html += '' + btn("Re-run", "start_assessment") + "

"; + if (rep.headline) html += '

' + esc(rep.headline) + "

"; + if (rep.summary) html += '

' + esc(rep.summary) + "

"; + if (rep.stack) { + const st = rep.stack; + html += '
' + + (st.buildTool ? kv("Build", esc(st.buildTool)) : "") + + (st.java ? kv("Java", esc(st.java)) : "") + + (st.framework ? kv("Framework", esc(st.framework)) : "") + + (st.database ? kv("Database", esc(st.database)) : "") + + (st.container ? kv("Container", esc(st.container)) : "") + + "
"; + } + html += '
' + sevChip("P0", by.P0) + sevChip("P1", by.P1) + sevChip("P2", by.P2) + sevChip("P3", by.P3) + "
"; + + ["P0", "P1", "P2", "P3"].forEach((sev) => { + const items = rep.findings.filter((x) => x.severity === sev); + if (!items.length) return; + html += '
' + esc(sevName(sev)) + "
"; + items.forEach((x) => (html += findingCard(x, s.ordering))); + }); + + if (rep.strengths && rep.strengths.length) { + html += '

Already done well ' + rep.strengths.length + "

    "; + rep.strengths.forEach((x) => (html += '
  • ' + esc(x) + "
  • ")); + html += "
"; + } + return html; + } + + // ---- assessment helpers -------------------------------------------------- + + function journeyHtml(s) { + const labels = ["Assess", "Remediate", "Validate", "Ship"]; + const hasPlan = !!((s.report && s.report.findings) || (s.plan && s.plan.exists) || (s.progress && s.progress.exists)); + const gates = s.gates || {}; + const anyGate = Object.keys(gates).some((k) => gates[k] && gates[k] !== "not_run"); + const done = s.status === "completed"; + let cur; + if (done) cur = 4; + else if (anyGate) cur = 2; + else if (hasPlan) cur = 1; + else cur = 0; + let h = '
'; + labels.forEach((label, i) => { + if (i > 0) h += ''; + const cls = i < cur ? "done" : i === cur ? "current" : ""; + const mark = i < cur ? "✓" : String(i + 1); + h += '
' + mark + '' + esc(label) + "
"; + }); + return h + "
"; + } + + function heroNext(s) { + const rep = s.report; + const findings = rep && rep.findings ? rep.findings : []; + const hasReport = findings.length > 0; + const p0 = findings.filter((f) => f.severity === "P0"); + const p1 = findings.filter((f) => f.severity === "P1"); + const gates = s.gates || {}; + const anyGate = Object.keys(gates).some((k) => gates[k] && gates[k] !== "not_run"); + const hasPlan = !!((s.plan && s.plan.exists) || (s.progress && s.progress.exists)); + + if (s.status === "completed") { + return { + eyebrow: "Final step", + title: "Ship your changes", + body: "Validations are complete. Open a pull request to hand off the modernized service.", + actions: btn("Open a pull request", "open_pr", {}, { primary: true }) + btn("View summary", "goto:summary"), + }; + } + if (!hasReport && !hasPlan) { + return { + eyebrow: "Start here", + title: "Assess the project", + body: "Scan the repo for its Java runtime, dependencies, known CVEs, and Azure cloud-readiness gaps. Nothing changes in your code — you get a prioritized list of findings to work through.", + actions: btn("Run assessment", "start_assessment", {}, { primary: true }), + }; + } + if (p0.length) { + return { + eyebrow: "Blocker — fix this first", + title: p0[0].title, + body: esc(p0[0].detail || "A P0 issue is blocking modernization. Resolve it before moving on."), + actions: heroAction(p0[0]) + btn("See all findings", "goto:assessment"), + }; + } + if (hasReport && !anyGate) { + if (p1.length) { + return { + eyebrow: "Recommended next", + title: "Start remediation: " + p1[0].title, + body: esc(p1[0].detail || "Work through the high-priority findings first."), + actions: heroAction(p1[0]) + btn("See all findings", "goto:assessment"), + }; + } + return { + eyebrow: "Recommended next", + title: "Work through the findings", + body: "Open the Assessment tab and resolve findings by priority. Each one has a button that hands the fix to the agent.", + actions: btn("Open Assessment", "goto:assessment", {}, { primary: true }), + }; + } + return { + eyebrow: "Recommended next", + title: "Validate your changes", + body: "Build the project, run unit tests, and re-check CVEs to confirm the migration holds.", + actions: btn("Build & run tests", "run_build_tests", {}, { primary: true }) + btn("Open Validation", "goto:validation"), + }; + } + + function heroAction(f) { + const act = f.action; + if (act && act.kind && act.kind !== "fix_finding") { + return btn(act.label || "Run this step", act.kind, act.payload || {}, { primary: true }); + } + return btn((act && act.label) || "Help me fix this", "fix_finding", findingCtx(f), { primary: true }); + } + + function findingCard(x, ord) { + const locked = isFindingLocked(x, ord); + let h = '
'; + h += '
' + esc(x.severity || "—") + "" + esc(x.title) + "
"; + if (x.detail) h += '
' + esc(x.detail) + "
"; + if (x.files && x.files.length) { + h += '
' + x.files.map((p) => "" + esc(p) + "").join("") + "
"; + } + h += '
' + findingActions(x, ord) + "
"; + return h + "
"; + } + + function sevRank(sev) { + const m = { P0: 1, P1: 2, P2: 3, P3: 4 }; + return m[sev] != null ? m[sev] : null; + } + function isFindingLocked(x, ord) { + if (!ord || ord.activeRank == null) return false; + const r = sevRank(x.severity); + return r != null && r > ord.activeRank; + } + + function findingActions(x, ord) { + const act = x.action; + if (isFindingLocked(x, ord)) { + const why = '🔒 fix ' + esc(shortPhase(ord.activePhase)) + " issues first"; + if (act && act.kind && act.kind !== "fix_finding") { + return why + btn(act.label || "Run this step", act.kind, act.payload || {}) + btn("Help me fix this", "fix_finding", findingCtx(x)); + } + return why + btn((act && act.label) || "Help me fix this", "fix_finding", findingCtx(x)); + } + if (act && act.kind && act.kind !== "fix_finding") { + return btn(act.label || "Run this step", act.kind, act.payload || {}, { primary: true }) + btn("Help me fix this", "fix_finding", findingCtx(x)); + } + return btn((act && act.label) || "Help me fix this", "fix_finding", findingCtx(x), { primary: true }); + } + + function findingCtx(x) { + return { title: x.title, detail: x.detail, files: x.files, severity: x.severity }; + } + function sevCounts(f) { + const c = { P0: 0, P1: 0, P2: 0, P3: 0 }; + (f || []).forEach((x) => { if (c[x.severity] != null) c[x.severity]++; }); + return c; + } + function sevBadgeClass(sev) { + return { P0: "b-red", P1: "b-amber", P2: "b-blue", P3: "b-gray" }[sev] || "b-gray"; + } + function sevChip(sev, n) { + return '' + esc(sev) + " · " + (n || 0) + ""; + } + function sevName(sev) { + return { P0: "P0 · Blockers", P1: "P1 · High priority", P2: "P2 · Medium priority", P3: "P3 · Low / platform" }[sev] || sev; + } + function fmtDate(iso) { + try { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " " + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + } catch (e) { + return iso; + } + } + + // ---- autopilot ----------------------------------------------------------- + + // Live status strip for an Autopilot run, shown above the active tab body. + function autopilotStrip(s) { + const a = s && s.autopilot; + if (!a) return ""; + if (a.running) { + const scopeLabel = a.scope === "all" ? "all phases" : "this phase"; + const now = a.current + ? "Working: " + esc(a.current.title) + (a.current.section ? ' ' + esc(a.current.section) + "" : "") + : "Selecting the next step…"; + return ( + '" + autopilotLog(a) + ); + } + const labels = { + completed: "finished — every eligible step is done", + phase_done: "finished this phase", + stuck: "paused — a step needs your input", + cancelled: "stopped", + capped: "reached its step limit", + error: "stopped on an error", + }; + const label = labels[a.status] || "finished"; + const bad = a.status === "error" || a.status === "stuck"; + let note = "Review the changes (nothing was committed), then continue."; + if (a.stuck) note = "Stuck on: " + esc(a.stuck) + ". Open it in Plan & Progress to finish it yourself, then resume."; + if (a.error) note = esc(a.error); + const more = + btn("▶ Continue (next phase)", "autopilot_start", { scope: "phase" }, { primary: true }) + + btn("Dismiss", "autopilot_dismiss", {}); + return ( + '" + autopilotLog(a) + ); + } + + function autopilotLog(a) { + if (!a || !a.completed || !a.completed.length) return ""; + let h = '
    '; + a.completed.forEach((c) => { + h += + "
  • " + (c.done ? '' : '') + + " " + esc(c.title) + + (c.section ? ' ' + esc(c.section) + "" : "") + + (c.error ? ' ' + esc(c.error) + "" : "") + + "
  • "; + }); + return h + "
"; + } + + // Autopilot launch card on the Plan tab. + function autopilotCard(s) { + const running = !!(s.autopilot && s.autopilot.running); + const blocked = s.doctor && s.doctor.overall === "blocked"; + let body = + '

Let Copilot work the checklist for you. Autopilot runs each eligible step in order, ' + + "updates progress.md, and streams results here live. It pauses at the end of the phase, stops if a step needs a decision, " + + "and does not commit — so you stay in control and can review the diff.

"; + let actions; + if (running) { + actions = btn("■ Stop autopilot", "autopilot_stop", {}); + } else if (blocked) { + actions = 'Environment is not ready. ' + btn("Open Readiness →", "goto:readiness"); + } else { + actions = + btn("▶ Run this phase", "autopilot_start", { scope: "phase" }, { primary: true }) + + btn("Run all phases", "autopilot_start", { scope: "all" }); + } + return '

⚡ Autopilot beta

' + body + '
' + actions + "
"; + } + + function planTab(s) { + const usingProgress = !!(s.progress && s.progress.steps.length); + const steps = (usingProgress ? s.progress.steps : (s.plan ? s.plan.steps : [])) || []; + if (!steps.length) { + return emptyState( + "No plan yet", + "Run an assessment or generate an upgrade plan. App Modernization writes plan.md and a progress.md checklist, and your steps land here — each unchecked one becomes a button that hands that exact step to the agent.", + btn("Run assessment", "start_assessment", {}, { primary: true }) + btn("Generate upgrade plan", "generate_plan", { targetJava: 21 }) + ); + } + const src = usingProgress ? "progress.md" : "plan.md"; + const total = steps.length; + const done = steps.filter((x) => x.status === "done").length; + const ord = s.ordering || { activeRank: null, activePhase: null }; + + // Intro + overall progress + legend. + let html = '

Plan & Progress ' + esc(src) + "" + statusBadge(s.status) + "

"; + html += '

Your live modernization checklist. Each unchecked step has a Work on this button that hands just that step to the agent in this session — it checks the box here when the step is done.

'; + html += '
'; + html += '

' + done + " of " + total + " steps done · " + (s.percent || 0) + "% complete

"; + html += '
DoneIn progressTo do🔒 Do later
'; + html += "
"; + + // Recommended-order guide so users don't work steps out of sequence. + html += approachGuide(ord); + + // Autopilot: hand the whole phase to the agent instead of clicking each step. + html += autopilotCard(s); + + // "Continue here" — the recommended next step: first not-done step in the + // active phase (falls back to the first not-done step overall). + const next = steps.find((x) => x.status !== "done" && (ord.activeRank == null || x.rank === ord.activeRank)) || steps.find((x) => x.status !== "done"); + if (next) { + html += + '
Continue here · recommended next step
' + + '
' + esc(next.title) + "
" + + (next.section ? '
Phase: ' + esc(next.section) + "
" : "") + + '
' + stepAction(next) + (next.section && /validation|gates/i.test(next.section) ? btn("Open Validation", "goto:validation") : "") + "
"; + } else { + html += ''; + } + + // Steps grouped by their phase/section. + const groups = []; + const idx = {}; + steps.forEach((st) => { + const key = st.section || "Steps"; + if (idx[key] == null) { idx[key] = groups.length; groups.push({ name: key, items: [], rank: st.rank }); } + groups[idx[key]].items.push(st); + }); + + groups.forEach((g) => { + const gdone = g.items.filter((x) => x.status === "done").length; + const isValidation = /validation|gates/i.test(g.name); + const gLocked = ord.activeRank != null && g.rank != null && g.rank > ord.activeRank; + html += '

' + (gLocked ? "🔒 " : "") + esc(g.name) + ' ' + gdone + "/" + g.items.length + ""; + if (isValidation) html += '' + btn("Open Validation →", "goto:validation"); + else if (gLocked) html += 'after ' + esc(shortPhase(ord.activePhase)) + ""; + html += '

    '; + g.items.forEach((st) => { + const locked = ord.activeRank != null && st.rank != null && st.rank > ord.activeRank; + html += + '' + + '' + esc(st.title) + "" + + '' + stepRowAction(st, isValidation, locked, ord) + ""; + }); + html += "
"; + }); + + return html; + } + + function approachGuide(ord) { + const order = ["Assessment", "P0 Build", "P1 Security", "P2 Runtime", "P3 Observability", "Validate"]; + const activeIdx = ord && ord.activeRank != null ? ord.activeRank : -1; + const flow = order + .map((name, i) => '' + esc(name) + "") + .join(''); + let body = "Work top-down and finish each phase before the next — e.g. don't upgrade the Java runtime (P2) while the build is still broken (P0). Steps in later phases are marked 🔒 until the current phase is done; Continue here always points to the safe next step."; + if (ord && ord.activePhase) body += " You're in " + esc(ord.activePhase) + "."; + return '

Recommended approach

' + flow + "

" + body + "

"; + } + function shortPhase(name) { + if (!name) return "the current phase"; + const m = String(name).match(/\bP[0-3]\b/i); + return m ? m[0].toUpperCase() : name; + } + + function stepAction(st) { + const label = st.status === "in_progress" ? "Resume this step" : "Work on this step"; + return btn(label, "work_step", { title: st.title, section: st.section }, { primary: true }); + } + function stepRowAction(st, isValidation, locked, ord) { + if (st.status === "done") return '✓ Done'; + if (isValidation) return 'run from Validation →'; + if (locked) { + return '🔒 after ' + esc(shortPhase(ord && ord.activePhase)) + "" + btn("Do anyway", "work_step", { title: st.title, section: st.section }); + } + const label = st.status === "in_progress" ? "Resume" : "Work on this"; + return btn(label, "work_step", { title: st.title, section: st.section }); + } + + function validationTab(s) { + const gates = s.gates || {}; + const labels = { build: "Build", tests: "Unit Tests", cve: "CVE Check", consistency: "Consistency", completeness: "Completeness" }; + let html = '

Validation gates

'; + Object.keys(labels).forEach((k) => { + html += '
' + esc(labels[k]) + "" + statusBadge(gates[k] || "not_run") + "
"; + }); + html += "
"; + html += + '

Run validations

' + + btn("Build & unit tests", "run_build_tests", {}, { primary: true }) + + btn("Scan CVEs", "run_cve") + + btn("Generate unit tests", "generate_tests") + + btn("Consistency check", "run_consistency") + + btn("Completeness check", "run_completeness") + + "

CVE scan uses #appmod-validate-cves-for-java; test generation uses #appmod-generate-tests-for-java.

"; + return html; + } + + function doctorTab(s) { + const d = s.doctor; + if (!d) { + return emptyState( + "Environment readiness", + "Check that your machine has the tools the App Modernization workflow needs — a JDK, your build tool, git, and (optionally) Docker and the Azure CLI. This only reads version numbers; nothing is installed or changed.", + btn("Check my environment", "recheck_env", {}, { primary: true }) + ); + } + const tone = d.overall === "ready" ? "b-green" : d.overall === "caution" ? "b-amber" : "b-red"; + const head = + d.overall === "ready" ? "Your environment is ready" : + d.overall === "caution" ? "Almost ready — a couple of things to check" : + "Not ready yet — resolve the blockers below"; + const sub = + d.overall === "ready" ? "All required tools are installed. You're clear to start modernizing." : + d.overall === "caution" ? "The required tools are present; some optional or recommended items need attention." : + "One or more required tools are missing. Fix these before building the project or running tasks."; + + const notAssessed = d.groups.some((g) => g.checks.some((c) => c.id === "assessed" && c.status !== "ok")); + let heroActions = btn("Re-check environment", "recheck_env", {}, { primary: true }); + if (notAssessed) heroActions += btn("Run assessment", "start_assessment", {}); + + let html = + '
Environment readiness
' + + '
' + esc(head) + ' ' + esc(d.overall) + "
" + + '
' + esc(sub) + "
" + + '
' + heroActions + "
"; + + d.groups.forEach((g) => { + html += '

' + esc(g.name) + "

    "; + g.checks.forEach((c) => { + html += + '
  • ' + + '' + checkIcon(c.status) + "" + + '' + esc(c.label) + "" + + '' + esc(c.detail || "") + "" + + (c.fix ? 'Fix: ' + esc(c.fix) + "" : "") + + "" + + '' + checkAction(c) + "
  • "; + }); + html += "
"; + }); + + html += '

Probed locally on your machine — version numbers only. Nothing was installed or modified.

'; + return html; + } + + function checkIcon(status) { + if (status === "ok") return "✓"; + if (status === "warn") return "!"; + if (status === "fail") return "✕"; + return "•"; + } + function checkAction(c) { + if (!c.action) return ""; + const opts = c.status === "fail" ? { primary: true } : {}; + return btn(c.action.label || "Fix", c.action.kind, c.action.payload || {}, opts); + } + + function tasksTab(s) { + let html = ""; + // Custom skills + html += '

Custom skills .github/skills

'; + if (!s.skills || !s.skills.length) { + html += '

No custom skills found. Capture an org-specific migration as a reusable skill.

' + + '
' + btn("Create a custom skill", "create_skill") + "
"; + } else { + s.skills.forEach((sk) => { + html += + '
' + esc(sk.name) + "
" + + '
' + esc(sk.description || sk.folder) + "
" + + '
' + btn("Run", "run_skill", { folder: sk.folder }, { primary: true }) + "
"; + }); + } + html += "
"; + + // Predefined catalog grouped by category, relevant first + const tasks = (s.tasks || []).slice().sort((a, b) => (b.relevant ? 1 : 0) - (a.relevant ? 1 : 0)); + const cats = {}; + tasks.forEach((t) => { + (cats[t.category] = cats[t.category] || []).push(t); + }); + html += '

Microsoft predefined tasks

'; + Object.keys(cats).forEach((cat) => { + html += '
' + esc(cat) + "
"; + cats[cat].forEach((t) => (html += taskRow(t))); + }); + html += "
"; + return html; + } + + function summaryTab(s) { + let html = ""; + if (s.summary && s.summary.exists && s.summary.markdown) { + html += '

summary.md

' + miniMd(s.summary.markdown) + "
"; + } else { + html += emptyState("No summary yet", "App Modernization writes summary.md after validations pass.", ""); + } + const g = s.git || {}; + html += + '

Branch & PR

' + + kv("Branch", g.branch ? esc(g.branch) : "—") + + kv("Migration branch", g.isMigrationBranch ? "Yes" : "No") + + kv("Changed files", g.changedFiles != null ? String(g.changedFiles) : "—") + + "
" + + btn("Open a pull request", "open_pr", {}, { primary: true }) + + btn("Refresh", "refresh") + + "
"; + return html; + } + + // ---- helpers ------------------------------------------------------------- + + function nextTarget(v) { + const ladder = [11, 17, 21, 25]; + for (const t of ladder) if (t > v) return t; + return 25; + } + function kv(k, v) { + return '
' + esc(k) + '' + v + "
"; + } + function taskRow(t) { + return ( + '
' + esc(t.name) + + (t.relevant ? ' relevant' : "") + + '
' + esc(t.summary) + "
" + + "
" + btn("Run", "run_task", { taskId: t.id }) + "
" + ); + } + function emptyState(big, sub, action) { + return '
' + esc(big) + "
" + sub + "
" + (action ? '
' + action + "
" : "") + "
"; + } + function miniMd(md) { + const lines = esc(md).split(/\r?\n/); + let out = ""; + let inList = false; + for (let line of lines) { + if (/^\s*[-*]\s+/.test(line)) { + if (!inList) { out += "
    "; inList = true; } + out += "
  • " + line.replace(/^\s*[-*]\s+/, "") + "
  • "; + continue; + } + if (inList) { out += "
"; inList = false; } + const h = line.match(/^(#{1,3})\s+(.*)/); + if (h) { out += "" + h[2] + ""; continue; } + if (line.trim() === "") { out += "
"; continue; } + out += "

" + line + "

"; + } + if (inList) out += ""; + return out.replace(/`([^`]+)`/g, "$1"); + } + + // ---- shell --------------------------------------------------------------- + + function render() { + if (!state) { + $("#app").innerHTML = '
Loading…
'; + return; + } + if (!state.ok) { + $("#app").innerHTML = emptyState("Repo not available", esc(state.error || "Could not read the repository."), btn("Retry", "refresh", {}, { primary: true })); + renderHeader(); + return; + } + renderHeader(); + autopilotRunning = !!(state.autopilot && state.autopilot.running); + const tabs = { overview: overviewTab, readiness: doctorTab, assessment: assessmentTab, plan: planTab, validation: validationTab, tasks: tasksTab, summary: summaryTab }; + let body = ""; + try { + body = autopilotStrip(state); + body += pending ? '" : ""; + body += (tabs[activeTab] || overviewTab)(state); + } catch (e) { + // A tab renderer threw — show the error instead of silently leaving the + // previous tab's content on screen (which looks like a dead click). + body = '"; + } + $("#app").innerHTML = body; + } + + function renderHeader() { + const s = state || {}; + const a = s.assessment || {}; + $("#status").innerHTML = s.status ? statusBadge(s.status) : ""; + $("#repo").textContent = s.repoPath ? s.repoPath.split(/[\\/]/).slice(-2).join("/") : ""; + const chips = $("#chips"); + if (!s.ok) { chips.innerHTML = ""; } + else { + chips.innerHTML = [ + a.buildTool ? '' + esc(a.buildTool) + "" : "", + a.javaVersion ? 'Java ' + esc(a.javaVersion) + "" : "", + a.springBoot ? 'Spring Boot' : "", + a.hasDockerfile ? 'Docker' : "", + s.git && s.git.branch ? '' + esc(s.git.branch) + "" : "", + ].join(""); + } + const counts = { + assessment: s.report && s.report.findings ? s.report.findings.length : 0, + plan: s.progress && s.progress.steps.length ? s.progress.steps.length : (s.plan ? s.plan.steps.length : 0), + tasks: (s.tasks ? s.tasks.length : 0) + (s.skills ? s.skills.length : 0), + }; + document.querySelectorAll("nav.tabs button").forEach((b) => { + b.classList.toggle("active", b.dataset.tab === activeTab); + const c = b.querySelector(".count"); + if (!c) return; + if (b.dataset.tab === "readiness") { + const ov = s.doctor && s.doctor.overall; + c.textContent = ov === "blocked" ? "⚠" : ov === "caution" ? "!" : ""; + c.className = "count " + (ov === "blocked" ? "c-red" : ov === "caution" ? "c-amber" : ""); + } else { + c.textContent = counts[b.dataset.tab] ? "(" + counts[b.dataset.tab] + ")" : ""; + } + }); + } + + async function loadState() { + try { + const r = await fetch("/state" + tokenQuery); + state = await r.json(); + render(); + } catch (e) { + toast("Failed to load state"); + } + } + + async function doAction(kind, payload) { + if (kind === "refresh") { toast("Refreshing…"); await loadState(); return; } + if (kind.indexOf("goto:") === 0) { activeTab = kind.slice(5); render(); window.scrollTo(0, 0); return; } + // Autopilot controls stream their results over SSE, so they must not set the + // blocking "pending" banner that freezes the whole view for a one-shot send. + const isAutopilot = kind.indexOf("autopilot_") === 0; + try { + const r = await fetch("/action" + tokenQuery, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind, payload }), + }); + const res = await r.json(); + if (res.ok) { + if (res.state) { + // Local, synchronous actions (refresh, recheck_env) hand back a + // fresh snapshot inline. Apply it now and clear any spinner instead + // of waiting on an SSE broadcast that can be missed if the provider + // restarts or the connection drops — which would spin forever. + state = res.state; + pending = null; + } else if (!isAutopilot) { + pending = res.label || kind; + } + toast(res.message || "Sent to the agent"); + render(); + } else { + toast(res.error || "Action failed"); + } + } catch (e) { + toast("Action failed"); + } + } + + // event delegation for all action buttons + tabs + document.addEventListener("click", (e) => { + const tabBtn = e.target.closest("nav.tabs button"); + if (tabBtn) { activeTab = tabBtn.dataset.tab; render(); return; } + const b = e.target.closest("[data-kind]"); + if (b && !b.disabled) { + let payload = {}; + try { payload = JSON.parse(b.dataset.payload || "{}"); } catch {} + doAction(b.dataset.kind, payload); + } + }); + + // live updates: server pushes a fresh snapshot when the agent finishes a turn + try { + const ev = new EventSource("/events" + tokenQuery); + // Resync whenever the stream (re)connects. If the provider restarted while an + // action was in flight, its "done" broadcast was lost; re-fetching state here + // clears any stuck spinner and shows the real result. + ev.onopen = () => { loadState(); }; + ev.onmessage = (m) => { + try { + const data = JSON.parse(m.data); + if (data && data.type === "state") { state = data.state; pending = null; render(); } + else if (data && data.type === "autopilot" && state) { state.autopilot = data.autopilot; render(); } + } catch {} + }; + } catch {} + + render(); + loadState(); +} + +export function renderHtml({ instanceId, initialTab, token } = {}) { + // Escape `<` so a stray "" in any field can't break out of the inline + // " + + "" + ); +} diff --git a/extensions/java-modernization-studio/scan.mjs b/extensions/java-modernization-studio/scan.mjs new file mode 100644 index 000000000..60f8b1a44 --- /dev/null +++ b/extensions/java-modernization-studio/scan.mjs @@ -0,0 +1,571 @@ +// scan.mjs — Grounds the cockpit in the real repository. Reads App Modernization +// artifacts (plan.md / progress.md / summary.md), discovers custom skills under +// .github/skills/, extracts assessment facts from Maven/Gradle build files, and +// reports git branch state. Everything degrades gracefully when files are absent. + +import { readFile, readdir, stat, open } from "node:fs/promises"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { catalogWithRelevance, VALIDATION_GATES } from "./catalog.mjs"; + +// Dependency signatures: substring(s) searched in build files -> catalog `detect` key. +const DEP_SIGNATURES = { + rabbitmq: ["rabbitmq", "spring-rabbit", "amqp-client", "spring-amqp", "starter-amqp", "amqp"], + activemq: ["activemq"], + jms: ["javax.jms", "jakarta.jms", "spring-jms"], + awsS3: ["aws-java-sdk-s3", "s3", "software.amazon.awssdk"], + awsSqs: ["aws-java-sdk-sqs", "sqs"], + awsSecrets: ["secretsmanager"], + javamail: ["javax.mail", "jakarta.mail", "com.sun.mail", "spring-boot-starter-mail"], + ldap: ["spring-ldap", "spring-security-ldap", "unboundid-ldapsdk", "ldaptive"], + cache: ["infinispan", "swarmcache", "memcached", "spymemcached", "ehcache"], + oracle: ["ojdbc", "oracle"], + db2: ["db2jcc", "com.ibm.db2"], + sybase: ["jconn", "sybase"], + informix: ["informix"], + jdbc: ["jdbc", "hikari", "mysql", "postgresql", "mssql-jdbc", "sqlserver"], + keystore: ["keystore", ".jks", "javax.net.ssl"], + crypto: ["javax.crypto", "java.security", "bouncycastle", "bcprov"], + filelog: ["fileappender", "rollingfileappender", "logback", "log4j"], +}; + +// Cap how much of any single file we read. Build files and modernization docs are +// tiny; an unbounded read on a pathological repo (a giant generated XML, a vendored +// blob) would waste memory for no parsing benefit. Truncating is safe — every +// parser here scans for early markers/coordinates. +const MAX_READ_BYTES = 2 * 1024 * 1024; + +function execGit(args, cwd) { + return new Promise((resolve) => { + execFile("git", args, { cwd, timeout: 4000 }, (err, stdout) => { + if (err) resolve(null); + else resolve(String(stdout).trim()); + }); + }); +} + +async function readText(path) { + try { + const st = await stat(path); + if (!st.isFile()) return null; + if (st.size > MAX_READ_BYTES) { + const fh = await open(path, "r"); + try { + const buf = Buffer.alloc(MAX_READ_BYTES); + const { bytesRead } = await fh.read(buf, 0, MAX_READ_BYTES, 0); + return buf.toString("utf8", 0, bytesRead); + } finally { + await fh.close(); + } + } + return await readFile(path, "utf8"); + } catch { + return null; + } +} + +async function exists(path) { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +async function isDirectory(path) { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +// Explicit provenance marker the cockpit writes at the top of artifacts it owns, +// so a root plan/progress/summary self-identifies as modernization state even +// when there is no .appmod/ directory yet. +const APPMOD_MARKER_RE = / marker. This stops an unrelated summary.md from + // flipping a foreign repo to "completed", or a stray plan.md from injecting + // fake steps into the progress/ordering/autopilot machinery. + const hasAppmodArtifact = + reportRaw != null || apPlan != null || apProgress != null || apSummary != null; + const trusted = (root) => hasAppmodArtifact || hasMarker(root); + const planMd = apPlan || (trusted(rootPlan) ? rootPlan : null); + const progressMd = apProgress || (trusted(rootProgress) ? rootProgress : null); + const summaryMd = apSummary || (trusted(rootSummary) ? rootSummary : null); + + const assessment = await buildAssessment(repoPath); + const skills = await discoverSkills(repoPath); + + const planSteps = parseSteps(planMd); + const progressSteps = parseSteps(progressMd); + // Tag each step with its canonical phase rank for ordering guidance. + for (const arr of [planSteps, progressSteps]) { + for (const st of arr) st.rank = phaseRankFromName(st.section); + } + // Progress is the source of truth for status; fall back to plan steps. + const steps = progressSteps.length ? progressSteps : planSteps; + const percent = percentDone(steps); + const ordering = computeOrdering(steps); + // Prefer an explicit "Validation"/"gates" section for gate status; fall back + // to scanning all steps when no such section exists. + const gateSteps = + sectionSteps(progressMd, /validation|gates/i) || + sectionSteps(summaryMd, /validation|gates/i) || + steps; + const gates = deriveGates(gateSteps, summaryMd); + + let status = "not_started"; + if (summaryMd) status = "completed"; + else if (planMd || progressMd) status = "in_progress"; + + let git = null; + if (includeGit) { + const branch = await execGit(["rev-parse", "--abbrev-ref", "HEAD"], repoPath); + const dirty = await execGit(["status", "--porcelain"], repoPath); + git = { + branch, + isMigrationBranch: !!branch && /moderniz|migrat|upgrade|appmod/i.test(branch), + dirty: dirty === null ? null : dirty.length > 0, + changedFiles: dirty ? dirty.split(/\r?\n/).filter(Boolean).length : 0, + }; + } + + return { + ok: true, + repoPath, + scannedAt: new Date().toISOString(), + status, + percent, + assessment, + tasks: catalogWithRelevance(assessment.detectedKeys), + skills, + git, + plan: { exists: !!planMd, steps: planSteps }, + progress: { exists: !!progressMd, steps: progressSteps }, + summary: { exists: !!summaryMd, markdown: summaryMd || "" }, + report, + ordering, + gates, + }; +} diff --git a/extensions/java-modernization-studio/server.mjs b/extensions/java-modernization-studio/server.mjs new file mode 100644 index 000000000..af2e22753 --- /dev/null +++ b/extensions/java-modernization-studio/server.mjs @@ -0,0 +1,334 @@ +// server.mjs — Per-instance loopback HTTP server + action dispatch for the cockpit. +// Decoupled from the SDK: agent messaging is injected as `sendPrompt`, so this +// module can be exercised by tests without a live session. + +import { createServer } from "node:http"; +import { randomBytes } from "node:crypto"; +import { scanRepo } from "./scan.mjs"; +import { renderHtml } from "./renderer.mjs"; +import { buildPrompt, ACTION_LABELS } from "./prompts.mjs"; +import { runDoctor } from "./doctor.mjs"; +import { makeRun, runAutopilot, AUTOPILOT_MAX_STEPS } from "./autopilot.mjs"; + +/** Resolve which repo path an open/action context refers to, by precedence. */ +export function resolveRepoPath(ctx, lastWorkingDir) { + const fromInput = + ctx && ctx.input && typeof ctx.input.repoPath === "string" ? ctx.input.repoPath : null; + const fromSession = + ctx && ctx.session && ctx.session.workingDirectory ? ctx.session.workingDirectory : null; + // No process.cwd() fallback: that resolves to the extension's own directory, + // not the user's repo, and would make the cockpit scan unrelated files. When + // nothing resolves we return null and the UI shows a "repo not available" + // state instead of plausible-but-wrong data. + return fromInput || fromSession || lastWorkingDir || null; +} + +/** + * Scan the repo and attach the environment readiness report. Tool probing is + * expensive (spawns several processes), so the doctor result is cached on the + * instance record and only recomputed on first build or an explicit recheck — + * not on every per-turn refresh. Probing only runs when `rec.runDoctor` is wired + * (real instances), so unit tests stay fast and deterministic. + */ +export async function buildState(rec, { recheckEnv = false } = {}) { + const state = await scanRepo(rec.repoPath); + if (state && state.ok && typeof rec.runDoctor === "function") { + if (recheckEnv || rec.doctor == null) { + try { + rec.doctor = await rec.runDoctor(state); + } catch { + rec.doctor = rec.doctor || null; + } + } + state.doctor = rec.doctor; + } else if (state && state.ok) { + state.doctor = rec.doctor || null; + } + if (state && state.ok) state.autopilot = rec.autopilot || null; + return state; +} + +/** Write an SSE payload to every connected client of an instance. */ +export function broadcast(rec, payload) { + const data = "data: " + JSON.stringify(payload) + "\n\n"; + for (const res of rec.sseClients) { + try { + res.write(data); + } catch { + // Client disconnected uncleanly: drop it so we don't keep throwing on + // every future broadcast (dead clients would otherwise leak in the Set). + rec.sseClients.delete(res); + } + } +} + +/** Re-scan the repo and push the fresh snapshot to connected clients. */ +export async function pushState(rec, log, opts) { + let state; + try { + state = await buildState(rec, opts); + } catch (e) { + if (log) log("appmod-cockpit scan failed: " + e.message, { level: "error" }); + return null; + } + broadcast(rec, { type: "state", state }); + return state; +} + +/** Clamp a requested step budget into a sane range. */ +function clampSteps(n) { + const v = Number(n); + if (!Number.isFinite(v)) return AUTOPILOT_MAX_STEPS; + return Math.max(1, Math.min(50, Math.round(v))); +} + +/** + * Kick off an Autopilot run for this instance. Returns immediately; the run loop + * streams progress to connected clients over SSE. The run promise is parked on + * the record so it is not garbage-collected mid-flight. + * @param {object} rec instance record + * @param {object} payload { scope?: "phase"|"all", maxSteps?, force? } + * @param {{ runTurn:(p:string)=>Promise, log?:Function }} deps + */ +export async function startAutopilot(rec, payload, deps) { + if (rec.autopilot && rec.autopilot.running) { + return { ok: false, error: "Autopilot is already running." }; + } + if (!deps || typeof deps.runTurn !== "function") { + return { ok: false, error: "This session can't drive Autopilot." }; + } + const p = payload || {}; + const start = await buildState(rec); + if (!start || !start.ok) { + return { ok: false, error: "Can't read the repo to start Autopilot." }; + } + if (start.doctor && start.doctor.overall === "blocked" && !p.force) { + return { ok: false, error: "Your environment isn't ready. Open Readiness and install the missing tools first." }; + } + const scope = p.scope === "all" ? "all" : "phase"; + const startRank = start.ordering ? start.ordering.activeRank : null; + const run = makeRun({ scope, maxSteps: clampSteps(p.maxSteps), startRank }); + rec.autopilot = run; + broadcast(rec, { type: "autopilot", autopilot: run }); + + const runDeps = { + snapshot: () => buildState(rec), + runTurn: deps.runTurn, + buildStepPrompt: (step) => buildPrompt("auto_step", { title: step.title, section: step.section }, rec.repoPath), + onProgress: (r, state) => { + if (state) broadcast(rec, { type: "state", state }); + else broadcast(rec, { type: "autopilot", autopilot: r }); + }, + log: deps.log, + }; + rec.autopilotPromise = runAutopilot(run, runDeps) + .then(() => pushState(rec, deps.log)) + .catch((e) => { + if (deps.log) deps.log("Autopilot run failed: " + e.message, { level: "error" }); + }); + return { ok: true, message: "Autopilot started", autopilot: run }; +} + +/** + * Handle a cockpit action. + * @param {object} rec instance record ({ repoPath, sseClients, ... }) + * @param {string} kind action kind + * @param {object} payload action payload + * @param {{ sendPrompt:(p:string)=>Promise, runTurn?:Function, log?:Function }} deps + */ +export async function dispatchAction(rec, kind, payload, deps) { + if (kind === "refresh") { + const state = await pushState(rec, deps && deps.log); + return { ok: true, message: "Refreshed", state }; + } + if (kind === "recheck_env") { + const state = await pushState(rec, deps && deps.log, { recheckEnv: true }); + return { ok: true, message: "Re-checked environment", state }; + } + if (kind === "autopilot_start") { + return await startAutopilot(rec, payload, deps); + } + if (kind === "autopilot_stop") { + if (rec.autopilot && rec.autopilot.running) { + rec.autopilot.cancelled = true; + broadcast(rec, { type: "autopilot", autopilot: rec.autopilot }); + return { ok: true, message: "Stopping after the current step finishes…" }; + } + return { ok: false, error: "Autopilot isn't running." }; + } + if (kind === "autopilot_dismiss") { + if (rec.autopilot && !rec.autopilot.running) { + rec.autopilot = null; + await pushState(rec, deps && deps.log); + } + return { ok: true, message: "Dismissed" }; + } + const prompt = buildPrompt(kind, payload, rec.repoPath); + if (!prompt) return { ok: false, error: "Unknown action: " + kind }; + if (!deps || typeof deps.sendPrompt !== "function") { + return { ok: false, error: "Session not ready" }; + } + const label = ACTION_LABELS[kind] || kind; + try { + await deps.sendPrompt(prompt); + if (deps.log) deps.log("Java Modernization Studio → " + label, { ephemeral: true }); + return { ok: true, label, message: "Sent to the agent: " + label, prompt }; + } catch (e) { + return { ok: false, error: e.message }; + } +} + +// Cap the request body the /action endpoint will buffer. Actions carry tiny JSON +// payloads; anything large is a bug or abuse, so we reject rather than buffer it. +const MAX_BODY_BYTES = 256 * 1024; + +function readBody(req, max = MAX_BODY_BYTES) { + return new Promise((resolve) => { + let data = ""; + let size = 0; + let done = false; + req.on("data", (c) => { + if (done) return; + size += c.length; + if (size > max) { + done = true; + resolve({ body: "", tooLarge: true }); + if (typeof req.destroy === "function") req.destroy(); + return; + } + data += c; + }); + req.on("end", () => { + if (!done) { + done = true; + resolve({ body: data, tooLarge: false }); + } + }); + req.on("error", () => { + if (!done) { + done = true; + resolve({ body: "", tooLarge: false }); + } + }); + }); +} + +/** + * Build the request handler for an instance. Exposed for tests so routes can be + * exercised without binding a socket. + */ +export function makeHandler(rec, { instanceId, initialTab, sendPrompt, runTurn, log }) { + return async function handler(req, res) { + const url = new URL(req.url, "http://127.0.0.1"); + try { + // Per-instance secret: real instances (createInstanceServer) mint a token + // that the host embeds in the iframe URL. Reject any loopback request that + // doesn't present it, so other local processes can't read repo state or + // dispatch agent actions just by guessing the random port. When no token is + // set (direct makeHandler unit tests) the guard is a no-op. + if (rec.token) { + const provided = url.searchParams.get("token"); + if (provided !== rec.token) { + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "forbidden" })); + return; + } + } + if (req.method === "GET" && url.pathname === "/") { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderHtml({ instanceId, initialTab, token: rec.token })); + return; + } + if (req.method === "GET" && url.pathname === "/state") { + const state = await buildState(rec); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(state)); + return; + } + if (req.method === "GET" && url.pathname === "/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + res.write("retry: 3000\n\n"); + rec.sseClients.add(res); + req.on("close", () => rec.sseClients.delete(res)); + return; + } + if (req.method === "POST" && url.pathname === "/action") { + const { body, tooLarge } = await readBody(req); + if (tooLarge) { + res.statusCode = 413; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "request body too large" })); + return; + } + let parsed; + try { + parsed = JSON.parse(body || "{}"); + } catch { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "invalid JSON body" })); + return; + } + if (!parsed || typeof parsed.kind !== "string" || !parsed.kind) { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "missing or invalid 'kind'" })); + return; + } + const result = await dispatchAction(rec, parsed.kind, parsed.payload, { sendPrompt, runTurn, log }); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(result)); + return; + } + res.statusCode = 404; + res.end("not found"); + } catch (e) { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + } + res.end(JSON.stringify({ ok: false, error: e.message })); + } + }; +} + +/** + * Start a loopback server for one canvas instance. + * @returns {Promise} rec with { server, url, repoPath, sseClients } + */ +export async function createInstanceServer({ instanceId, repoPath, initialTab, sendPrompt, runTurn, log, runDoctor: runDoctorImpl }) { + const rec = { server: null, url: "", repoPath, sseClients: new Set(), doctor: null, autopilot: null, token: randomBytes(16).toString("hex") }; + // Wire the environment doctor (injectable for tests); real instances probe the + // local toolchain, cached and only recomputed on explicit recheck. + rec.runDoctor = typeof runDoctorImpl === "function" ? runDoctorImpl : (scan) => runDoctor(scan, {}); + const handler = makeHandler(rec, { instanceId, initialTab, sendPrompt, runTurn, log }); + const server = createServer((req, res) => { + // The handler is async: if it rejects, surface a 500 and log it rather than + // letting it become an unhandled rejection that can crash the extension. + handler(req, res).catch((err) => { + if (log) log("Java Modernization Studio server error: " + (err && err.message ? err.message : err), { level: "error" }); + try { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "internal error" })); + } else { + res.end(); + } + } catch { + /* response already torn down */ + } + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + rec.server = server; + rec.url = "http://127.0.0.1:" + port + "/?token=" + rec.token; + return rec; +} diff --git a/extensions/java-modernization-studio/test/cockpit.test.mjs b/extensions/java-modernization-studio/test/cockpit.test.mjs new file mode 100644 index 000000000..c85207d41 --- /dev/null +++ b/extensions/java-modernization-studio/test/cockpit.test.mjs @@ -0,0 +1,1662 @@ +// cockpit.test.mjs — Test suite for the Java Modernization Studio canvas. +// Run with: node --test test/cockpit.test.mjs +// +// Covers the testable modules (scan, catalog, prompts, renderer, server). It does +// NOT import extension.mjs, which calls joinSession() at module load and needs a +// live CLI session. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { execFileSync } from "node:child_process"; +import { EventEmitter } from "node:events"; + +import { scanRepo } from "../scan.mjs"; +import { PREDEFINED_TASKS, catalogWithRelevance, VALIDATION_GATES } from "../catalog.mjs"; +import { buildPrompt, ACTION_LABELS } from "../prompts.mjs"; +import { renderHtml } from "../renderer.mjs"; +import { + buildDoctorReport, + parseToolVersion, + probeOne, + runDoctor, + augmentedEnv, + discoverJavaHome, + parseJavaHomeFromRc, + TOOL_PROBES, +} from "../doctor.mjs"; +import { + resolveRepoPath, + broadcast, + pushState, + dispatchAction, + makeHandler, + createInstanceServer, +} from "../server.mjs"; +import { + selectNextStep, + stepKey, + isStepDone, + makeRun, + runAutopilot, +} from "../autopilot.mjs"; + +// ---- helpers ---------------------------------------------------------------- + +// Minimal provenance: a .appmod/ artifact tells the scanner this repo is actively +// running the modernization workflow, so root plan/progress/summary are trusted as +// workflow state (vs. unrelated files that merely share those names). +const PROV = { ".appmod/assessment.json": "{}" }; + +async function makeRepo(files = {}) { + const dir = await mkdtemp(join(tmpdir(), "appmod-test-")); + for (const [rel, content] of Object.entries(files)) { + const full = join(dir, rel); + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, content); + } + return dir; +} + +async function withRepo(files, fn) { + const dir = await makeRepo(files); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function git(dir, ...args) { + execFileSync("git", args, { cwd: dir, stdio: "pipe" }); +} + +function fakeRes() { + return { + statusCode: 200, + headers: {}, + body: "", + ended: false, + setHeader(k, v) { + this.headers[String(k).toLowerCase()] = v; + }, + writeHead(code, hdrs) { + this.statusCode = code; + if (hdrs) for (const k of Object.keys(hdrs)) this.headers[k.toLowerCase()] = hdrs[k]; + }, + write(s) { + this.body += s; + }, + end(s) { + if (s !== undefined) this.body += s; + this.ended = true; + }, + }; +} + +// Drive makeHandler with a fake POST request stream. +async function invokePost(handler, path, payloadObj) { + const req = new EventEmitter(); + req.method = "POST"; + req.url = path; + const res = fakeRes(); + const pending = handler(req, res); + req.emit("data", JSON.stringify(payloadObj)); + req.emit("end"); + await pending; + return res; +} + +async function invokeGet(handler, path) { + const req = new EventEmitter(); + req.method = "GET"; + req.url = path; + const res = fakeRes(); + await handler(req, res); + return { req, res }; +} + +// =========================================================================== +// scan.mjs +// =========================================================================== + +test("scan: nonexistent path returns ok:false", async () => { + const s = await scanRepo("/no/such/path/at/all/xyz"); + assert.equal(s.ok, false); +}); + +test("scan: empty repo is not_started with all gates not_run", async () => { + await withRepo({}, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.ok, true); + assert.equal(s.status, "not_started"); + assert.equal(s.percent, 0); + assert.equal(s.plan.exists, false); + assert.equal(s.progress.exists, false); + assert.equal(s.summary.exists, false); + for (const g of VALIDATION_GATES) assert.equal(s.gates[g.key], "not_run"); + }); +}); + +test("scan: Maven Java version from properties", async () => { + await withRepo( + { "pom.xml": "8" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.buildTool, "Maven"); + assert.equal(s.assessment.javaVersion, "8"); + } + ); +}); + +test("scan: Maven compiler release strips 1.x and detects", async () => { + await withRepo( + { "pom.xml": "17" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.javaVersion, "17"); + } + ); +}); + +test("scan: Gradle build tool + sourceCompatibility", async () => { + await withRepo( + { "build.gradle": "sourceCompatibility = '11'\napply plugin: 'java'" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.buildTool, "Gradle"); + assert.equal(s.assessment.javaVersion, "11"); + } + ); +}); + +test("scan: nested module pom is discovered", async () => { + await withRepo( + { "service/pom.xml": "8" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.buildTool, "Maven"); + assert.equal(s.assessment.javaVersion, "8"); + } + ); +}); + +test("scan: Spring Boot detection", async () => { + await withRepo( + { "pom.xml": "spring-boot-starter-web" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.springBoot, true); + } + ); +}); + +test("scan: Dockerfile detected at root and one level down", async () => { + await withRepo({ Dockerfile: "FROM tomcat:9" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasDockerfile, true); + }); + await withRepo({ "svc/Dockerfile": "FROM tomcat:9" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasDockerfile, true); + }); +}); + +test("scan: dependency relevance flags the right catalog tasks", async () => { + const cases = [ + ["spring-boot-starter-amqp", "rabbitmq-to-servicebus"], + ["activemq-client", "activemq-to-servicebus"], + ["aws-java-sdk-s3", "aws-s3-to-blob"], + ["ojdbc8", "databases-to-azure"], + ["spring-ldap-core", "entra-id-auth"], + ]; + for (const [dep, taskId] of cases) { + await withRepo( + { "pom.xml": `${dep}` }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + const task = s.tasks.find((t) => t.id === taskId); + assert.ok(task, `task ${taskId} exists`); + assert.equal(task.relevant, true, `${dep} -> ${taskId} relevant`); + } + ); + } +}); + +test("scan: checklist statuses (done / in_progress / pending)", async () => { + await withRepo( + { ...PROV, "progress.md": "- [x] Done step\n- [/] Working step\n- [ ] Todo step\n" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + const byTitle = Object.fromEntries(s.progress.steps.map((x) => [x.title, x.status])); + assert.equal(byTitle["Done step"], "done"); + assert.equal(byTitle["Working step"], "in_progress"); + assert.equal(byTitle["Todo step"], "pending"); + } + ); +}); + +test("scan: emoji status markers override", async () => { + await withRepo( + { ...PROV, "progress.md": "- [ ] Build ✅\n- [ ] Tests ❌\n" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + const byTitle = Object.fromEntries( + s.progress.steps.map((x) => [x.title.replace(/[^A-Za-z ].*/, "").trim(), x.status]) + ); + assert.equal(byTitle["Build"], "done"); + assert.equal(byTitle["Tests"], "failed"); + } + ); +}); + +test("scan: numbered-list fallback when no checkboxes", async () => { + await withRepo({ ...PROV, "plan.md": "# Plan\n1. First thing\n2. Second thing\n" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.plan.steps.length, 2); + assert.equal(s.plan.steps[0].title, "First thing"); + }); +}); + +test("scan: heading fallback when no checkboxes or numbers", async () => { + await withRepo({ ...PROV, "plan.md": "# Title\n## Phase one\n## Phase two\n" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + const titles = s.plan.steps.map((x) => x.title); + assert.deepEqual(titles, ["Phase one", "Phase two"]); + }); +}); + +test("scan: percent counts in_progress as half", async () => { + await withRepo( + { ...PROV, "progress.md": "- [x] a\n- [/] b\n- [ ] c\n- [ ] d\n" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + // (1 + 0.5) / 4 = 37.5 -> 38 + assert.equal(s.percent, 38); + } + ); +}); + +test("scan: gates derived from matching step titles", async () => { + await withRepo( + { ...PROV, "progress.md": "- [x] Unit Tests pass\n- [/] Build app\n- [ ] CVE scan\n" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.gates.tests, "done"); + assert.equal(s.gates.build, "in_progress"); + assert.equal(s.gates.cve, "pending"); // matched but not run + assert.equal(s.gates.consistency, "not_run"); // unmatched + } + ); +}); + +test("scan: gates scope to an explicit Validation section, not assessment text", async () => { + // Assessment rows mention "build" and "vulnerable" (done); they must NOT + // satisfy the Build/CVE gates, which live in the Validation section (pending). + const md = [ + "## Assessment", + "- [x] Inventory runtime, build tool, dependencies", + "- [x] Identify vulnerable dependencies", + "", + "## Validation gates", + "- [ ] Build — mvn clean package succeeds", + "- [ ] CVE Check — no critical CVEs", + "", + "## Next", + "- [x] something else with the word build in it", + ].join("\n"); + await withRepo({ ...PROV, "progress.md": md }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.gates.build, "pending"); + assert.equal(s.gates.cve, "pending"); + }); +}); + +test("scan: status is completed when summary.md present", async () => { + await withRepo({ ...PROV, "summary.md": "# done" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.status, "completed"); + }); +}); + +test("scan: status is in_progress when only plan present", async () => { + await withRepo({ ...PROV, "plan.md": "- [ ] a" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.status, "in_progress"); + }); +}); + +test("scan: reads .appmod/assessment.json into report", async () => { + const report = { + generatedAt: "2026-01-01T00:00:00Z", + headline: "h", + findings: [{ id: "x", severity: "P0", title: "t", detail: "d", files: ["a"], action: { kind: "fix_finding" } }], + }; + await withRepo({ ".appmod/assessment.json": JSON.stringify(report) }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.ok(s.report, "report should be present"); + assert.equal(s.report.findings.length, 1); + assert.equal(s.report.findings[0].severity, "P0"); + }); +}); + +test("scan: report is null when assessment.json absent or malformed", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.report, null); + }); + await withRepo({ ".appmod/assessment.json": "{ not valid json" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.report, null, "malformed JSON should not throw, just yield null"); + }); +}); + +// ---- provenance gate (cross-repo safety) ------------------------------------ + +test("scan: an unrelated summary.md without provenance does NOT mark completed", async () => { + await withRepo({ "summary.md": "# My library\nUsage and changelog notes." }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.notEqual(s.status, "completed", "a stray summary.md must not flip status to completed"); + assert.equal(s.status, "not_started"); + assert.equal(s.summary.exists, false, "untrusted summary is treated as absent"); + }); +}); + +test("scan: an unrelated plan.md without provenance is not treated as workflow steps", async () => { + await withRepo({ "plan.md": "# Roadmap\n- [ ] ship feature x\n- [ ] write docs\n" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.plan.steps.length, 0, "stray checkboxes must not become modernization steps"); + assert.equal(s.status, "not_started"); + }); +}); + +test("scan: assessment.json provenance makes a root progress.md trusted", async () => { + await withRepo( + { ".appmod/assessment.json": "{}", "progress.md": "## P0\n- [x] a\n- [ ] b\n" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.progress.steps.length, 2); + assert.equal(s.status, "in_progress"); + } + ); +}); + +test("scan: an explicit appmod marker makes a root plan trusted without .appmod/", async () => { + await withRepo( + { "plan.md": "\n## P0 Build\n- [ ] Fix build\n" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.plan.steps.length, 1, "marked file is trusted"); + assert.equal(s.status, "in_progress"); + } + ); +}); + +test("scan: .appmod/ namespaced docs are trusted and take precedence over root", async () => { + await withRepo( + { + ".appmod/plan.md": "## P0\n- [ ] from appmod\n", + "plan.md": "## P0\n- [ ] from root\n- [ ] root two\n", + }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.plan.steps.length, 1, "uses .appmod/plan.md, not the root copy"); + assert.equal(s.plan.steps[0].title, "from appmod"); + } + ); +}); + +test("scan: a non-directory path is reported unavailable (not scanned)", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + const s = await scanRepo(join(dir, "pom.xml"), { includeGit: false }); + assert.equal(s.ok, false, "a file path is not a repo"); + }); +}); + +test("scan: steps carry their section heading", async () => { + const md = "# Plan\n## P0 — Build\n- [x] a\n- [ ] b\n## P1 — Secrets\n- [ ] c\n"; + await withRepo({ ...PROV, "progress.md": md }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + const byTitle = Object.fromEntries(s.progress.steps.map((x) => [x.title, x.section])); + assert.equal(byTitle["a"], "P0 — Build"); + assert.equal(byTitle["b"], "P0 — Build"); + assert.equal(byTitle["c"], "P1 — Secrets"); + }); +}); + +test("scan: ordering computes the active phase, ranks, and per-phase counts", async () => { + const md = + "# Plan\n## Assessment\n- [x] a1\n- [x] a2\n" + + "## P0 — Build consistency\n- [x] b1\n- [ ] b2\n" + + "## P1 — Secrets & identity\n- [ ] c1\n- [ ] c2\n" + + "## Validation gates\n- [ ] v1\n"; + await withRepo({ ...PROV, "progress.md": md }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.ordering.activeRank, 1, "P0 is the active rank"); + assert.match(s.ordering.activePhase, /P0/); + // Per-phase roll-up. + const byName = Object.fromEntries(s.ordering.phases.map((p) => [p.name, p])); + assert.equal(byName["Assessment"].done, 2); + assert.equal(byName["Assessment"].total, 2); + assert.equal(byName["P0 — Build consistency"].done, 1); + assert.equal(byName["P0 — Build consistency"].total, 2); + assert.equal(byName["P1 — Secrets & identity"].done, 0); + // Each step carries a phase rank; P1 steps sit ahead of the active P0. + const byTitle = Object.fromEntries(s.progress.steps.map((x) => [x.title, x.rank])); + assert.equal(byTitle["a1"], 0); + assert.equal(byTitle["b2"], 1); + assert.equal(byTitle["c1"], 2); + assert.ok(byTitle["c1"] > s.ordering.activeRank, "P1 step is ahead of sequence (locked)"); + }); +}); + +test("scan: ordering activeRank is null once every ranked step is done", async () => { + const md = "# Plan\n## Assessment\n- [x] a\n## P0 — Build\n- [x] b\n"; + await withRepo({ ...PROV, "progress.md": md }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.ordering.activeRank, null); + assert.equal(s.ordering.activePhase, null); + }); +}); + + +test("scan: discovers custom skills from .github/skills", async () => { + await withRepo( + { + ".github/skills/my-skill/SKILL.md": "name: My Skill\ndescription: Does a thing\n", + ".github/skills/other/SKILL.md": "# Other Skill\n", + }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.skills.length, 2); + const mine = s.skills.find((x) => x.folder === "my-skill"); + assert.equal(mine.name, "My Skill"); + assert.equal(mine.description, "Does a thing"); + } + ); +}); + +test("scan: git branch + migration detection + dirty flag", async () => { + await withRepo({ "pom.xml": "", "README.md": "x" }, async (dir) => { + git(dir, "init", "-b", "modernize-java21"); + git(dir, "config", "user.email", "t@example.com"); + git(dir, "config", "user.name", "Test"); + git(dir, "add", "."); + git(dir, "commit", "-m", "init", "--no-gpg-sign"); + + let s = await scanRepo(dir); + assert.equal(s.git.branch, "modernize-java21"); + assert.equal(s.git.isMigrationBranch, true); + assert.equal(s.git.dirty, false); + + await writeFile(join(dir, "new.txt"), "hi"); + s = await scanRepo(dir); + assert.equal(s.git.dirty, true); + assert.equal(s.git.changedFiles, 1); + }); +}); + +// =========================================================================== +// doctor.mjs (environment readiness) +// =========================================================================== + +function fakeExec(map) { + // map: cmd -> { stdout?, stderr?, error? } (missing cmd => ENOENT) + return async (cmd) => { + const r = map[cmd]; + if (!r) return { error: Object.assign(new Error("not found"), { code: "ENOENT" }), stdout: "", stderr: "" }; + return { error: r.error || null, stdout: r.stdout || "", stderr: r.stderr || "" }; + }; +} + +test("doctor: parseToolVersion handles java (stderr), node, and generic output", () => { + assert.equal(parseToolVersion("java", 'openjdk version "17.0.2" 2022-01-18'), "17.0.2"); + assert.equal(parseToolVersion("node", "v22.3.0"), "22.3.0"); + assert.equal(parseToolVersion("git", "git version 2.39.5"), "2.39.5"); + assert.equal(parseToolVersion("x", ""), null); +}); + +test("doctor: probeOne reports not-found on ENOENT and found otherwise", async () => { + const exec = fakeExec({ java: { stderr: 'openjdk version "21.0.1"' } }); + const found = await probeOne({ key: "java", cmd: "java", args: ["-version"] }, exec); + assert.deepEqual(found, { key: "java", found: true, version: "21.0.1" }); + const missing = await probeOne({ key: "mvn", cmd: "mvn", args: ["-v"] }, exec); + assert.equal(missing.found, false); +}); + +test("doctor: probeOne rejects the macOS java stub (non-zero exit, no version)", async () => { + const stub = async () => ({ + error: Object.assign(new Error("exit 1"), { code: 1 }), + stdout: "", + stderr: "The operation couldn't be completed. Unable to locate a Java Runtime.", + }); + const r = await probeOne({ key: "java", cmd: "java", args: ["-version"] }, stub); + assert.equal(r.found, false, "stub with no real version is treated as not installed"); +}); + +test("doctor: missing JDK + missing Maven (no wrapper) blocks readiness", () => { + const rep = buildDoctorReport({ + probes: { git: { found: true, version: "2.39" } }, + scan: { ok: true, assessment: { buildTool: "Maven", hasMavenWrapper: false }, git: { branch: "main" }, plan: {}, progress: {} }, + }); + assert.equal(rep.overall, "blocked"); + const checks = rep.groups.flatMap((g) => g.checks); + const jdk = checks.find((c) => c.id === "jdk"); + assert.equal(jdk.status, "fail"); + assert.ok(jdk.action && jdk.action.kind === "fix_env", "JDK failure offers a fix_env action"); + const build = checks.find((c) => c.id === "build"); + assert.equal(build.status, "fail"); +}); + +test("doctor: a Maven wrapper satisfies the build-tool check without mvn installed", () => { + const rep = buildDoctorReport({ + probes: { java: { found: true, version: "17" }, git: { found: true } }, + scan: { ok: true, assessment: { buildTool: "Maven", hasMavenWrapper: true }, git: { branch: "x" }, progress: { exists: true } }, + }); + const build = rep.groups.flatMap((g) => g.checks).find((c) => c.id === "build"); + assert.equal(build.status, "ok"); + assert.match(build.detail, /wrapper/i); +}); + +test("doctor: a Dockerfile without Docker is a caution (warn), not a blocker", () => { + const rep = buildDoctorReport({ + probes: { java: { found: true }, mvn: { found: true }, git: { found: true } }, + scan: { ok: true, assessment: { buildTool: "Maven", hasDockerfile: true }, git: { branch: "x" }, progress: { exists: true } }, + }); + const docker = rep.groups.flatMap((g) => g.checks).find((c) => c.id === "docker"); + assert.equal(docker.status, "warn"); + assert.equal(rep.overall, "caution"); +}); + +test("doctor: no assessment yet surfaces a start_assessment action", () => { + const rep = buildDoctorReport({ + probes: { java: { found: true }, mvn: { found: true }, git: { found: true } }, + scan: { ok: true, assessment: { buildTool: "Maven" }, git: { branch: "x" }, plan: { exists: false }, progress: { exists: false } }, + }); + const assessed = rep.groups.flatMap((g) => g.checks).find((c) => c.id === "assessed"); + assert.equal(assessed.status, "warn"); + assert.equal(assessed.action.kind, "start_assessment"); +}); + +test("doctor: everything present is ready", () => { + const rep = buildDoctorReport({ + probes: { + java: { found: true, version: "21" }, mvn: { found: true }, git: { found: true }, + docker: { found: true }, az: { found: true }, azd: { found: true }, node: { found: true }, + }, + scan: { ok: true, assessment: { buildTool: "Maven" }, git: { branch: "modernize", isMigrationBranch: true }, progress: { exists: true }, skills: [{ folder: "a" }] }, + }); + assert.equal(rep.overall, "ready"); +}); + +test("doctor: runDoctor probes every tool via the injected exec", async () => { + const exec = fakeExec({ + java: { stderr: 'openjdk version "17.0.9"' }, + git: { stdout: "git version 2.40.0" }, + }); + const rep = await runDoctor( + { ok: true, assessment: { buildTool: "Maven" }, git: { branch: "x" }, progress: { exists: true } }, + { exec } + ); + assert.ok(rep.groups.length >= 3); + assert.equal(TOOL_PROBES.length, 8); +}); + +test("doctor: augmentedEnv covers Homebrew, keg JDKs, SDKMAN, and JAVA_HOME (macOS)", () => { + // GUI apps inherit a minimal PATH; the doctor must still find tools the user's + // terminal sees. Inject a directory lister so the test doesn't hit the filesystem. + const lister = (d) => (d === "/opt/homebrew/opt" ? ["openjdk@21", "openjdk", "maven", "node@20"] : []); + const env = augmentedEnv({ PATH: "/usr/bin:/bin", HOME: "/Users/x", JAVA_HOME: "/jh" }, "darwin", lister); + const parts = env.PATH.split(":"); + assert.ok(parts.includes("/opt/homebrew/bin"), "Homebrew bin"); + assert.ok(parts.includes("/jh/bin"), "JAVA_HOME/bin"); + assert.ok(parts.includes("/Users/x/.sdkman/candidates/java/current/bin"), "SDKMAN"); + assert.ok(parts.includes("/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home/bin"), "versioned keg"); + assert.ok(parts.includes("/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home/bin"), "unversioned keg"); + assert.ok(!parts.some((p) => /\/maven\/libexec\/openjdk/.test(p)), "non-JDK kegs are skipped"); + assert.ok(env.PATH.endsWith("/usr/bin:/bin"), "original PATH preserved at the tail"); +}); + +test("doctor: augmentedEnv is a no-op on Windows and tolerates a bare env", () => { + assert.equal(augmentedEnv({ PATH: "C:\\Windows" }, "win32", () => []).PATH, "C:\\Windows"); + // No HOME/JAVA_HOME/PATH provided — must not throw and still prepend brew bins. + const env = augmentedEnv({}, "darwin", () => []); + assert.ok(env.PATH.split(":").includes("/opt/homebrew/bin")); +}); + +test("doctor: discoverJavaHome respects an explicit JAVA_HOME and skips rc files", async () => { + let read = false; + const readRc = async () => { read = true; return ""; }; + const jh = await discoverJavaHome({ JAVA_HOME: "/explicit/jdk" }, "darwin", readRc); + assert.equal(jh, "/explicit/jdk"); + assert.equal(read, false, "must not read rc files when JAVA_HOME is already set"); +}); + +test("doctor: discoverJavaHome statically parses a literal JAVA_HOME from the rc (no shell)", async () => { + const reads = []; + const readRc = async (p) => { + reads.push(p); + if (/\.zshrc$/.test(p)) { + return 'export PATH="$HOME/bin:$PATH"\nexport JAVA_HOME="$HOME/.sdkman/candidates/java/current"\n'; + } + return null; + }; + const jh = await discoverJavaHome({ HOME: "/Users/x", SHELL: "/bin/zsh" }, "darwin", readRc); + assert.equal(jh, "/Users/x/.sdkman/candidates/java/current", "expands $HOME, no execution"); + assert.ok(reads.some((p) => /\.zshrc$/.test(p)), "reads the zsh rc files"); +}); + +test("doctor: discoverJavaHome returns null on Windows, no match, unresolved subst, or read error", async () => { + assert.equal(await discoverJavaHome({ HOME: "/h" }, "win32", async () => "export JAVA_HOME=/x"), null); + assert.equal( + await discoverJavaHome({ HOME: "/h", SHELL: "/bin/zsh" }, "darwin", async () => "# nothing here"), + null + ); + // Command substitution can't be resolved statically -> null (falls back to PATH probing). + assert.equal( + await discoverJavaHome({ HOME: "/h", SHELL: "/bin/zsh" }, "darwin", async () => + "export JAVA_HOME=$(/usr/libexec/java_home -v 21)" + ), + null + ); + assert.equal( + await discoverJavaHome({ HOME: "/h", SHELL: "/bin/zsh" }, "darwin", async () => { throw new Error("boom"); }), + null + ); +}); + +test("doctor: parseJavaHomeFromRc handles quotes, comments, ~ and last-wins", () => { + assert.equal(parseJavaHomeFromRc("export JAVA_HOME=/opt/jdk17 # pinned", {}), "/opt/jdk17"); + assert.equal(parseJavaHomeFromRc("JAVA_HOME='/opt/jdk21'", {}), "/opt/jdk21"); + assert.equal(parseJavaHomeFromRc("export JAVA_HOME=~/jdks/21", { HOME: "/Users/x" }), "/Users/x/jdks/21"); + assert.equal( + parseJavaHomeFromRc("export JAVA_HOME=/a\nexport JAVA_HOME=/b", {}), + "/b", + "later assignment wins" + ); + assert.equal(parseJavaHomeFromRc("export NOTJAVA=/x", {}), null); +}); + +test("prompts: fix_env requires a tool and embeds the readiness detail", () => { + const p = buildPrompt("fix_env", { tool: "a JDK", detail: "java not found", fix: "Install Temurin" }, "/r"); + assert.match(p, /a JDK/); + assert.match(p, /java not found/); + assert.match(p, /do not modify my application code/i); + assert.equal(buildPrompt("fix_env", {}, "/r"), null); +}); + +test("scan: detects Maven/Gradle wrappers for the doctor", async () => { + await withRepo({ "pom.xml": "", "mvnw": "#!/bin/sh\n" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasMavenWrapper, true); + assert.equal(s.assessment.hasGradleWrapper, false); + }); +}); + +test("scan: detects Windows wrapper scripts (mvnw.cmd / gradlew.bat)", async () => { + await withRepo({ "pom.xml": "", "mvnw.cmd": "@echo off\n" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasMavenWrapper, true, "mvnw.cmd counts as a wrapper"); + }); + await withRepo({ "build.gradle": "plugins {}", "gradlew.bat": "@echo off\n" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasGradleWrapper, true, "gradlew.bat counts as a wrapper"); + }); +}); + +test("scan: Dockerfile detection is case-insensitive and accepts Containerfile", async () => { + await withRepo({ "dockerfile": "FROM tomcat:9" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasDockerfile, true, "lowercase dockerfile"); + }); + await withRepo({ "Containerfile": "FROM tomcat:9" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasDockerfile, true, "Podman Containerfile"); + }); +}); + +test("scan: multi-module repo unions Java version + deps across modules (not just the first)", async () => { + await withRepo( + { + "pom.xml": "pomapiworker", + "api/pom.xml": "17", + "worker/pom.xml": + "com.rabbitmqamqp-client", + }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.buildTool, "Maven"); + assert.equal(s.assessment.javaVersion, "17", "Java version comes from a module, not the aggregator"); + assert.ok( + s.assessment.detectedKeys.includes("rabbitmq"), + "a dependency declared only in a second module is still detected" + ); + } + ); +}); + +test("scan: dependency matching no longer fires on English words containing a token", async () => { + // "important"/"constant" contain "ant"; a plain substring match used to flag Ant. + await withRepo( + { + "pom.xml": + "An important and constant service", + }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.ok(!s.assessment.detectedKeys.includes("ant"), "no false Ant detection"); + } + ); + // A real Ant build is still flagged, via the build tool. + await withRepo({ "build.xml": "" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.buildTool, "Ant"); + assert.ok(s.assessment.detectedKeys.includes("ant"), "genuine Ant build is detected"); + }); +}); + +test("scan: a real coordinate token is still matched with boundary-aware logic", async () => { + await withRepo( + { + "pom.xml": + "aws-java-sdk-s3", + }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.ok(s.assessment.detectedKeys.includes("awsS3")); + } + ); +}); + +test("scan: assessment.json is normalized (severity aliases, files string, bad shapes)", async () => { + const raw = { + findings: [ + { id: "a", severity: "critical", title: "x", files: "only/one.txt" }, + { id: "b", severity: "high", title: "y" }, + { id: "c", severity: "weird", title: "z", files: ["k.txt", 5] }, + "not-an-object", + { severity: "low" }, + ], + strengths: "single strength", + }; + await withRepo({ ".appmod/assessment.json": JSON.stringify(raw) }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + const f = s.report.findings; + assert.equal(f.length, 4, "non-object findings are dropped, valid ones kept"); + assert.equal(f[0].severity, "P0", "critical -> P0"); + assert.deepEqual(f[0].files, ["only/one.txt"], "string files -> array"); + assert.equal(f[1].severity, "P1", "high -> P1"); + assert.equal(f[2].severity, "P3", "unknown severity -> P3 (kept, not dropped)"); + assert.deepEqual(f[2].files, ["k.txt"], "non-string file entries filtered out"); + assert.equal(f[3].title, "Untitled finding", "missing title gets a fallback"); + assert.deepEqual(s.report.strengths, ["single strength"], "string strengths -> array"); + }); +}); + +test("scan: a non-object assessment.json yields a null report (no crash)", async () => { + await withRepo({ ".appmod/assessment.json": "42" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.report, null); + }); + await withRepo({ ".appmod/assessment.json": "[1,2,3]" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.report, null, "a top-level array is not a valid report"); + }); +}); + + + +test("catalog: has 16 well-formed tasks", () => { + assert.equal(PREDEFINED_TASKS.length, 16); + for (const t of PREDEFINED_TASKS) { + assert.ok(t.id && t.name && t.category && t.summary, "task fields present: " + t.id); + assert.ok(Array.isArray(t.detect)); + } + const ids = new Set(PREDEFINED_TASKS.map((t) => t.id)); + assert.equal(ids.size, PREDEFINED_TASKS.length, "ids are unique"); +}); + +test("catalog: catalogWithRelevance flags only matching detect keys", () => { + const withRel = catalogWithRelevance(["rabbitmq"]); + assert.equal(withRel.find((t) => t.id === "rabbitmq-to-servicebus").relevant, true); + assert.equal(withRel.find((t) => t.id === "aws-s3-to-blob").relevant, false); +}); + +// =========================================================================== +// prompts.mjs +// =========================================================================== + +test("prompts: every labelled action builds a non-empty prompt", () => { + for (const kind of Object.keys(ACTION_LABELS)) { + const payload = + kind === "run_task" + ? { taskId: PREDEFINED_TASKS[0].id } + : kind === "run_skill" + ? { folder: "x" } + : kind === "fix_finding" + ? { title: "Some finding" } + : kind === "work_step" + ? { title: "Some step" } + : kind === "fix_env" + ? { tool: "a JDK" } + : {}; + const p = buildPrompt(kind, payload, "/repo"); + assert.ok(typeof p === "string" && p.length > 0, "prompt for " + kind); + } +}); + +test("prompts: run_task unknown id returns null", () => { + assert.equal(buildPrompt("run_task", { taskId: "nope" }, "/repo"), null); +}); + +test("prompts: run_task known id embeds the task name", () => { + const p = buildPrompt("run_task", { taskId: "aws-s3-to-blob" }, "/repo"); + assert.match(p, /AWS S3 to Azure Storage Blob/); +}); + +test("prompts: assessment embeds repo path; generate_plan embeds target", () => { + assert.match(buildPrompt("start_assessment", {}, "/my/repo"), /\/my\/repo/); + assert.match(buildPrompt("generate_plan", { targetJava: 21 }, "/r"), /Java 21/); +}); + +test("prompts: appmod tools referenced by hash command", () => { + assert.match(buildPrompt("run_cve", {}, "/r"), /#appmod-validate-cves-for-java/); + assert.match(buildPrompt("generate_tests", {}, "/r"), /#appmod-generate-tests-for-java/); +}); + +test("prompts: unknown kind returns null", () => { + assert.equal(buildPrompt("bogus", {}, "/r"), null); +}); + +test("prompts: fix_finding embeds the finding title/detail and needs a title", () => { + const p = buildPrompt("fix_finding", { title: "TLS off", detail: "encrypt=false", files: ["db.properties"], severity: "P1" }, "/r"); + assert.match(p, /TLS off/); + assert.match(p, /encrypt=false/); + assert.match(p, /db\.properties/); + assert.equal(buildPrompt("fix_finding", {}, "/r"), null, "no title -> null"); +}); + +test("prompts: start_assessment instructs writing structured assessment.json", () => { + const p = buildPrompt("start_assessment", {}, "/my/repo"); + assert.match(p, /\.appmod\/assessment\.json/); + assert.match(p, /findings/); + assert.match(p, /P0\|P1\|P2\|P3/); +}); + +test("prompts: fix_finding has a label in ACTION_LABELS", () => { + assert.ok(ACTION_LABELS.fix_finding, "fix_finding should be labelled"); +}); + +test("prompts: work_step embeds the step title + phase and requires a title", () => { + const p = buildPrompt("work_step", { title: "Add ingress TLS", section: "P1 — Secrets & identity" }, "/r"); + assert.match(p, /Add ingress TLS/); + assert.match(p, /P1 — Secrets & identity/); + assert.match(p, /progress\.md/); + assert.equal(buildPrompt("work_step", {}, "/r"), null, "no title -> null"); +}); + +// =========================================================================== +// renderer.mjs +// =========================================================================== + +test("renderer: returns full HTML doc with app root and boot blob", () => { + const html = renderHtml({ instanceId: "abc", initialTab: "plan" }); + assert.match(html, /^/); + assert.match(html, /id="app"/); + assert.match(html, /Java Modernization Studio/); + assert.match(html, /"initialTab":"plan"/); + assert.match(html, /"instanceId":"abc"/); +}); + +test("renderer: defaults initialTab to overview", () => { + const html = renderHtml({ instanceId: "x" }); + assert.match(html, /"initialTab":"overview"/); +}); + +test("renderer: button hover is contrast-safe (no inverted emphasis background)", () => { + const html = renderHtml({ instanceId: "css" }); + const css = html.slice(html.indexOf("")); + // Regression: a `:hover` must never swap a dark-text button onto the dark + // `--background-color-emphasis` surface (that made hovered text unreadable). + assert.ok(!/:hover[^}]*background-color-emphasis/.test(css), "no emphasis background on hover"); + // The default (non-primary) button keeps a readable surface on hover. + assert.match(css, /button\.btn:not\(\.primary\):not\(:disabled\):hover/); + // Keyboard users get a visible focus ring. + assert.match(css, /button\.btn:focus-visible/); +}); + +test("renderer: boot blob escapes < so it cannot break out of ")[0]; + const slots = {}; + const make = (id) => ({ + _html: "", id, + set innerHTML(v) { this._html = v; }, + get innerHTML() { return this._html; }, + addEventListener() {}, querySelectorAll() { return []; }, querySelector() { return null; }, + classList: { add() {}, remove() {}, toggle() {} }, setAttribute() {}, style: {}, dataset: {}, textContent: "", + }); + const win = { __APPMOD__: { instanceId: "exec", initialTab }, addEventListener() {}, location: {}, scrollTo() {} }; + const ES = class { constructor() {} addEventListener() {} close() {} }; + const doc = { + getElementById: (id) => { if (!slots[id]) slots[id] = make(id); return slots[id]; }, + querySelectorAll: () => [], + querySelector: (s) => { if (typeof s === "string" && s[0] === "#") { const id = s.slice(1); if (!slots[id]) slots[id] = make(id); return slots[id]; } return null; }, + addEventListener() {}, createElement: () => make("x"), body: make("body"), + }; + const fetchShim = async () => ({ ok: true, json: async () => state }); + // Inject our shims as the only free identifiers the client uses, so the body + // runs against them rather than this module's lexical scope. + const fn = new Function("window", "document", "EventSource", "fetch", "setTimeout", "clearTimeout", "console", script); + fn(win, doc, ES, fetchShim, setTimeout, clearTimeout, console); + // The client renders once synchronously (Loading), then loadState() fetches + // and re-renders async — let those microtasks settle before reading #app. + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + return { app: () => (slots.app ? slots.app._html : "") }; +} + +// A realistic snapshot with a multi-phase plan, an assessment report, gates, an +// environment report, and no autopilot run — exercises the common render paths. +function sampleState(extra = {}) { + const steps = [ + { title: "Fix the build", section: "P0 Build", status: "in_progress", rank: 1 }, + { title: "Patch CVEs", section: "P1 Security", status: "pending", rank: 2 }, + { title: "Upgrade Java", section: "P2 Runtime", status: "pending", rank: 3 }, + ]; + return { + ok: true, repoPath: "/demo/app", scannedAt: new Date().toISOString(), + status: "in_progress", percent: 10, + assessment: { buildTool: "Maven", javaVersion: "8", springBoot: true, hasDockerfile: true, detectedKeys: [] }, + tasks: [], skills: [], + git: { branch: "modernize/java21", isMigrationBranch: true, dirty: true, changedFiles: 3 }, + plan: { exists: true, steps }, progress: { exists: true, steps }, + summary: { exists: false, markdown: "" }, + report: { + generatedAt: new Date().toISOString(), headline: "Java 8 Spring app", summary: "Outdated runtime and deps.", + stack: { buildTool: "Maven", java: "8", framework: "Spring Boot", database: "MSSQL", container: "Docker" }, + findings: [ + { id: "java8", severity: "P0", title: "Java 8 is EOL", detail: "Upgrade the runtime.", files: ["pom.xml"], action: { kind: "generate_plan", payload: { targetJava: 21 }, label: "Plan upgrade" } }, + { id: "cve", severity: "P1", title: "Vulnerable dependency", detail: "Bump it.", files: [], action: { kind: "fix_finding", payload: {}, label: "Fix" } }, + ], + strengths: ["Has a Dockerfile"], + }, + ordering: { activeRank: 1, activePhase: "P0 Build", phases: [{ name: "P0 Build", rank: 1, done: 0, total: 1 }] }, + gates: { build: "not_run", tests: "not_run", cve: "not_run", consistency: "not_run", completeness: "not_run" }, + doctor: { overall: "ready", generatedAt: new Date().toISOString(), groups: [ + { id: "build", name: "Build & run", checks: [{ id: "jdk", label: "JDK", status: "ok", detail: "Java 21" }] }, + ] }, + autopilot: null, + ...extra, + }; +} + +test("renderer: every tab renders without throwing against a realistic state", async () => { + const tabs = ["overview", "readiness", "assessment", "plan", "validation", "tasks", "summary"]; + for (const tab of tabs) { + const r = await renderClientTab(sampleState(), tab); + const out = r.app(); + assert.ok(out && out.length > 40, "tab '" + tab + "' produced no body"); + assert.ok(!/hit an error/.test(out), "tab '" + tab + "' fell back to the error banner"); + } +}); + +test("renderer: Plan tab actually renders its checklist when executed (regression)", async () => { + const r = await renderClientTab(sampleState(), "plan"); + const out = r.app(); + assert.match(out, /Plan & Progress/); + assert.match(out, /Fix the build/); + assert.match(out, /data-kind="work_step"/); + assert.match(out, /Autopilot/); + assert.ok(!/hit an error/.test(out)); +}); + +test("renderer: a running autopilot shows the live strip and freezes manual actions", async () => { + const state = sampleState({ + autopilot: { running: true, scope: "phase", status: "running", current: { title: "Fix the build", section: "P0 Build" }, completed: [{ title: "Earlier", section: "P0 Build", done: true }], maxSteps: 25 }, + }); + const r = await renderClientTab(state, "plan"); + const out = r.app(); + assert.match(out, /Autopilot running/); + assert.match(out, /autopilot_stop/); + assert.match(out, /Earlier/); // step log + assert.match(out, /data-kind="work_step"[^>]*disabled/); // manual buttons frozen +}); + +// Drive the live client: capture its click handler + EventSource so a test can +// fire a real button click and simulate an SSE (re)connect, then read #app. +// fetch routes GET /state -> current snapshot, POST /action -> a scripted result. +function driveClient(initialState, opts = {}) { + const initialTab = opts.initialTab || "readiness"; + const html = renderHtml({ instanceId: "drive", initialTab }); + const script = html.split("")[0]; + const slots = {}; + const make = (id) => ({ + _html: "", id, + set innerHTML(v) { this._html = v; }, get innerHTML() { return this._html; }, + addEventListener() {}, querySelectorAll() { return []; }, querySelector() { return null; }, + classList: { add() {}, remove() {}, toggle() {} }, setAttribute() {}, style: {}, dataset: {}, textContent: "", + }); + let clickHandler = null; + const es = { onopen: null, onmessage: null, onerror: null, addEventListener() {}, close() {} }; + const win = { __APPMOD__: { instanceId: "drive", initialTab }, addEventListener() {}, location: {}, scrollTo() {} }; + const ES = function () { return es; }; // `new ES()` yields our singleton + const doc = { + getElementById: (id) => { if (!slots[id]) slots[id] = make(id); return slots[id]; }, + querySelectorAll: () => [], + querySelector: (s) => { if (typeof s === "string" && s[0] === "#") { const id = s.slice(1); if (!slots[id]) slots[id] = make(id); return slots[id]; } return null; }, + addEventListener: (type, fn) => { if (type === "click") clickHandler = fn; }, + createElement: () => make("x"), body: make("body"), + }; + let getState = initialState; + let getStateCalls = 0; + const fetchShim = async (url, init) => { + if (init && init.method === "POST") return { ok: true, json: async () => (opts.actionResponse || { ok: true, message: "ok" }) }; + getStateCalls++; + return { ok: true, json: async () => getState }; + }; + new Function("window", "document", "EventSource", "fetch", "setTimeout", "clearTimeout", "console", script)(win, doc, ES, fetchShim, setTimeout, clearTimeout, console); + const settle = async () => { await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); }; + return { + async ready() { await settle(); return this; }, + app: () => (slots.app ? slots.app._html : ""), + getStateCalls: () => getStateCalls, + setGetState(s) { getState = s; }, + async clickKind(kind, payload) { + const btnEl = { disabled: false, dataset: { kind, payload: JSON.stringify(payload || {}) } }; + const ev = { target: { closest: (sel) => (sel.indexOf("nav.tabs") === 0 ? null : btnEl) } }; + clickHandler(ev); + await settle(); + }, + async reconnect() { if (es.onopen) es.onopen(); await settle(); }, + }; +} + +test("renderer: recheck_env applies the inline result and never strands the spinner", async () => { + // Backend returns a fresh, now-ready snapshot inline (res.state) — the client + // must apply it directly, not fall back to the agent-style 'pending' banner + // that only clears on an SSE broadcast (which can be lost on a provider restart). + const blocked = sampleState({ doctor: { overall: "blocked", generatedAt: "t", groups: [{ id: "build", name: "Build", checks: [{ id: "jdk", label: "JDK", status: "fail", detail: "java not found" }] }] } }); + const ready = sampleState({ doctor: { overall: "ready", generatedAt: "t2", groups: [{ id: "build", name: "Build", checks: [{ id: "jdk", label: "JDK", status: "ok", detail: "Java 21" }] }] } }); + const c = await driveClient(blocked, { initialTab: "readiness", actionResponse: { ok: true, message: "Re-checked environment", state: ready } }).ready(); + assert.match(c.app(), /Not ready yet/); // starts blocked + await c.clickKind("recheck_env", {}); + assert.match(c.app(), /Your environment is ready/); // inline state applied + assert.ok(!/Watch the chat/.test(c.app()), "must not show the agent 'pending' spinner banner"); + assert.equal(c.getStateCalls(), 1, "recheck must not need an extra /state fetch (uses inline state)"); +}); + +test("renderer: an SSE reconnect resyncs state (recovers a missed broadcast)", async () => { + const blocked = sampleState({ doctor: { overall: "blocked", generatedAt: "t", groups: [{ id: "build", name: "Build", checks: [{ id: "jdk", label: "JDK", status: "fail", detail: "x" }] }] } }); + const ready = sampleState({ doctor: { overall: "ready", generatedAt: "t2", groups: [{ id: "build", name: "Build", checks: [{ id: "jdk", label: "JDK", status: "ok", detail: "Java 21" }] }] } }); + const c = await driveClient(blocked, { initialTab: "readiness" }).ready(); + const before = c.getStateCalls(); + assert.match(c.app(), /Not ready yet/); + // Provider finished the recheck after a restart that dropped the broadcast; + // backend state is now ready. The stream reconnects -> client must re-fetch. + c.setGetState(ready); + await c.reconnect(); + assert.equal(c.getStateCalls(), before + 1, "onopen must re-fetch /state"); + assert.match(c.app(), /Your environment is ready/); +}); + +// =========================================================================== +// server.mjs +// =========================================================================== + +test("server: resolveRepoPath precedence input > session > last, else null", () => { + assert.equal( + resolveRepoPath({ input: { repoPath: "/in" }, session: { workingDirectory: "/s" } }, "/last"), + "/in" + ); + assert.equal(resolveRepoPath({ session: { workingDirectory: "/s" } }, "/last"), "/s"); + assert.equal(resolveRepoPath({}, "/last"), "/last"); + // No process.cwd() fallback — unresolved means null (UI shows "repo not available"). + assert.equal(resolveRepoPath({}, null), null); + assert.equal(resolveRepoPath({}, undefined), null); +}); + +test("server: broadcast writes SSE frame to every client", () => { + const chunks = []; + const rec = { sseClients: new Set([{ write: (s) => chunks.push(s) }]) }; + broadcast(rec, { type: "state", state: { ok: true } }); + assert.equal(chunks.length, 1); + assert.match(chunks[0], /^data: /); + assert.match(chunks[0], /"type":"state"/); +}); + +test("server: pushState scans repo and broadcasts snapshot", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + const chunks = []; + const rec = { repoPath: dir, sseClients: new Set([{ write: (s) => chunks.push(s) }]) }; + const state = await pushState(rec); + assert.equal(state.ok, true); + assert.equal(chunks.length, 1); + assert.match(chunks[0], /"buildTool":"Maven"/); + }); +}); + +test("server: dispatch refresh re-scans without sending a prompt", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + let sent = 0; + const rec = { repoPath: dir, sseClients: new Set() }; + const res = await dispatchAction(rec, "refresh", {}, { sendPrompt: async () => sent++ }); + assert.equal(res.ok, true); + assert.equal(sent, 0); + }); +}); + +test("server: dispatch agent action forwards crafted prompt to sendPrompt", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const sentPrompts = []; + const res = await dispatchAction(rec, "run_cve", {}, { sendPrompt: async (p) => sentPrompts.push(p) }); + assert.equal(res.ok, true); + assert.equal(res.label, "CVE scan"); + assert.equal(sentPrompts.length, 1); + assert.match(sentPrompts[0], /#appmod-validate-cves-for-java/); +}); + +test("server: dispatch unknown action returns error, no send", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + let sent = 0; + const res = await dispatchAction(rec, "bogus", {}, { sendPrompt: async () => sent++ }); + assert.equal(res.ok, false); + assert.match(res.error, /Unknown action/); + assert.equal(sent, 0); +}); + +test("server: dispatch without sendPrompt reports session not ready", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const res = await dispatchAction(rec, "run_cve", {}, {}); + assert.equal(res.ok, false); + assert.match(res.error, /not ready/); +}); + +test("server: handler GET / serves HTML", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const handler = makeHandler(rec, { instanceId: "i", initialTab: "overview", sendPrompt: async () => {} }); + const { res } = await invokeGet(handler, "/"); + assert.match(res.headers["content-type"], /text\/html/); + assert.match(res.body, /Java Modernization Studio/); +}); + +test("server: handler GET /state serves scanned JSON", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + const rec = { repoPath: dir, sseClients: new Set() }; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => {} }); + const { res } = await invokeGet(handler, "/state"); + const json = JSON.parse(res.body); + assert.equal(json.ok, true); + assert.equal(json.repoPath, dir); + }); +}); + +test("server: handler GET /events registers and cleans up SSE client", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => {} }); + const { req } = await invokeGet(handler, "/events"); + assert.equal(rec.sseClients.size, 1); + req.emit("close"); + assert.equal(rec.sseClients.size, 0); +}); + +test("server: handler POST /action runs dispatch", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const sent = []; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async (p) => sent.push(p) }); + const res = await invokePost(handler, "/action", { kind: "run_cve" }); + const json = JSON.parse(res.body); + assert.equal(json.ok, true); + assert.equal(sent.length, 1); +}); + +test("server: token guard forbids requests without the per-instance token", async () => { + const rec = { repoPath: "/repo", sseClients: new Set(), token: "s3cret" }; + const sent = []; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async (p) => sent.push(p) }); + for (const path of ["/", "/state", "/events"]) { + const { res } = await invokeGet(handler, path); + assert.equal(res.statusCode, 403, path + " without a token must be forbidden"); + } + const post = await invokePost(handler, "/action", { kind: "run_cve" }); + assert.equal(post.statusCode, 403, "/action without a token must be forbidden"); + assert.equal(sent.length, 0, "no action is dispatched for an unauthenticated request"); + assert.equal(rec.sseClients.size, 0, "no SSE client is registered for an unauthenticated request"); +}); + +test("server: token guard allows requests carrying the correct token", async () => { + const rec = { repoPath: "/repo", sseClients: new Set(), token: "s3cret" }; + const sent = []; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async (p) => sent.push(p) }); + const { req } = await invokeGet(handler, "/events?token=s3cret"); + assert.equal(rec.sseClients.size, 1, "a valid token registers the SSE client"); + req.emit("close"); + const post = await invokePost(handler, "/action?token=s3cret", { kind: "run_cve" }); + const json = JSON.parse(post.body); + assert.equal(json.ok, true); + assert.equal(sent.length, 1); +}); + +test("server: POST /action rejects an oversized request body (413)", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const sent = []; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async (p) => sent.push(p) }); + const req = new EventEmitter(); + req.method = "POST"; + req.url = "/action"; + const res = fakeRes(); + const pending = handler(req, res); + req.emit("data", "x".repeat(300 * 1024)); // > 256 KB cap + req.emit("end"); + await pending; + assert.equal(res.statusCode, 413); + const json = JSON.parse(res.body); + assert.equal(json.ok, false); + assert.match(json.error, /too large/i); + assert.equal(sent.length, 0, "no action is dispatched for a rejected body"); +}); + +test("server: handler unknown route 404s", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => {} }); + const { res } = await invokeGet(handler, "/nope"); + assert.equal(res.statusCode, 404); +}); + +test("server: broadcast drops a client whose write fails (no leak, no repeat throw)", () => { + const live = []; + const dead = { + write() { + throw new Error("EPIPE: client gone"); + }, + }; + const rec = { sseClients: new Set([dead, { write: (s) => live.push(s) }]) }; + broadcast(rec, { type: "state", state: { ok: true } }); + assert.ok(!rec.sseClients.has(dead), "a client whose write throws is removed from the Set"); + assert.equal(rec.sseClients.size, 1, "the healthy client is retained"); + assert.equal(live.length, 1, "the healthy client still receives the frame"); + // A later broadcast must not keep throwing now that the dead client is gone. + assert.doesNotThrow(() => broadcast(rec, { type: "state", state: { ok: true } })); + assert.equal(live.length, 2); +}); + +test("server: POST /action rejects malformed JSON with 400 and dispatches nothing", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + let sent = 0; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => sent++ }); + const req = new EventEmitter(); + req.method = "POST"; + req.url = "/action"; + const res = fakeRes(); + const pending = handler(req, res); + req.emit("data", "{ not valid json "); + req.emit("end"); + await pending; + assert.equal(res.statusCode, 400); + assert.match(res.headers["content-type"], /application\/json/); + const json = JSON.parse(res.body); + assert.equal(json.ok, false); + assert.match(json.error, /invalid JSON/i); + assert.equal(sent, 0, "a malformed body dispatches no action"); +}); + +test("server: POST /action rejects a missing/invalid kind with 400", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + let sent = 0; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => sent++ }); + const res = await invokePost(handler, "/action", { payload: {} }); // no kind + assert.equal(res.statusCode, 400); + assert.match(res.headers["content-type"], /application\/json/); + const json = JSON.parse(res.body); + assert.equal(json.ok, false); + assert.match(json.error, /kind/i); + assert.equal(sent, 0, "a kindless request dispatches no action"); +}); + +test("server: a handler exception returns 500 with a JSON content-type", async () => { + // A throwing repoPath getter makes buildState (GET /state) reject, exercising + // the handler's outer catch — which must still label the error body as JSON. + const rec = { + sseClients: new Set(), + get repoPath() { + throw new Error("boom"); + }, + }; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => {} }); + const { res } = await invokeGet(handler, "/state"); + assert.equal(res.statusCode, 500); + assert.match(res.headers["content-type"], /application\/json/); + const json = JSON.parse(res.body); + assert.equal(json.ok, false); +}); + +test("server: recheck_env runs the doctor, caches it, and broadcasts (no agent prompt)", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + let runs = 0; + const chunks = []; + const rec = { + repoPath: dir, + sseClients: new Set([{ write: (s) => chunks.push(s) }]), + doctor: null, + runDoctor: async () => ({ overall: "blocked", groups: [], runs: ++runs }), + }; + let sent = 0; + const res = await dispatchAction(rec, "recheck_env", {}, { sendPrompt: async () => sent++ }); + assert.equal(res.ok, true); + assert.equal(sent, 0, "recheck_env does not message the agent"); + assert.equal(rec.doctor.overall, "blocked"); + assert.equal(runs, 1); + assert.match(chunks[0], /"overall":"blocked"/); + }); +}); + +test("server: createInstanceServer binds a loopback socket end-to-end", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + const sent = []; + const rec = await createInstanceServer({ + instanceId: "live", + repoPath: dir, + initialTab: "overview", + sendPrompt: async (p) => sent.push(p), + runDoctor: async () => ({ overall: "ready", groups: [] }), + }); + try { + assert.match(rec.url, /^http:\/\/127\.0\.0\.1:\d+\/\?token=[a-f0-9]+$/); + const u = new URL(rec.url); + const base = u.origin; + const q = "?token=" + u.searchParams.get("token"); + + const home = await fetch(rec.url); + assert.equal(home.status, 200); + assert.match(await home.text(), /Java Modernization Studio/); + + const state = await (await fetch(base + "/state" + q)).json(); + assert.equal(state.ok, true); + assert.equal(state.assessment.buildTool, "Maven"); + assert.equal(state.doctor.overall, "ready", "doctor report is attached to state"); + + const action = await fetch(base + "/action" + q, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "generate_tests" }), + }); + const aj = await action.json(); + assert.equal(aj.ok, true); + assert.equal(sent.length, 1); + + // A request that omits the per-instance token must be refused, even though + // it reached the right loopback port. + const unauth = await fetch(base + "/state"); + assert.equal(unauth.status, 403, "missing token is rejected at the socket"); + } finally { + await new Promise((r) => rec.server.close(() => r())); + } + }); +}); + +// ---- autopilot -------------------------------------------------------------- + +// Build an evolving plan model: snapshot() reflects current statuses and a fresh +// activeRank; markNextDone() simulates the agent checking a step off. +function planSim(steps) { + const model = steps.map((s) => ({ section: null, rank: null, status: "pending", ...s })); + const activeRank = () => { + const notDone = model.filter((s) => s.status !== "done" && s.rank != null); + return notDone.length ? Math.min(...notDone.map((s) => s.rank)) : null; + }; + return { + model, + snapshot: async () => ({ + ok: true, + progress: { steps: model.map((s) => ({ ...s })) }, + plan: { steps: model.map((s) => ({ ...s })) }, + ordering: { activeRank: activeRank() }, + }), + markNextDone() { + const notDone = model.find((s) => s.status !== "done"); + if (notDone) notDone.status = "done"; + }, + }; +} + +test("autopilot: selectNextStep follows phase order then falls back", () => { + const s = { + progress: { steps: [ + { title: "A", status: "done", rank: 1 }, + { title: "B", status: "pending", rank: 1 }, + { title: "C", status: "pending", rank: 2 }, + ] }, + ordering: { activeRank: 1 }, + }; + assert.equal(selectNextStep(s).title, "B", "picks the active-phase pending step"); + const s2 = { progress: { steps: [{ title: "X", status: "done", rank: 1 }] }, ordering: { activeRank: null } }; + assert.equal(selectNextStep(s2), null, "null when nothing is pending"); + assert.equal(selectNextStep({}), null, "null when there are no steps"); +}); + +test("autopilot: stepKey is stable and isStepDone reads status", () => { + const step = { title: "Upgrade", section: "P0 Build" }; + assert.equal(stepKey(step), "P0 Build::Upgrade"); + const state = { progress: { steps: [{ title: "Upgrade", section: "P0 Build", status: "done" }] } }; + assert.equal(isStepDone(state, step), true); + assert.equal(isStepDone({ progress: { steps: [] } }, step), false); +}); + +test("autopilot: auto_step prompt is autonomous and never commits", () => { + assert.equal(buildPrompt("auto_step", {}, "/repo"), null, "no title -> no prompt"); + const p = buildPrompt("auto_step", { title: "Bump Spring", section: "P1 Security" }, "/repo"); + assert.match(p, /AUTOPILOT/); + assert.match(p, /Bump Spring/); + assert.match(p, /Do NOT commit/); + assert.match(p, /one step/i); +}); + +test("autopilot: runs every step to completion (happy path)", async () => { + const sim = planSim([ + { title: "A", section: "P0 Build", rank: 1 }, + { title: "B", section: "P0 Build", rank: 1 }, + ]); + const run = makeRun({ scope: "all", maxSteps: 10, startRank: 1 }); + const prompts = []; + await runAutopilot(run, { + snapshot: sim.snapshot, + runTurn: async (p) => { prompts.push(p); sim.markNextDone(); }, + buildStepPrompt: (step) => step.title, + onProgress: () => {}, + }); + assert.equal(run.status, "completed"); + assert.equal(run.completed.length, 2); + assert.ok(run.completed.every((c) => c.done), "each step ended up checked off"); + assert.deepEqual(prompts, ["A", "B"]); + assert.equal(run.running, false); +}); + +test("autopilot: phase scope stops at the phase boundary", async () => { + const sim = planSim([ + { title: "A", section: "P0 Build", rank: 1 }, + { title: "B", section: "P1 Security", rank: 2 }, + ]); + const run = makeRun({ scope: "phase", maxSteps: 10, startRank: 1 }); + await runAutopilot(run, { + snapshot: sim.snapshot, + runTurn: async () => sim.markNextDone(), + buildStepPrompt: (step) => step.title, + onProgress: () => {}, + }); + assert.equal(run.status, "phase_done"); + assert.equal(run.completed.length, 1, "only the P0 step ran; it paused before P1"); + assert.equal(run.completed[0].title, "A"); +}); + +test("autopilot: stops (stuck) when a step does not get checked off", async () => { + const sim = planSim([{ title: "Tricky", section: "P0 Build", rank: 1 }]); + const run = makeRun({ scope: "all", maxSteps: 10, startRank: 1 }); + await runAutopilot(run, { + snapshot: sim.snapshot, + runTurn: async () => { /* agent fails to complete the step */ }, + buildStepPrompt: (step) => step.title, + onProgress: () => {}, + }); + assert.equal(run.status, "stuck"); + assert.equal(run.stuck, "Tricky"); + assert.equal(run.completed.length, 1); + assert.equal(run.completed[0].done, false); +}); + +test("autopilot: honors cancellation between steps", async () => { + const sim = planSim([ + { title: "A", section: "P0 Build", rank: 1 }, + { title: "B", section: "P0 Build", rank: 1 }, + ]); + const run = makeRun({ scope: "all", maxSteps: 10, startRank: 1 }); + await runAutopilot(run, { + snapshot: sim.snapshot, + runTurn: async () => sim.markNextDone(), + buildStepPrompt: (step) => step.title, + onProgress: (r) => { if (r.current) r.cancelled = true; }, + }); + assert.equal(run.status, "cancelled"); + assert.equal(run.completed.length, 1, "the in-flight step finished, then it stopped"); +}); + +test("server: autopilot_start refuses without a runTurn driver", async () => { + const rec = { repoPath: "/nope", sseClients: new Set(), doctor: null, autopilot: null }; + const res = await dispatchAction(rec, "autopilot_start", {}, { sendPrompt: async () => {}, log: () => {} }); + assert.equal(res.ok, false); + assert.match(res.error, /drive/i); + assert.equal(rec.autopilot, null); +}); + +test("server: autopilot_start is blocked when the environment is not ready", async () => { + await withRepo({ ...PROV, "pom.xml": "", "progress.md": "## P0 Build\n- [ ] Fix build\n" }, async (dir) => { + const rec = { + repoPath: dir, + sseClients: new Set(), + doctor: null, + autopilot: null, + runDoctor: async () => ({ overall: "blocked", groups: [] }), + }; + const res = await dispatchAction(rec, "autopilot_start", {}, { runTurn: async () => {}, log: () => {} }); + assert.equal(res.ok, false); + assert.match(res.error, /environment/i); + assert.equal(rec.autopilot, null, "no run starts while blocked"); + }); +}); + +test("server: autopilot_start drives the loop and broadcasts progress", async () => { + await withRepo({ ...PROV, "pom.xml": "", "progress.md": "## P0 Build\n- [ ] Fix the build\n" }, async (dir) => { + const chunks = []; + const rec = { + repoPath: dir, + sseClients: new Set([{ write: (s) => chunks.push(s) }]), + doctor: null, + autopilot: null, + runDoctor: async () => ({ overall: "ready", groups: [] }), + }; + let turns = 0; + const res = await dispatchAction(rec, "autopilot_start", { scope: "phase" }, { runTurn: async () => { turns++; }, log: () => {} }); + assert.equal(res.ok, true); + assert.ok(rec.autopilot, "a run record is attached"); + await rec.autopilotPromise; // let the loop settle + assert.equal(rec.autopilot.running, false); + assert.ok(turns >= 1, "the agent was driven at least once"); + // The step never gets checked off (runTurn is a stub), so it pauses as stuck. + assert.equal(rec.autopilot.status, "stuck"); + assert.ok(chunks.some((c) => /autopilot|"type":"state"/.test(c)), "progress was broadcast"); + }); +}); + +test("server: autopilot_stop and autopilot_dismiss behave by run state", async () => { + const rec = { repoPath: "/x", sseClients: new Set(), doctor: null, autopilot: null }; + const stopIdle = await dispatchAction(rec, "autopilot_stop", {}, {}); + assert.equal(stopIdle.ok, false, "nothing to stop when idle"); + + rec.autopilot = { running: true, cancelled: false, completed: [] }; + const stopRunning = await dispatchAction(rec, "autopilot_stop", {}, {}); + assert.equal(stopRunning.ok, true); + assert.equal(rec.autopilot.cancelled, true, "stop flips the cancel flag"); + + rec.autopilot = { running: false, status: "completed", completed: [] }; + await withRepo({ "pom.xml": "" }, async (dir) => { + rec.repoPath = dir; + const res = await dispatchAction(rec, "autopilot_dismiss", {}, {}); + assert.equal(res.ok, true); + assert.equal(rec.autopilot, null, "dismiss clears a finished run"); + }); +}); + +test("renderer: ships the Autopilot controls (start/stop, card, live strip)", () => { + const html = renderHtml({ instanceId: "x" }); + assert.match(html, /autopilot_start/); + assert.match(html, /autopilot_stop/); + assert.match(html, /Autopilot/); + assert.match(html, /autopilotStrip/); +});