diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e985fedd..948167f8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "migration-to-aws", "source": "./migrate/plugins/migration-to-aws", "version": "1.5.0", - "description": "Migrate from GCP or Heroku to AWS — including your entire AI stack. Moves infrastructure (Cloud Run → Fargate, Heroku Dynos → Fargate, databases → RDS/Aurora), OpenAI/Gemini workloads to Amazon Bedrock, and agentic systems (LangChain, CrewAI, AutoGen, OpenAI Agents SDK) to AWS-native frameworks. Generates runnable Terraform, migration scripts, provider adapters, and deployment artifacts. Gives honest model-by-model pricing comparisons so you know exactly when Bedrock saves money and when it doesn't. The bundled llm-to-bedrock skill executes AI/LLM migrations automatically (rewrite, evaluate, deliver a branch)." + "description": "Migrate from GCP or Heroku to AWS — including your entire AI stack — or get an honest Vercel-to-AWS assessment. Moves infrastructure (Cloud Run → Fargate, Heroku Dynos → Fargate, databases → RDS/Aurora), OpenAI/Gemini workloads to Amazon Bedrock, and agentic systems (LangChain, CrewAI, AutoGen, OpenAI Agents SDK) to AWS-native frameworks. For Vercel: computes a Coupling Score and 10 Pre-Flight Checks, then recommends OpenNext/SST, ECS Fargate, or a Vercel+AWS Hybrid via a fixed, auditable decision order. Generates runnable Terraform, migration scripts, provider adapters, and deployment artifacts. Gives honest model-by-model pricing comparisons so you know exactly when Bedrock saves money and when it doesn't. The bundled llm-to-bedrock skill executes AI/LLM migrations automatically (rewrite, evaluate, deliver a branch)." }, { "name": "aws-startup-advisor", diff --git a/migrate/README.md b/migrate/README.md index 1b5ea629..84561236 100644 --- a/migrate/README.md +++ b/migrate/README.md @@ -10,6 +10,7 @@ Point this plugin at your Terraform files, application code, or billing data. It - **GCP → AWS** — Cloud Run, Cloud SQL, GKE, Cloud Functions, Pub/Sub, Cloud Storage, VPC, and AI/agentic workloads - **Heroku → AWS** — Dynos (→ Elastic Beanstalk by default; Fargate or EKS overrides), Postgres, Redis, Kafka, Private Spaces, Pipelines, and 13+ common add-ons +- **Vercel → AWS** — an honest assessment (discovery, Coupling Score, Pre-Flight Checks, a three-outcome recommendation) for Next.js apps, with an optional thin scaffold **For infrastructure migrations:** diff --git a/migrate/plugins/migration-to-aws/.claude-plugin/plugin.json b/migrate/plugins/migration-to-aws/.claude-plugin/plugin.json index 25c38da9..da320160 100644 --- a/migrate/plugins/migration-to-aws/.claude-plugin/plugin.json +++ b/migrate/plugins/migration-to-aws/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "migration-to-aws", "version": "1.5.0", - "description": "This no-cost tool assesses your current cloud provider's usage, geography, and billing data to estimate and compare AWS services and pricing, and recommends migration or continued use of your current provider. Supports GCP and Heroku migrations. AWS pricing is based on current published pricing and may vary over time. The tool may generate a .migration folder containing comparison and migration execution data, which you may delete upon completion or use to migrate to AWS.", + "description": "This no-cost tool assesses your current cloud provider's usage, geography, and billing data to estimate and compare AWS services and pricing, and recommends migration or continued use of your current provider. Supports GCP, Heroku, and Vercel migrations. AWS pricing is based on current published pricing and may vary over time. The tool may generate a .migration folder containing comparison and migration execution data, which you may delete upon completion or use to migrate to AWS.", "author": { "name": "Amazon Web Services" }, @@ -56,6 +56,11 @@ "temporal-cloud", "temporal-workers", "temporal-to-aws", - "durable-execution" + "durable-execution", + "vercel", + "vercel-to-aws", + "next-js", + "nextjs", + "opennext" ] } diff --git a/migrate/plugins/migration-to-aws/.codex-plugin/plugin.json b/migrate/plugins/migration-to-aws/.codex-plugin/plugin.json index 30f634b3..320d15fe 100644 --- a/migrate/plugins/migration-to-aws/.codex-plugin/plugin.json +++ b/migrate/plugins/migration-to-aws/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "migration-to-aws", "version": "1.5.0", - "description": "Migrate from GCP or Heroku to AWS — including your entire AI stack. Moves infrastructure (Cloud Run → Fargate, Heroku Dynos → Fargate, databases → RDS/Aurora), OpenAI/Gemini workloads to Amazon Bedrock, and agentic systems (LangChain, CrewAI, AutoGen, OpenAI Agents SDK) to AWS-native frameworks. Generates runnable Terraform, migration scripts, provider adapters, and deployment artifacts. Gives honest model-by-model pricing comparisons so you know exactly when Bedrock saves money and when it doesn't.", + "description": "Migrate from GCP or Heroku to AWS \u2014 including your entire AI stack. Moves infrastructure (Cloud Run \u2192 Fargate, Heroku Dynos \u2192 Fargate, databases \u2192 RDS/Aurora), OpenAI/Gemini workloads to Amazon Bedrock, and agentic systems (LangChain, CrewAI, AutoGen, OpenAI Agents SDK) to AWS-native frameworks. Generates runnable Terraform, migration scripts, provider adapters, and deployment artifacts. Gives honest model-by-model pricing comparisons so you know exactly when Bedrock saves money and when it doesn't.", "author": { "name": "Amazon Web Services", "url": "https://aws.amazon.com" @@ -59,14 +59,19 @@ "temporal-cloud", "temporal-workers", "temporal-to-aws", - "durable-execution" + "durable-execution", + "vercel", + "vercel-to-aws", + "next-js", + "nextjs", + "opennext" ], "skills": "./skills/", "mcpServers": "./.mcp.json", "interface": { "displayName": "GCP to AWS Migration", "shortDescription": "Migrate GCP infrastructure and AI workloads to AWS with architecture mapping, cost analysis, and runnable Terraform.", - "longDescription": "Point this plugin at your Terraform files, application code, or GCP billing data. It runs a structured 6-phase migration assessment — discovering what you have, asking the right questions, designing the AWS architecture, estimating costs with real pricing data, and generating runnable migration artifacts.\n\nFor AI-focused startups, it detects your entire AI stack (models, agents, tools, orchestration patterns) and recommends three migration paths: retarget (keep your framework, swap models), AgentCore Harness (config-based managed agents), or Strands Agents (AWS-native multi-agent SDK).\n\nGenerates Terraform configurations, migration scripts, provider adapters, harness.json, and documentation — all tailored to your specific stack.", + "longDescription": "Point this plugin at your Terraform files, application code, or GCP billing data. It runs a structured 6-phase migration assessment \u2014 discovering what you have, asking the right questions, designing the AWS architecture, estimating costs with real pricing data, and generating runnable migration artifacts.\n\nFor AI-focused startups, it detects your entire AI stack (models, agents, tools, orchestration patterns) and recommends three migration paths: retarget (keep your framework, swap models), AgentCore Harness (config-based managed agents), or Strands Agents (AWS-native multi-agent SDK).\n\nGenerates Terraform configurations, migration scripts, provider adapters, harness.json, and documentation \u2014 all tailored to your specific stack.", "developerName": "Amazon Web Services", "category": "Developer Tools", "capabilities": [ diff --git a/migrate/plugins/migration-to-aws/.cursor-plugin/plugin.json b/migrate/plugins/migration-to-aws/.cursor-plugin/plugin.json index 7e57a76e..7f02bbf4 100644 --- a/migrate/plugins/migration-to-aws/.cursor-plugin/plugin.json +++ b/migrate/plugins/migration-to-aws/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "migration-to-aws", "displayName": "Migration to AWS", "version": "1.5.0", - "description": "Migrate from GCP or Heroku to AWS — including your entire AI stack. Moves infrastructure (Cloud Run → Fargate, Heroku Dynos → Fargate, databases → RDS/Aurora), OpenAI/Gemini workloads to Amazon Bedrock, and agentic systems (LangChain, CrewAI, AutoGen, OpenAI Agents SDK) to AWS-native frameworks. Generates runnable Terraform, migration scripts, provider adapters, and deployment artifacts. Gives honest model-by-model pricing comparisons so you know exactly when Bedrock saves money and when it doesn’t.", + "description": "Migrate from GCP or Heroku to AWS \u2014 including your entire AI stack. Moves infrastructure (Cloud Run \u2192 Fargate, Heroku Dynos \u2192 Fargate, databases \u2192 RDS/Aurora), OpenAI/Gemini workloads to Amazon Bedrock, and agentic systems (LangChain, CrewAI, AutoGen, OpenAI Agents SDK) to AWS-native frameworks. Generates runnable Terraform, migration scripts, provider adapters, and deployment artifacts. Gives honest model-by-model pricing comparisons so you know exactly when Bedrock saves money and when it doesn\u2019t.", "author": { "name": "Amazon Web Services" }, @@ -57,6 +57,11 @@ "temporal-cloud", "temporal-workers", "temporal-to-aws", - "durable-execution" + "durable-execution", + "vercel", + "vercel-to-aws", + "next-js", + "nextjs", + "opennext" ] } diff --git a/migrate/plugins/migration-to-aws/README.md b/migrate/plugins/migration-to-aws/README.md index 98945f52..654b4fa8 100644 --- a/migrate/plugins/migration-to-aws/README.md +++ b/migrate/plugins/migration-to-aws/README.md @@ -164,6 +164,7 @@ Pass `--estimation-infra` / `--estimation-ai` only when those files exist. Resol | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **gcp-to-aws** | "migrate GCP to AWS", "move from GCP", "GCP migration plan", "migrate Cloud SQL to RDS or Aurora", "move Cloud Run to Fargate", "estimate AWS costs for my GCP infrastructure", "migrate my OpenAI app to Bedrock", "migrate my LangChain agents to AWS" | | **heroku-to-aws** | "migrate from Heroku", "Heroku to AWS", "move off Heroku", "migrate Heroku Postgres to RDS", "migrate dynos to Elastic Beanstalk", "migrate dynos to Fargate", "migrate Heroku Private Space", "leave Heroku", "estimate AWS costs for my Heroku app" | +| **vercel-to-aws** | "migrate from Vercel", "Vercel to AWS", "move off Vercel", "migrate Next.js off Vercel", "assess my Vercel migration", "leave Vercel", "Vercel to Fargate", "Vercel to OpenNext", "should I migrate off Vercel" | ### MCP Servers diff --git a/migrate/plugins/migration-to-aws/fixtures/assessment-report-reference.html b/migrate/plugins/migration-to-aws/fixtures/assessment-report-reference.html new file mode 100644 index 00000000..f955fd55 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/assessment-report-reference.html @@ -0,0 +1,82 @@ + + + + +Vercel-to-AWS Assessment - reference-startup + + + + + + +
+

Recommendation: migrate to ECS Fargate. Your sustained traffic and small team's preference for predictable, debuggable infrastructure both point the same way.

+
+ +
+

Owning your CDN configuration means you can cache aggressively enough to serve fewer origin requests, not just cheaper ones. Your bill becomes predictable instead of usage-spiky.

+

Separately, AWS Activate offers eligible early-stage startups credits (Founders tier: up to $5,000 self-service; Portfolio tier: up to $200,000 for VC/accelerator-backed companies) that apply directly to the AWS services this migration would use. Worth checking eligibility before finalizing a budget.

+
+ +
+

Preview deployments as you know them go away first. Your reviewers will need a different workflow for testing branches before merge. Skew protection (keeping mismatched client/server deploys from breaking users mid-deploy) also needs to be handled explicitly on AWS instead of automatically.

+
+ +
+

Two findings above would upgrade with more input: the ISR completeness finding would firm up if you confirm your autoscaling instance count, and the uncached-route cost estimate would go from a rough guess to a firm number with 7-14 days of log drain data.

+
+ +
+

We detected middleware.ts on your project. Its matcher intersects several cached routes, and it appears to perform an authentication check. On every AWS target, a cached response from the CDN skips middleware entirely - so a visitor could receive a cached page that never had its auth check run.

+
+ +
+ + + + + + + +
FeatureDetectedWhy it matters
ISR / revalidationYesPortable, needs a tag cache and queue together
Edge middlewareYesBehavioral divergence on every AWS target
Preview deploymentsYes (load-bearing)No AWS equivalent exists
+
+ +
+
Your middleware runs auth checks on some pages that are also cached by the CDN - those checks get skipped on a cache hit.
+
A handful of your API routes fire much more often than they're cached, which is estimated monthly cost of roughly $85 you could trim with better caching.
+
+ +
+
The recommendation rule that fired: your team said production ownership and predictable debugging matter more than minimal ops overhead, and your traffic is steady rather than spiky - that combination pointed to the container-based path over the serverless one.
+
+ +
+

If you were a single low-traffic app with no AWS credits, a small VPS would be the more rational choice and this tooling would not be for you. That is not your situation here, since a separable backend surface exists.

+
+ +
+
    +
  1. Review the coupling score and pre-flight findings above with your team.
  2. +
  3. Optionally upgrading to Next.js 16.2+ would unlock higher-confidence discovery for a future re-assessment - entirely your call, not required to proceed.
  4. +
  5. Opt in to the scaffold checkpoint when you are ready for a working Terraform skeleton.
  6. +
+
+ + + + + diff --git a/migrate/plugins/migration-to-aws/fixtures/assessment-report-stub.html b/migrate/plugins/migration-to-aws/fixtures/assessment-report-stub.html new file mode 100644 index 00000000..bf07c3d1 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/assessment-report-stub.html @@ -0,0 +1,36 @@ + + + + +Vercel-to-AWS Assessment - stub + + + +
+

M1 fired at HIGH severity based on route_disposition analysis. See preflight-findings.json for details.

+
+ +
+

Some cost savings are possible.

+
+ +
+

Preview deployments. TODO: fill in the rest of this section.

+
+ +
+

See coupling-score.json for the full breakdown.

+
+ +
+

An uncached route costs $85 extra per month.

+
+ +
+ +
+ + + diff --git a/migrate/plugins/migration-to-aws/fixtures/preflight-findings-reference.json b/migrate/plugins/migration-to-aws/fixtures/preflight-findings-reference.json new file mode 100644 index 00000000..816959a9 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/preflight-findings-reference.json @@ -0,0 +1,105 @@ +{ + "phase": "discover", + "timestamp": "2026-07-22T14:00:00Z", + "checks": [ + { + "id": "M1", + "detected": true, + "severity": "HIGH", + "applies_to": [ + "A", + "B", + "C" + ], + "confidence": "HIGH" + }, + { + "id": "M2", + "detected": false, + "severity": "MEDIUM", + "applies_to": [ + "A", + "B", + "C" + ], + "confidence": "HIGH" + }, + { + "id": "B1", + "detected": false, + "severity": "HIGH", + "applies_to": [ + "A" + ], + "confidence": "HIGH" + }, + { + "id": "B2", + "detected": false, + "severity": "MEDIUM", + "applies_to": [ + "A" + ], + "confidence": "HIGH" + }, + { + "id": "B3", + "detected": false, + "severity": "LOW", + "applies_to": [ + "A" + ], + "confidence": "HIGH" + }, + { + "id": "B4", + "detected": false, + "severity": "LOW", + "applies_to": [ + "A" + ], + "confidence": "HIGH" + }, + { + "id": "S1", + "detected": false, + "severity": "MEDIUM", + "applies_to": [ + "A" + ], + "confidence": "HIGH" + }, + { + "id": "I1", + "detected": true, + "severity": "HIGH", + "applies_to": [ + "A", + "B" + ], + "confidence": "MEDIUM", + "upgrade_input": "confirm autoscaling instance count" + }, + { + "id": "O1", + "detected": false, + "severity": "advisory", + "applies_to": [ + "A" + ], + "confidence": "HIGH" + }, + { + "id": "U1", + "detected": true, + "severity": "informational", + "applies_to": [ + "A", + "B", + "C" + ], + "confidence": "LOW", + "upgrade_input": "7-14 day log drain export" + } + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/recommendation-reference.json b/migrate/plugins/migration-to-aws/fixtures/recommendation-reference.json new file mode 100644 index 00000000..52008e1a --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/recommendation-reference.json @@ -0,0 +1,15 @@ +{ + "phase": "recommend", + "timestamp": "2026-07-22T14:00:00Z", + "outcome": "B", + "fired_rule": 3, + "tiebreak": false, + "separable": null, + "backend_shape": null, + "confidence": "high", + "reasons": [ + "Sustained traffic (not spiky)", + "Team stated a debuggability preference over minimal ops overhead" + ], + "resolving_input": null +} diff --git a/migrate/plugins/migration-to-aws/fixtures/tier1-signals-reference.json b/migrate/plugins/migration-to-aws/fixtures/tier1-signals-reference.json new file mode 100644 index 00000000..4437cfea --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/tier1-signals-reference.json @@ -0,0 +1,19 @@ +{ + "phase": "prescan", + "timestamp": "2026-07-22T14:00:00Z", + "repo_access": true, + "next_build_health": "clean", + "vercel_token_present": true, + "project_list": [ + "reference-startup-web" + ], + "project_scoping_needed": false, + "next_version": "15.3.0", + "package_manager": "pnpm@9.0.0", + "has_sharp_dependency": false, + "lockfile_census": [ + "pnpm-lock.yaml" + ], + "has_middleware": true, + "has_vercel_json": true +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/.gitignore b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/.gitignore new file mode 100644 index 00000000..36dd2b24 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/.gitignore @@ -0,0 +1,5 @@ +# The repo-root .gitignore excludes build/ globally. This fixture's +# capture/build/ holds CANNED .next build manifests (committed test data +# mirroring the real capture layout), not build output — re-include them. +!capture/build/ +!capture/build/** diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/README.md b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/README.md new file mode 100644 index 00000000..b3b009a8 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/README.md @@ -0,0 +1,52 @@ +# Vercel capture fixtures (replay mode) + +Canned capture output for testing the `vercel-to-aws` Discover phase's +capture/parse split (`discover-capture.md` → parse-only fragments → assembler) +without a Vercel account or a real Next.js build. The synthetic project +`acme-shop` exercises the designed-for paths: + +- **Manifest fallback** — Next.js 15.1.0 (< 16.2), so `build.method: + "manifests"`; the captured `capture/build/` manifests (`routes-manifest` / + `prerender-manifest` / `app-path-routes-manifest`, exactly the three files + the capture manifest's `build.files` names) drive route dispositions, + including the two subtle + cases: `/blog/[slug]` is genuinely ISR (real `initialRevalidateSeconds` + entries in `prerender-manifest.json`), and `/api/checkout` is a Route Handler + the manifests do NOT classify (must land `dynamic` at LOW confidence with the + Adapter-API `upgrade_input`). +- **API captures with realistic gaps** — crons endpoint `skipped` (404 on plan; + the `vercel.json` cron remains the source), usage aggregates `skipped`; env + capture already reduced to KEY NAMES ONLY, deliberately including + secret-looking names (`STRIPE_SECRET_KEY`, `KV_REST_API_TOKEN`) — if a value + ever appears in output, the projection rule broke. +- **Storage integrations** — a KV store and a Postgres store feed + `peripherals[]` (the separability check's input). +- **No probe** — `probe.attempted: false`; the probe fragment must not run. +- **Workspace files** — `package.json`, `vercel.json` (cron + headers + + `maxDuration`), `middleware.ts` (auth gate + rewrite, with matcher), + `next.config.js` for `discover-configs.md`. + +## How to replay + +1. Create a scratch directory; copy `workspace/*` into its root. +2. Create `.migration/0721-1725/` inside it; copy `capture/` and `seed/*` + (including the dot-file `.phase-status.json`) into that run directory. + The seed marks prescan `completed` (with its two artifacts) and discover + `in_progress` — the state right after the capture pre-work finished. +3. Invoke the vercel-to-aws skill's Discover phase (resume the run). +4. Discover's capture `_precondition` passes via the existing + `capture/manifest.json`; the fragments parse the captures — zero network + calls, zero builds, no token anywhere. +5. Check the run dir against `expected-discovery.json`: + `python3 check_expected_discovery.py ` (exits non-zero on any + failed assertion, including secret-hygiene checks). + +**What a run must never produce:** env var values or token material anywhere; +any network call or `next build`; AWS recommendations inside discover +artifacts; a halt caused by the skipped crons/usage captures. + +## Regenerating / extending + +Captures follow `discover-capture.md`'s endpoint whitelist and file naming. If +you add an endpoint: whitelist row first, then fixture, then extend +`expected-discovery.json`. All ids, names, and hostnames are synthetic. diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/deployments-acme-shop.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/deployments-acme-shop.json new file mode 100644 index 00000000..23930114 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/deployments-acme-shop.json @@ -0,0 +1,25 @@ +{ + "deployments": [ + { + "uid": "dpl_prod042", + "state": "READY", + "target": "production", + "created": 1784050000000, + "url": "acme-shop-h3k2j1.vercel.app" + }, + { + "uid": "dpl_prev107", + "state": "READY", + "target": null, + "created": 1784040000000, + "url": "acme-shop-git-feat-cart-acme.vercel.app" + }, + { + "uid": "dpl_prev106", + "state": "ERROR", + "target": null, + "created": 1784030000000, + "url": "acme-shop-git-fix-nav-acme.vercel.app" + } + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/domains-acme-shop.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/domains-acme-shop.json new file mode 100644 index 00000000..5c8e3411 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/domains-acme-shop.json @@ -0,0 +1,6 @@ +{ + "domains": [ + { "name": "acme-shop.vercel.app", "apexName": "vercel.app", "verified": true }, + { "name": "www.acme-shop.com", "apexName": "acme-shop.com", "verified": true } + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/env-keys-acme-shop.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/env-keys-acme-shop.json new file mode 100644 index 00000000..4d958205 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/env-keys-acme-shop.json @@ -0,0 +1,9 @@ +[ + "DATABASE_URL", + "KV_REST_API_TOKEN", + "KV_REST_API_URL", + "NEXT_PUBLIC_SITE_URL", + "NODE_ENV", + "STRIPE_SECRET_KEY", + "STRIPE_WEBHOOK_SECRET" +] diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/projects.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/projects.json new file mode 100644 index 00000000..f98a02db --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/projects.json @@ -0,0 +1,13 @@ +{ + "projects": [ + { + "id": "prj_acmeshop01", + "name": "acme-shop", + "framework": "nextjs", + "nodeVersion": "20.x", + "latestDeployments": [ + { "id": "dpl_prod042", "target": "production", "readyState": "READY", "createdAt": 1784050000000 } + ] + } + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/stores.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/stores.json new file mode 100644 index 00000000..aaf92edb --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/stores.json @@ -0,0 +1,6 @@ +{ + "stores": [ + { "id": "store_kv_01", "type": "kv", "name": "acme-cart-cache", "region": "iad1", "sizeBytes": 52428800 }, + { "id": "store_pg_01", "type": "postgres", "name": "acme-orders-db", "region": "iad1", "sizeBytes": 3221225472 } + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/teams.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/teams.json new file mode 100644 index 00000000..5a51586e --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/api/teams.json @@ -0,0 +1,5 @@ +{ + "teams": [ + { "id": "team_acme01", "slug": "acme", "name": "Acme Inc" } + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/build/app-path-routes-manifest.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/build/app-path-routes-manifest.json new file mode 100644 index 00000000..fb2b0505 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/build/app-path-routes-manifest.json @@ -0,0 +1,6 @@ +{ + "/page": "/", + "/blog/[slug]/page": "/blog/[slug]", + "/dashboard/page": "/dashboard", + "/api/checkout/route": "/api/checkout" +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/build/prerender-manifest.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/build/prerender-manifest.json new file mode 100644 index 00000000..a2acd8e9 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/build/prerender-manifest.json @@ -0,0 +1,37 @@ +{ + "version": 4, + "routes": { + "/": { + "initialRevalidateSeconds": false, + "srcRoute": "/", + "dataRoute": "/index.rsc", + "experimentalBypassFor": [] + }, + "/blog/launch-post": { + "initialRevalidateSeconds": 3600, + "srcRoute": "/blog/[slug]", + "dataRoute": "/blog/launch-post.rsc", + "experimentalBypassFor": [] + }, + "/blog/hiring": { + "initialRevalidateSeconds": 3600, + "srcRoute": "/blog/[slug]", + "dataRoute": "/blog/hiring.rsc", + "experimentalBypassFor": [] + } + }, + "dynamicRoutes": { + "/blog/[slug]": { + "routeRegex": "^/blog/([^/]+?)(?:/)?$", + "dataRoute": "/blog/[slug].rsc", + "fallback": null, + "dataRouteRegex": "^/blog/([^/]+?)\\.rsc$" + } + }, + "notFoundRoutes": [], + "preview": { + "previewModeId": "fixture-preview-id-not-a-secret", + "previewModeSigningKey": "fixture-signing-key-not-a-secret", + "previewModeEncryptionKey": "fixture-encryption-key-not-a-secret" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/build/routes-manifest.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/build/routes-manifest.json new file mode 100644 index 00000000..1e0563ed --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/build/routes-manifest.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "pages404": true, + "caseSensitive": false, + "basePath": "", + "redirects": [], + "headers": [], + "rewrites": { "beforeFiles": [], "afterFiles": [], "fallback": [] }, + "staticRoutes": [ + { + "page": "/", + "regex": "^/(?:/)?$", + "routeKeys": {}, + "namedRegex": "^/(?:/)?$" + }, + { + "page": "/dashboard", + "regex": "^/dashboard(?:/)?$", + "routeKeys": {}, + "namedRegex": "^/dashboard(?:/)?$" + } + ], + "dynamicRoutes": [ + { + "page": "/blog/[slug]", + "regex": "^/blog/([^/]+?)(?:/)?$", + "routeKeys": { "nxtPslug": "nxtPslug" }, + "namedRegex": "^/blog/(?[^/]+?)(?:/)?$" + } + ], + "dataRoutes": [], + "rsc": { + "header": "RSC", + "varyHeader": "RSC, Next-Router-State-Tree, Next-Router-Prefetch" + }, + "i18n": null +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/manifest.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/manifest.json new file mode 100644 index 00000000..e0b5e9c5 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/capture/manifest.json @@ -0,0 +1,52 @@ +{ + "captured_at": "2026-07-21T17:30:00Z", + "build": { + "method": "manifests", + "files": ["build/routes-manifest.json", "build/prerender-manifest.json", "build/app-path-routes-manifest.json"], + "prescan_discrepancy": false, + "reason": "next_version 15.1.0 < 16.2 — Adapter API unavailable; manifests from existing .next/" + }, + "api": [ + { "endpoint": "/v2/teams", "file": "api/teams.json", "status": "ok", "note": null }, + { "endpoint": "/v10/projects?teamId=team_acme01", "file": "api/projects.json", "status": "ok", "note": null }, + { + "endpoint": "/v6/deployments?projectId=prj_acmeshop01&limit=20", + "file": "api/deployments-acme-shop.json", + "status": "ok", + "note": null + }, + { + "endpoint": "/v10/projects/prj_acmeshop01/env?teamId=team_acme01&decrypt=false (key-name projection)", + "file": "api/env-keys-acme-shop.json", + "status": "ok", + "note": "reduced to key names via jq before write" + }, + { + "endpoint": "/v9/projects/prj_acmeshop01/domains?teamId=team_acme01", + "file": "api/domains-acme-shop.json", + "status": "ok", + "note": null + }, + { + "endpoint": "project cron configuration (OpenAPI-discovered row 6)", + "file": "api/crons-acme-shop.json", + "status": "skipped", + "discovered_via": "vercel api list", + "note": "no GET endpoint with a crons path segment found via vercel api list — crons from vercel.json only" + }, + { + "endpoint": "/v1/storage/stores?teamId=team_abc123", + "file": "api/stores.json", + "status": "ok", + "discovered_via": "vercel api list", + "note": "GET endpoint with a storage/stores path segment located via vercel api list; exact resolved path recorded per the rows 6–7 discovery constraints" + }, + { + "endpoint": "usage aggregates", + "file": "api/usage-acme-shop.json", + "status": "skipped", + "note": "no usage aggregates exposed on hobby/pro plan without observability" + } + ], + "probe": { "attempted": false, "routes": [] } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/check_expected_discovery.py b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/check_expected_discovery.py new file mode 100644 index 00000000..aca730ae --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/check_expected_discovery.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Assert a Discover run's output against expected-discovery.json. + +Usage: + python3 check_expected_discovery.py + +Where contains discovery.json, coupling-score.json, and +preflight-findings.json produced by a replay of this fixture (see README.md). +Exits 0 on PASS, 1 on FAIL with one line per failed assertion. Stdlib only. +(Same pattern as the heroku/gcp live-capture asserters.) +""" + +import json +import sys +from pathlib import Path + +FAILS: list[str] = [] + + +def check(cond: bool, msg: str) -> None: + if not cond: + FAILS.append(msg) + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__) + return 2 + run_dir = Path(sys.argv[1]) + fixture_dir = Path(__file__).resolve().parent + exp = json.loads((fixture_dir / "expected-discovery.json").read_text()) + + docs = {} + for name in exp["artifacts_must_exist"]: + p = run_dir / name + if not p.exists(): + check(False, f"missing artifact {name}") + continue + docs[name] = json.loads(p.read_text()) + if "discovery.json" not in docs: + print(f"FAIL ({len(FAILS)}):") + for f in FAILS: + print(f" - {f}") + return 1 + + disc = docs["discovery.json"] + disc_text = json.dumps(disc) + + # Route dispositions + rd = {r["route"]: r for r in disc.get("route_disposition", [])} + for route, want in exp["route_dispositions"].items(): + r = rd.get(route) + if r is None: + check(False, f"route {route} missing from route_disposition") + continue + check(r.get("disposition") == want, f"route {route} disposition={r.get('disposition')} want {want}") + api_route = rd.get("/api/checkout") + if api_route is not None: + check(api_route.get("confidence") == "LOW", "/api/checkout must be LOW confidence (manifest sources don't classify Route Handlers)") + + # Manifest metadata + mm = disc.get("manifest_metadata", {}) + check(mm.get("adapter_api_used") is False, "manifest_metadata.adapter_api_used must be false") + + # Env var names: exact, names only + check(sorted(disc.get("env_var_names", [])) == sorted(exp["env_var_names_exact"]), f"env_var_names mismatch: {sorted(disc.get('env_var_names', []))}") + + # Storage integrations / peripherals + stores = disc.get("storage_integrations", []) + check(len(stores) >= exp["storage_integrations_min"], f"storage_integrations count {len(stores)}") + ptypes = {p.get("type") for p in disc.get("peripherals", [])} | {s.get("type") for s in stores} + for t in exp["peripheral_types_must_include"]: + check(t in ptypes, f"peripheral type {t} missing") + + # Domains + domains = json.dumps(disc.get("domains", [])) + for d in exp["domains_must_include"]: + check(d in domains, f"domain {d} missing") + + # Probe absent + check(not disc.get("header_probe_results"), "header_probe_results must be absent/empty (probe.attempted false)") + + # Pre-flight checks: all 10, unconditional + pf = docs.get("preflight-findings.json", {}) + pf_text = json.dumps(pf) + for c in exp["preflight_checks_all_10"]: + check(f'"{c}"' in pf_text, f"pre-flight check {c} missing") + + # Confidence rules across discovery + preflight findings + def walk_findings(node, path="$"): + if isinstance(node, dict): + if "confidence" in node: + check(node["confidence"] in exp["confidence_rules"]["every_finding_has_confidence"], f"bad confidence at {path}") + if node["confidence"] != "HIGH" and exp["confidence_rules"]["non_high_findings_have_upgrade_input"]: + check("upgrade_input" in node, f"non-HIGH finding missing upgrade_input at {path}") + for k, v in node.items(): + walk_findings(v, f"{path}.{k}") + elif isinstance(node, list): + for i, v in enumerate(node): + walk_findings(v, f"{path}[{i}]") + + walk_findings(disc, "discovery") + walk_findings(pf, "preflight") + + # Secret hygiene + all_text = disc_text + pf_text + json.dumps(docs.get("coupling-score.json", {})) + for bad in ("VERCEL_TOKEN", "Bearer ", "vcp_", "sk_live"): + check(bad not in all_text, f"possible token/secret material: {bad}") + + def walk_env(node, path="$"): + if isinstance(node, dict): + if "name" in node and ("value" in node or "valueFrom" in node): + check(False, f"env-like object with a value payload at {path}") + for k, v in node.items(): + walk_env(v, f"{path}.{k}") + elif isinstance(node, list): + for i, v in enumerate(node): + walk_env(v, f"{path}[{i}]") + + walk_env(disc) + for key_name in ("STRIPE_SECRET_KEY", "DATABASE_URL"): + check(f'"{key_name}": ' not in disc_text, f"env name {key_name} appears as a KEY (value paired) — names must be list items only") + + if FAILS: + print(f"FAIL ({len(FAILS)}):") + for f in FAILS: + print(f" - {f}") + return 1 + print("PASS — expected-discovery.json assertions hold") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/expected-discovery.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/expected-discovery.json new file mode 100644 index 00000000..0be31f96 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/expected-discovery.json @@ -0,0 +1,39 @@ +{ + "_comment": "Assertions for a Discover-phase replay of this fixture (see README.md). Checked against the run dir's discovery.json, coupling-score.json, and preflight-findings.json by check_expected_discovery.py — not a full expected document.", + "artifacts_must_exist": ["discovery.json", "coupling-score.json", "preflight-findings.json"], + "route_dispositions": { + "/": "static", + "/blog/[slug]": "isr", + "/dashboard": "dynamic", + "/api/checkout": "dynamic" + }, + "route_disposition_notes": { + "/blog/[slug]": "appears in prerender-manifest dynamicRoutes with ISR entries (launch-post/hiring at 3600s) — must be isr, not dynamic", + "/api/checkout": "Route Handler — manifests don't classify it; must be dynamic at LOW confidence with the Adapter-API upgrade_input" + }, + "manifest_metadata": { "adapter_api_used": false }, + "env_var_names_exact": [ + "DATABASE_URL", + "KV_REST_API_TOKEN", + "KV_REST_API_URL", + "NEXT_PUBLIC_SITE_URL", + "NODE_ENV", + "STRIPE_SECRET_KEY", + "STRIPE_WEBHOOK_SECRET" + ], + "storage_integrations_min": 2, + "peripheral_types_must_include": ["kv", "postgres"], + "domains_must_include": ["www.acme-shop.com"], + "sections_unavailable_ok": ["crons (api capture skipped — vercel.json cron remains)", "usage_metrics"], + "probe": { "must_not_have_results": true, "reason": "manifest records probe.attempted: false" }, + "preflight_checks_all_10": ["M1", "M2", "B1", "B2", "B3", "B4", "S1", "I1", "O1", "U1"], + "confidence_rules": { + "every_finding_has_confidence": ["LOW", "MEDIUM", "HIGH"], + "non_high_findings_have_upgrade_input": true + }, + "must_not_exist_anywhere": [ + "any env var VALUE (fixture env entries are names only)", + "any token material (VERCEL_TOKEN, Bearer, vcp_, sk_live)", + "AWS service recommendations inside discover artifacts" + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed/.phase-status.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed/.phase-status.json new file mode 100644 index 00000000..7572b41e --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed/.phase-status.json @@ -0,0 +1,13 @@ +{ + "migration_id": "0721-1725", + "last_updated": "2026-07-21T17:26:00Z", + "current_phase": "discover", + "phases": { + "prescan": "completed", + "discover": "in_progress", + "clarify": "pending", + "recommend": "pending", + "scaffold": "pending", + "report": "pending" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed/assessment-state.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed/assessment-state.json new file mode 100644 index 00000000..bfd93b19 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed/assessment-state.json @@ -0,0 +1,17 @@ +{ + "schema_version": "1.0", + "migration_id": "0721-1725", + "last_updated": "2026-07-21T17:25:00Z", + "inputs_received": { + "tier1": { + "repo_access": { "received": true, "received_at": "2026-07-21T17:25:00Z" }, + "vercel_api_token": { "received": true, "received_at": "2026-07-21T17:25:00Z" }, + "project_scope": { "received": true, "received_at": "2026-07-21T17:25:00Z" } + }, + "tier2": {}, + "tier3": {} + }, + "findings": {}, + "clarify_answers": {}, + "report_history": [] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed/tier1-signals.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed/tier1-signals.json new file mode 100644 index 00000000..417480d4 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed/tier1-signals.json @@ -0,0 +1,15 @@ +{ + "phase": "prescan", + "timestamp": "2026-07-21T17:25:00Z", + "repo_access": true, + "next_build_health": "clean", + "vercel_token_present": true, + "project_list": ["acme-shop"], + "project_scoping_needed": false, + "next_version": "15.1.0", + "package_manager": "pnpm@9.1.0", + "has_sharp_dependency": false, + "lockfile_census": ["pnpm-lock.yaml"], + "has_middleware": true, + "has_vercel_json": true +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/workspace/middleware.ts b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/workspace/middleware.ts new file mode 100644 index 00000000..0366b097 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/workspace/middleware.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +// Fixture middleware: auth-gate the dashboard, rewrite legacy blog paths. +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (pathname.startsWith("/dashboard")) { + const session = request.cookies.get("session"); + if (!session) { + return NextResponse.redirect(new URL("/login", request.url)); + } + } + + if (pathname.startsWith("/old-blog/")) { + return NextResponse.rewrite( + new URL(pathname.replace("/old-blog/", "/blog/"), request.url), + ); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/dashboard/:path*", "/old-blog/:path*"], +}; diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/workspace/next.config.js b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/workspace/next.config.js new file mode 100644 index 00000000..6bcb211a --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/workspace/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [{ protocol: "https", hostname: "cdn.acme-shop.com" }], + }, + experimental: {}, +}; + +module.exports = nextConfig; diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/workspace/package.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/workspace/package.json new file mode 100644 index 00000000..ffb91238 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/workspace/package.json @@ -0,0 +1,18 @@ +{ + "name": "acme-shop", + "private": true, + "packageManager": "pnpm@9.1.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@vercel/kv": "1.0.1", + "@vercel/postgres": "0.9.0", + "next": "15.1.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "stripe": "16.2.0" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/workspace/vercel.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/workspace/vercel.json new file mode 100644 index 00000000..863a8b61 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/workspace/vercel.json @@ -0,0 +1,14 @@ +{ + "crons": [ + { "path": "/api/cron/digest", "schedule": "0 8 * * *" } + ], + "functions": { + "app/api/checkout/route.ts": { "maxDuration": 30 } + }, + "headers": [ + { + "source": "/(.*)", + "headers": [{ "key": "X-Frame-Options", "value": "DENY" }] + } + ] +} diff --git a/migrate/plugins/migration-to-aws/scripts/validate-assessment-report.py b/migrate/plugins/migration-to-aws/scripts/validate-assessment-report.py new file mode 100644 index 00000000..238c758b --- /dev/null +++ b/migrate/plugins/migration-to-aws/scripts/validate-assessment-report.py @@ -0,0 +1,604 @@ +#!/usr/bin/env python3 +"""Validate assessment-report.html completeness after the Report phase. + +Fork of scripts/validate-migration-report.py (GCP skill), adapted for +vercel-to-aws's outcome-filtered pre-flight-finding report structure. Checks +required section IDs, TOC anchor integrity, minimum appendix content, the +reader-vocabulary rule (no Pre-Flight Check IDs / artifact filenames / +Terraform resource IDs / "route disposition" in executive-flow sections), the +cost-labeling rule (every dollar figure phrased as "estimated monthly"), and +fixture-bleed detection. Exit 0 on PASS, 1 on FAIL, anything else means this +script itself did not run (e.g. python3 missing) - the caller must branch on +the shell exit code, never on stdout text alone. + +Usage: + python3 validate-assessment-report.py /path/to/assessment-report.html + python3 validate-assessment-report.py report.html \\ + --recommendation recommendation.json \\ + --preflight-findings preflight-findings.json \\ + --tier1-signals tier1-signals.json \\ + --migration-dir "$MIGRATION_DIR" + +Script location: this file lives at + migrate/plugins/migration-to-aws/scripts/validate-assessment-report.py +Agents should invoke it via Path(__file__) resolution or: + python3 "$(dirname ...)/scripts/validate-assessment-report.py" ... +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +# Plugin root: migrate/plugins/migration-to-aws/ +PLUGIN_ROOT = Path(__file__).resolve().parent.parent + +# Requirement 9.1 / design.md 4.2 - always-required sections. +REQUIRED_SECTION_IDS = [ + "exec-verdict", + "what-you-gain", + "what-you-lose", + "coupling-score", + "preflight-findings", + "decision-traceability", + "next-steps", +] + +# Conditional sections - required only when their trigger condition holds +# (checked separately in validate_report(), not via REQUIRED_SECTION_IDS). +CONDITIONAL_SECTION_IDS = { + "exec-tiebreak": "recommendation.tiebreak == true", + "inputs-received": "any finding below HIGH confidence", + "appendix-m1": "tier1-signals.has_middleware == true", + "out-of-scope": "recommendation.outcome is 'C' or 'stay'", +} + +OPTIONAL_SECTION_IDS = list(CONDITIONAL_SECTION_IDS.keys()) + +FORBIDDEN_PATTERNS = [ + (r"\[placeholder\]", "placeholder text"), + (r"\bTODO\b", "TODO marker"), +] + +# Customer-facing readability rules (enforced unless --no-readability). +READABILITY_PATTERNS = [ + ( + r"Rubric:", + 'internal scoring trace ("Rubric:") - drop it or gate behind a ' + '
"Why this mapping?" block', + ), + ( + r"Section\s+0\b", + 'literal "Section 0" heading - drop numeric "Section N" prefixes from ' + "customer-facing headings; let the table of contents carry structure", + ), + ( + r"]*>\s*Section\s+\d+[a-z]?\s*[\u2014-]", + 'numbered "Section N -" heading - drop numeric prefixes from headings; ' + "let the table of contents carry structure", + ), +] + +# Executive-flow sections must speak the founder's language, not the system's. +# Pre-Flight Check IDs, artifact filenames, Terraform resource IDs, and the +# term "route disposition" are internal build vocabulary - they belong in the +# technical appendices, not the executive summary (Requirement 9.7). +EXEC_SECTION_IDS = ( + "exec-verdict", + "exec-tiebreak", + "what-you-gain", + "what-you-lose", +) + +ARTIFACT_FILENAME_RE = re.compile(r"\b[a-z0-9][a-z0-9_-]*\.json\b", re.IGNORECASE) +TERRAFORM_RESOURCE_RE = re.compile(r"\baws_[a-z0-9_]+\.[a-z0-9_]+\b") +# The 10 named Pre-Flight Check IDs: M1, M2, B1-B4, S1, I1, O1, U1. +PREFLIGHT_CHECK_ID_RE = re.compile(r"\b(M1|M2|B[1-4]|S1|I1|O1|U1)\b") +ROUTE_DISPOSITION_RE = re.compile(r"route disposition", re.IGNORECASE) + +APPENDIX_STUB_PATTERNS = [ + re.compile( + r']*id="preflight-findings"[^>]*>.*?Full findings:\s*preflight-findings\.json', + re.DOTALL | re.IGNORECASE, + ), + re.compile( + r']*id="coupling-score"[^>]*>\s*

\s*See\s*coupling-score\.json', + re.DOTALL | re.IGNORECASE, + ), +] + +MIN_CONTENT_DEPTH = { + "coupling-score": 3, + "preflight-findings": 2, + "decision-traceability": 1, +} + +SECTION_OPEN = re.compile( + r"]*\bid=(['\"])([^'\"]+)\1", + re.IGNORECASE, +) + +# Migration ID baked into the reference fixture. If this appears in a real +# $MIGRATION_DIR run, the agent copied the golden file verbatim (fixture bleed). +# Distinct from the GCP skill's canary (0611-0606) so the two never collide. +FIXTURE_CANARY_ID = "0722-1400" +MIGRATION_ID_RE = re.compile(r"\b(\d{4}-\d{4})\b") + +# Requirement 9.6 - every dollar figure must be phrased as "estimated monthly". +# Matches $123, $1,234.56, $1234, etc. +DOLLAR_AMOUNT_RE = re.compile(r"\$[0-9][0-9,]*(?:\.[0-9]{1,2})?") +ESTIMATED_MONTHLY_RE = re.compile(r"estimated\s+monthly", re.IGNORECASE) + +# AWS Activate credit ceilings (e.g. "up to $5,000 in AWS Activate credits") +# are one-time program limits, not a recurring cost or savings figure - +# forcing "estimated monthly" onto them would misrepresent a credit ceiling as +# a monthly estimate. Exempt a dollar figure from the cost-labeling rule ONLY +# when "activate" appears within the same window already used for the +# "estimated monthly" proximity check. Deliberately narrower than a bare +# "credit(s)" match (which would also match unrelated phrases like "credit +# card" or "store credit" and could let a real, unlabeled cost slip through) - +# "activate" is specific enough to this program that it should not appear +# near a dollar figure for any other reason in this report. +ACTIVATE_CREDIT_CONTEXT_RE = re.compile(r"\bactivate\b", re.IGNORECASE) + +# NOTE: _section_html uses non-greedy match to first . This assumes +# sections are NOT nested. Do not nest

elements in assessment reports. + + +def plugin_script_path() -> Path: + """Return absolute path to this validator (for agent invocation).""" + return Path(__file__).resolve() + + +def _section_html(html: str, section_id: str) -> str | None: + pattern = re.compile( + rf"]*\bid=\"{re.escape(section_id)}\"[^>]*>(.*?)
", + re.DOTALL | re.IGNORECASE, + ) + match = pattern.search(html) + return match.group(1) if match else None + + +def _section_id_counts(html: str) -> dict[str, int]: + counts: dict[str, int] = {} + for match in SECTION_OPEN.finditer(html): + sid = match.group(2) + counts[sid] = counts.get(sid, 0) + 1 + return counts + + +def _validate_required_sections(html: str) -> list[str]: + errors: list[str] = [] + counts = _section_id_counts(html) + for section_id in REQUIRED_SECTION_IDS: + n = counts.get(section_id, 0) + if n == 0: + errors.append(f'missing required
') + elif n > 1: + errors.append(f'duplicate
({n} occurrences)') + return errors + + +def _validate_conditional_sections( + html: str, + recommendation: dict | None, + preflight_findings: dict | None, + tier1_signals: dict | None, +) -> list[str]: + """Requirement 9.2-9.5 - the four conditional-gate sections.""" + errors: list[str] = [] + counts = _section_id_counts(html) + + if recommendation and recommendation.get("tiebreak") is True: + if counts.get("exec-tiebreak", 0) < 1: + errors.append( + 'recommendation.tiebreak is true but no
' + "(the Outcome A/B side-by-side section is required per Requirement 9.2)" + ) + + if preflight_findings: + checks = preflight_findings.get("checks", []) + any_sub_high = any(c.get("confidence", "HIGH") != "HIGH" for c in checks) + if any_sub_high and counts.get("inputs-received", 0) < 1: + errors.append( + 'a finding is below HIGH confidence but no
' + "(the confidence-upgrade-offers section is required per Requirement 9.3)" + ) + + if tier1_signals and tier1_signals.get("has_middleware") is True: + if counts.get("appendix-m1", 0) < 1: + errors.append( + 'tier1-signals.has_middleware is true but no
' + "(required per Requirement 9.4)" + ) + + if recommendation and recommendation.get("outcome") in ("C", "stay"): + if counts.get("out-of-scope", 0) < 1: + errors.append( + f'recommendation.outcome is "{recommendation.get("outcome")}" but no ' + '
(the separability rationale is required ' + "per Requirement 9.5)" + ) + + return errors + + +def _toc_hrefs(html: str) -> list[str]: + nav_match = re.search( + r"]*\bclass=[\"'][^\"']*toc[^\"']*[\"'][^>]*>(.*?)", + html, + re.DOTALL | re.IGNORECASE, + ) + if not nav_match: + return [] + return re.findall(r'href="#([^"]+)"', nav_match.group(1), re.IGNORECASE) + + +def _validate_toc(html: str) -> list[str]: + errors: list[str] = [] + hrefs = _toc_hrefs(html) + if not hrefs: + return errors # TOC optional if nav.toc absent; spec requires it in generated reports + + section_ids = set(_section_id_counts(html).keys()) + for href in hrefs: + if href not in section_ids: + errors.append(f'TOC broken link href="#{href}" - no matching
') + + for section_id in REQUIRED_SECTION_IDS: + if section_id in section_ids and section_id not in hrefs and hrefs: + errors.append( + f'TOC missing link to required section id="{section_id}" ' + f'(add )' + ) + return errors + + +def _count_table_rows(section_html: str) -> int: + tbody = re.search(r"(.*?)", section_html, re.DOTALL | re.IGNORECASE) + if not tbody: + return 0 + return len(re.findall(r" int: + rows = _count_table_rows(section_html) + if section_id == "preflight-findings": + cards = len(re.findall(r'class="preflight-check-card"', section_html)) + return max(rows, cards) + if section_id == "decision-traceability": + entries = len(re.findall(r'class="trace-entry"', section_html)) + return max(rows, entries, 1 if re.search(r"fired\b", section_html, re.IGNORECASE) else 0) + return rows + + +def _readability_scope(html: str) -> str: + """Body only, excluding ", "", html, flags=re.DOTALL | re.IGNORECASE) + body = re.search(r"]*>(.*?)", no_style, re.DOTALL | re.IGNORECASE) + return body.group(1) if body else no_style + + +def _validate_readability(html: str) -> list[str]: + errors: list[str] = [] + scope = _readability_scope(html) + for pattern, label in READABILITY_PATTERNS: + if re.search(pattern, scope, re.IGNORECASE): + errors.append(f"readability: {label}") + return errors + + +def _validate_exec_vocabulary(html: str) -> list[str]: + """Requirement 9.7 - executive-flow sections must name what the founder + controls, not internal identifiers. Pre-Flight Check IDs, artifact + filenames, Terraform resource IDs, and "route disposition" belong only in + technical appendices. Appendix sections are exempt by design.""" + errors: list[str] = [] + for sid in EXEC_SECTION_IDS: + section = _section_html(html, sid) + if not section: + continue + filenames = sorted(set(m.lower() for m in ARTIFACT_FILENAME_RE.findall(section))) + resources = sorted(set(TERRAFORM_RESOURCE_RE.findall(section))) + check_ids = sorted(set(PREFLIGHT_CHECK_ID_RE.findall(section))) + has_route_disposition = bool(ROUTE_DISPOSITION_RE.search(section)) + if filenames: + errors.append( + f'exec vocabulary:
exposes artifact filename(s) ' + f"{filenames} - name what the founder controls in the executive flow; " + "keep artifact filenames in the technical appendices" + ) + if resources: + errors.append( + f'exec vocabulary:
exposes Terraform resource ID(s) ' + f"{resources} - move resource names to the appendix" + ) + if check_ids: + errors.append( + f'exec vocabulary:
exposes Pre-Flight Check ID(s) ' + f'{check_ids} (e.g. "M1") - describe the behavior in plain language ' + '("your middleware skips on cached pages"), not the check ID' + ) + if has_route_disposition: + errors.append( + f'exec vocabulary:
uses the term "route disposition" - ' + "this is internal build vocabulary; describe the behavior in plain language" + ) + return errors + + +def _validate_cost_labeling(html: str) -> list[str]: + """Requirement 9.6 - every dollar figure anywhere in the report body must + be phrased as "estimated monthly cost/savings", including U1's cost-driver + figures, even though full cost estimation is deferred to v2. Scoped to + table cells and sentences (a $ figure and "estimated monthly" must appear + within the same ... or within ~120 characters of each other). + + Exception: an AWS Activate credit ceiling (e.g. "up to $5,000 in AWS + Activate credits") is a one-time program limit, not a recurring cost or + savings estimate - "estimated monthly" would misdescribe it. Exempted only + when "Activate" appears in the same proximity window (deliberately not a + bare "credit(s)" match, which would also match unrelated phrases like + "credit card" and could let a real, unlabeled cost slip through).""" + errors: list[str] = [] + scope = _readability_scope(html) + + # Scan table cells first (most dollar figures live in tables). + for cell_match in re.finditer(r"]*>(.*?)", scope, re.DOTALL | re.IGNORECASE): + cell = cell_match.group(1) + if DOLLAR_AMOUNT_RE.search(cell) and not ESTIMATED_MONTHLY_RE.search(cell): + # allow "estimated monthly" in an adjacent header cell/caption - do a + # widened check against a window around the cell before flagging. + start = max(0, cell_match.start() - 200) + end = min(len(scope), cell_match.end() + 200) + window = scope[start:end] + if ESTIMATED_MONTHLY_RE.search(window): + continue + if ACTIVATE_CREDIT_CONTEXT_RE.search(window): + continue # one-time credit ceiling, not a cost/savings estimate + amount = DOLLAR_AMOUNT_RE.search(cell).group(0) + errors.append( + f'cost-labeling: dollar figure "{amount}" appears without "estimated ' + 'monthly" nearby - every dollar figure must be phrased as "estimated ' + 'monthly cost/savings" (Requirement 9.6, applies even to U1 findings) - ' + 'unless it is an AWS Activate credit ceiling, which reads "Activate" ' + "nearby instead" + ) + + # Then scan prose outside tables for stray dollar figures. + prose = re.sub(r"", "", scope, flags=re.DOTALL | re.IGNORECASE) + for amount_match in DOLLAR_AMOUNT_RE.finditer(prose): + start = max(0, amount_match.start() - 120) + end = min(len(prose), amount_match.end() + 120) + window = prose[start:end] + if ESTIMATED_MONTHLY_RE.search(window): + continue + if ACTIVATE_CREDIT_CONTEXT_RE.search(window): + continue # one-time credit ceiling, not a cost/savings estimate + errors.append( + f'cost-labeling: dollar figure "{amount_match.group(0)}" in prose appears ' + 'without "estimated monthly" nearby (Requirement 9.6) - unless it is an AWS ' + 'Activate credit ceiling, which reads "Activate" nearby instead' + ) + + return errors + + +def _validate_action_lists(html: str) -> list[str]: + """Requirement 9.1 - Next Steps must be an ordered list.""" + errors: list[str] = [] + next_steps = _section_html(html, "next-steps") or "" + if next_steps and not re.search(r" (ordered action items), not a bullet list ' + "or plain paragraphs (Requirement 9.1)" + ) + return errors + + +def _validate_decision_traceability(html: str, recommendation: dict | None) -> list[str]: + """Requirement 10.1-10.4 - the decision-traceability appendix is ALWAYS + required (checked in REQUIRED_SECTION_IDS) and must name the fired rule.""" + errors: list[str] = [] + section = _section_html(html, "decision-traceability") + if section is None: + return errors # already flagged by _validate_required_sections + if not re.search(r"\bfired\b|\brule\b", section, re.IGNORECASE): + errors.append( + "decision-traceability appendix must state which precedence rule fired " + "and why (Requirement 10.1, 10.3)" + ) + if recommendation and recommendation.get("tiebreak") is True: + if not re.search(r"log drain|resolving", section, re.IGNORECASE): + errors.append( + "decision-traceability appendix must state which rule would have applied " + "had the missing input (log drain data) been available, since the " + "tiebreak fired (Requirement 10.4)" + ) + return errors + + +def _validate_verdict(html: str, recommendation: dict | None) -> list[str]: + """Requirement 9.2 - exec-verdict must state a one-sentence verdict, not + only badges.""" + if not recommendation: + return [] + section = _section_html(html, "exec-verdict") or "" + if not section: + return [] # already flagged by _validate_required_sections + if re.search(r'class="[^"]*\bverdict\b[^"]*"', section, re.IGNORECASE): + return [] + if re.search(r"Recommendation:", section): + return [] + return [ + 'exec-verdict section exists but has no verdict banner ' + '(add an element with class="verdict" or a "Recommendation:" sentence)' + ] + + +def _validate_fixture_bleed(html: str, migration_dir: Path | None) -> list[str]: + """Catch agents that copied the reference fixture verbatim into a real run. + + Only active when --migration-dir is passed (i.e. validating a real + $MIGRATION_DIR report, not the fixture itself). Fails if the fixture canary + ID appears, or if the report's stated migration ID does not match the run dir. + """ + if migration_dir is None: + return [] # fixture-self-exemption: no run dir - don't flag the canary + + errors: list[str] = [] + dir_name = migration_dir.name + body = _readability_scope(html) + + if FIXTURE_CANARY_ID in body and dir_name != FIXTURE_CANARY_ID: + errors.append( + f'fixture bleed: reference canary migration ID "{FIXTURE_CANARY_ID}" appears in a ' + f'real run (--migration-dir={dir_name}) - the report was copied from the fixture' + ) + + ids_in_report = {m.group(1) for m in MIGRATION_ID_RE.finditer(body)} + if re.fullmatch(r"\d{4}-\d{4}", dir_name) and ids_in_report and dir_name not in ids_in_report: + errors.append( + f'migration ID mismatch: report references {sorted(ids_in_report)} but ' + f"--migration-dir is {dir_name} - verify the report belongs to this run" + ) + return errors + + +def validate_report( + html: str, + recommendation: dict | None = None, + preflight_findings: dict | None = None, + tier1_signals: dict | None = None, + *, + require_toc: bool = True, + check_readability: bool = True, + migration_dir: Path | None = None, +) -> list[str]: + errors: list[str] = [] + + errors.extend(_validate_required_sections(html)) + errors.extend( + _validate_conditional_sections(html, recommendation, preflight_findings, tier1_signals) + ) + + if require_toc: + if not _toc_hrefs(html): + errors.append('missing