diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e985fedd..94dec688 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,8 +8,8 @@ { "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)." + "version": "1.6.0", + "description": "Migrate from GCP, Heroku, or Vercel 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. 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..801d6bfd 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** — a full migration pipeline for Next.js apps: discovery, Coupling Score, Pre-Flight Checks, a three-outcome recommendation, cost estimation (Vercel vs. AWS comparison), and production-ready Terraform generation with `baseline.tf`, migration scripts, and documentation **For infrastructure migrations:** @@ -26,6 +27,16 @@ Point this plugin at your Terraform files, application code, or billing data. It - **Gives honest pricing comparisons** — finds the best Bedrock option for your workload with current pricing data, including side-by-side estimated monthly cost comparisons against your existing OpenAI/Gemini spend - **Generates runnable AI artifacts** — `harness.json`, provider adapters, deployment scripts, incremental migration scripts — tailored to your specific models, tools, and architecture +**For Vercel migrations:** + +- **Derives what it can't export** — Vercel's infrastructure (CloudFront-equivalent behaviors, function tuning, edge routing) isn't directly readable, so discovery works from your build output, source configs (`next.config.js`, `middleware.ts`, `vercel.json`), and the Vercel API instead +- **Computes a Coupling Score** — ISR, edge middleware, edge runtime routes, image optimization, streaming SSR, preview deployments, and Vercel-managed stores (KV/Postgres/Blob/Edge Config/Cron), each with a detection method and why it matters +- **Runs 10 named Pre-Flight Checks** — including a flagship check for cached routes that intersect with middleware (a behavior change on every AWS target, not just one), computed unconditionally and filtered to whatever outcome fits you +- **Recommends one of three honest outcomes** — OpenNext/SST (serverless), ECS Fargate (containerized), or a Vercel+AWS Hybrid (your backend moves, your Next.js app and PR previews stay on Vercel) — via a fixed, auditable decision order, never a guess +- **Tells you what you'd lose** — PR preview deployments first, always — and says plainly when this tooling isn't a fit for you (a low-traffic app with no AWS credits is often better served by a VPS) +- **Estimates costs with Vercel comparison** — three-tier AWS projection (Premium/Balanced/Optimized) compared against your current Vercel spend, using cached AWS pricing data with live MCP fallback +- **Generates production-ready Terraform** — `baseline.tf` (GuardDuty, CloudTrail, IMDSv2, budget alerts), VPC, compute (Fargate/Lambda/SST per outcome), peripherals (RDS, ElastiCache, S3, EventBridge), and numbered migration scripts with dry-run defaults + ## Plugins | Plugin | Description | Status | @@ -88,6 +99,14 @@ After installation, just describe what you want to migrate: - "Estimate AWS costs for my Heroku workload" - "Migrate my Heroku Private Space to AWS" +**Vercel assessments:** + +- "Migrate my Next.js app off Vercel" +- "Assess my Vercel migration" +- "Should I migrate off Vercel" +- "Vercel to Fargate" +- "Vercel coupling score" + The skill creates a `.migration//` directory in the current working directory with all artifacts. ## What It Detects @@ -122,6 +141,18 @@ The skill creates a `.migration//` directory in the current working dir | Secrets | Config vars → AWS Secrets Manager or SSM Parameter Store | | Load Balancing | Web dynos → ALB; non-web → no ALB | +### Vercel → AWS + +| Category | Vercel → AWS | +| ------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Compute (Outcome A) | Next.js app → OpenNext/SST (server functions, CloudFront, ISR tag cache + revalidation queue, image optimization) | +| Compute (Outcome B) | Next.js app → ECS Fargate (`next start` behind ALB + CloudFront) | +| Compute (Outcome C) | Backend/API routes → API Gateway + Lambda or Fargate (Terraform); Next.js app + PR previews stay on Vercel | +| Storage | Blob → S3, Postgres → RDS/Aurora (Neon often correct to keep), KV → ElastiCache (Upstash often correct to keep) | +| Config/Secrets | Edge Config → Parameter Store/AppConfig, env vars → Secrets Manager/SSM | +| Scheduling | Cron → EventBridge Scheduler | +| Detect-only | Preview deployments (no AWS equivalent — this drives the Hybrid outcome and is the top "what you lose" item) | + ## What You Get That a Base LLM Can't **Infrastructure:** @@ -152,6 +183,7 @@ The skill creates a `.migration//` directory in the current working dir | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **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 @@ -167,6 +199,7 @@ The skill creates a `.migration//` directory in the current working dir - At least one input source: Terraform files, application code, or billing data - **For GCP AI/agentic migration:** Application source code is required (billing/IaC alone cannot detect agent architecture) - **For Heroku migration:** Terraform files with `heroku_*` resources are required (Procfile/app.json supplements but cannot stand alone) +- **For Vercel migration:** repo access with a locally-runnable `next build`, plus a Vercel API token, are both required — the assessment does not run on partial Tier 1 inputs. Vercel tokens can't be permission-scoped to read-only, so scope by resource instead (project-scoped when one project is in scope), pick a short expiration, and revoke after the assessment; the skill only ever issues read (GET) requests, enforced by its capture-step endpoint whitelist ## Structure 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..c04e8f3d 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, Heroku, or Vercel 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.", "author": { "name": "Amazon Web Services", "url": "https://aws.amazon.com" @@ -59,7 +59,12 @@ "temporal-cloud", "temporal-workers", "temporal-to-aws", - "durable-execution" + "durable-execution", + "vercel", + "vercel-to-aws", + "next-js", + "nextjs", + "opennext" ], "skills": "./skills/", "mcpServers": "./.mcp.json", @@ -79,7 +84,8 @@ "Migrate my OpenAI app to Amazon Bedrock", "Estimate AWS costs for my GCP workload", "Generate Terraform for my GCP to AWS migration", - "Migrate my LangChain app from OpenAI to Bedrock" + "Migrate my LangChain app from OpenAI to Bedrock", + "Assess my Vercel migration" ] } } diff --git a/migrate/plugins/migration-to-aws/.cursor-plugin/plugin.json b/migrate/plugins/migration-to-aws/.cursor-plugin/plugin.json index 7e57a76e..318074ef 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, Heroku, or Vercel 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.", "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..72d8541a 100644 --- a/migrate/plugins/migration-to-aws/README.md +++ b/migrate/plugins/migration-to-aws/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, Postgres, Redis, Kafka, Private Spaces, Pipelines, and 13+ common add-ons +- **Vercel → AWS** — a full migration pipeline (assessment, cost estimation, production-ready Terraform, migration scripts) for Next.js apps, with a three-outcome recommendation **For infrastructure migrations:** @@ -53,9 +54,9 @@ Point this plugin at your Terraform files, application code, or billing data. It ## Plugins -| Plugin | Description | Status | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| **migration-to-aws** | Assess, plan & execute: resource discovery, architecture mapping, cost analysis, execution planning (GCP and Heroku), LLM code rewrite to Bedrock (llm-to-bedrock skill), and a read-only Terraform security policy gate (tf-best-practices skill) | Available | +| Plugin | Description | Status | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| **migration-to-aws** | Assess, plan & execute: resource discovery, architecture mapping, cost analysis, execution planning (GCP, Heroku, and Vercel), LLM code rewrite to Bedrock (llm-to-bedrock skill), full Vercel migration (assessment, cost estimation, production-ready Terraform, migration scripts), and a read-only Terraform security policy gate (tf-best-practices skill) | Available | ## Installation @@ -158,12 +159,22 @@ Pass `--estimation-infra` / `--estimation-ai` only when those files exist. Resol | CI/CD | Pipelines and Review Apps → detect-only (recorded in inventory, no automated migration) | | Secrets | Config vars → AWS Secrets Manager or SSM Parameter Store | +#### Vercel → AWS (full migration: assessment + Terraform generation) + +| Category | Examples | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Compute | OpenNext/SST (Outcome A), ECS Fargate (Outcome B), or a Vercel+AWS Hybrid where only the backend moves (Outcome C) | +| Coupling | ISR, edge middleware, edge runtime routes, image optimization, streaming SSR, Server Actions/skew, preview deployments, Vercel-managed stores | +| Pre-Flight | 10 named checks (M1/M2/B1-B4/S1/I1/O1/U1), computed unconditionally and filtered by the recommended outcome | +| Peripherals | Blob → S3, Cron → EventBridge Scheduler, KV → ElastiCache, Postgres → RDS/Aurora, Edge Config → Parameter Store/AppConfig | + ### Agent Skill Triggers | Agent Skill | Triggers | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **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 @@ -201,18 +212,25 @@ The `--json` verdict lists each violation with `file`, `line`, `rule`, and `fix_ - At least one input source: Terraform files, application code, or billing data - **For GCP AI/agentic migration:** Application source code is required (billing/IaC alone cannot detect agent architecture) - **For Heroku migration:** Terraform files with `heroku_*` resources are required (Procfile/app.json supplements but cannot stand alone) +- **For Vercel migration:** repo access with a locally-runnable `next build`, plus a Vercel API token, are both required Tier 1 inputs — the assessment does not run without them. Vercel tokens can't be permission-scoped to read-only, so scope by resource instead (project-scoped when one project is in scope), pick a short expiration, and revoke after the assessment; the skill only ever issues read (GET) requests, enforced by its capture-step endpoint whitelist - **For AI execution (llm-to-bedrock skill):** Python 3.10+, `uv`, and Bedrock model access enabled + - **`uvx` required for cost estimation:** The `awspricing` MCP server runs via [`uvx`](https://docs.astral.sh/uv/guides/tools/) (part of the `uv` Python package manager). Install with `pip install uv` or `brew install uv`. Without it, the Estimate phase falls back to cached pricing — migration still works but live pricing lookups are unavailable. ## Architecture & contributing -This plugin ships two migration skills built on **different architectures**, and this -matters if you contribute: - -- **heroku-to-aws** is built on the **phase DSL** — a declarative frontmatter grammar - an LLM interprets at runtime, with a static validator that checks the structure - before anything runs. It is the reference implementation and the **direction for all - new work**. +This plugin ships three migration skills built on **different architectures**, and +this matters if you contribute: + +- **heroku-to-aws** and **vercel-to-aws** are built on the **phase DSL** — a + declarative frontmatter grammar an LLM interprets at runtime, with a static + validator that checks the structure before anything runs. This is the reference + implementation and the **direction for all new work**. `vercel-to-aws` additionally + owns its own resumability ledger (`assessment-state.json`, independent of the + vendored `.phase-status.json`) since its assessment supports incremental, + effort-for-confidence input collection across multiple sessions — see + `skills/vercel-to-aws/references/state/assessment-state.schema.json` if you're + building a skill with similar "come back later with more input" needs. - **gcp-to-aws** predates the DSL and uses the **older prose design**. It is maintained, but a future effort will port it onto the DSL. @@ -253,6 +271,26 @@ python3 scripts/validate-migration-report.py \ See [fixtures/README.md](fixtures/README.md) for what `REPORT_OK` does and does not guarantee. +### Vercel assessment report validator (unit tests) + +When changing anything under `skills/vercel-to-aws/references/phases/report/`, +`scripts/validate-assessment-report.py`, or +`fixtures/assessment-report-reference.html`: + +```bash +cd migrate/plugins/migration-to-aws + +pytest tests/test_validate_assessment_report.py -q + +python3 scripts/validate-assessment-report.py \ + fixtures/assessment-report-reference.html + +# Stub must fail (regression guard) +python3 scripts/validate-assessment-report.py \ + fixtures/assessment-report-stub.html \ + && exit 1 || true +``` + ## Security For security issue notifications, see the repo-root 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. +
+
+ +
+

This is a draft for review. Verify all figures before acting on this report.

+
+ + + 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.

+
+ +
+
    +
  • Review findings
  • +
+
+ + + 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..639f4e00 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/README.md @@ -0,0 +1,79 @@ +# 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). + +**Estimate replay** (seeded mid-pipeline, no discover run needed): + +1. Scratch directory with `workspace/*` at root; copy `seed-estimate/*` + (including `.phase-status.json`) into `.migration/0721-1725/`. The seeds + are a validated discover→clarify→recommend chain replay's outputs: Q6 + spend answered `$200-1000`, recommendation an unresolved `["A","B"]` + tiebreak (Q1 traffic shape declined, no log drain). +2. Resume the run — the Estimate phase starts. Expect a `user_provided` + baseline of exactly **$600/mo** (the documented range midpoint), BOTH + outcomes priced (`projected_costs` = Outcome A, `tiebreak_alternative` = + Outcome B, Property-16 on each), and tiebreak honesty in the summary. +3. `python3 check_expected_estimate.py `. + +**Generate replay** (seeded post-estimate): + +1. Scratch directory with `workspace/*` at root; copy `seed-generate/*` + (including `.phase-status.json`) into `.migration/0721-1725/`. +2. Resume the run — Generate starts and, per `generate.md`, must ASK which + tiebreak path to take: the replay founder answers "Outcome A + (OpenNext/SST)". +3. Expect the full artifact set (sst.config.ts + terraform/ + scripts/ + + docs + generation-warnings.json), OpenNext/Fargate mutual exclusion, no + placeholder tokens, no compliance resources (Q8 = none), and assembler + Step 6's terraform validate handled per environment (pass = no warning + entry; fail/skip = exactly one entry with a founder-facing action). +4. `python3 check_expected_generate.py `. + +**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/check_expected_estimate.py b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/check_expected_estimate.py new file mode 100644 index 00000000..6d424f2e --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/check_expected_estimate.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Assert an Estimate run's output against expected-estimate.json. + +Usage: + python3 check_expected_estimate.py + +Where contains the estimation-infra.json produced by a +replay seeded from seed-estimate/ (user-provided $200-1000 spend, unresolved +[A, B] tiebreak recommendation). Exits 0 on PASS, 1 on FAIL with one line per +failed assertion. Stdlib only. +""" + +import json +import math +import sys +from pathlib import Path + +FAILS: list[str] = [] + + +def check(cond: bool, msg: str) -> None: + if not cond: + FAILS.append(msg) + + +def is_num(v) -> bool: + return isinstance(v, (int, float)) and not isinstance(v, bool) + + +def close(a, b, tol=0.01) -> bool: + return is_num(a) and math.isclose(a, b, abs_tol=tol) + + +def tier_sum_matches(breakdown: dict, total, tier_keys=("mid", "balanced", "monthly")): + """Return None if some per-service tier key sums to total, else a message.""" + if not isinstance(breakdown, dict) or not breakdown: + return "breakdown missing/empty" + entries = {k: v for k, v in breakdown.items() if k != "total"} + for key in tier_keys: + vals = [] + ok = True + for v in entries.values(): + if isinstance(v, dict) and is_num(v.get(key)): + vals.append(v[key]) + else: + ok = False + break + if ok and vals and is_num(total): + if math.isclose(sum(vals), total, abs_tol=max(0.02 * total, 1.0)): + return None + return f"sum({key})={round(sum(vals), 2)} != total {total}" + return "no consistent per-service tier key found across breakdown entries" + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__) + return 2 + run_dir = Path(sys.argv[1]) + fixture_dir = Path(__file__).resolve().parent + + est = json.loads((run_dir / "estimation-infra.json").read_text()) + exp = json.loads((fixture_dir / "expected-estimate.json").read_text()) + doc = json.dumps(est) + + # --- Baseline (Part 1 rung 2: user-provided midpoint) --- + cur = est.get("current_costs", {}) + check(cur.get("source") == exp["current_costs"]["source"], f"current_costs.source={cur.get('source')}") + check( + close(cur.get("vercel_monthly"), exp["current_costs"]["vercel_monthly"]), + f"current_costs.vercel_monthly={cur.get('vercel_monthly')} want {exp['current_costs']['vercel_monthly']} (documented $200-1000 midpoint)", + ) + + # --- Projected costs: tiers positive + Property-16 --- + proj = est.get("projected_costs", {}) + for k in exp["projected_costs"]["tiers_positive"]: + check(is_num(proj.get(k)) and proj[k] > 0, f"projected_costs.{k}={proj.get(k)} not positive") + if exp["projected_costs"]["balanced_equals_breakdown_sum"]: + msg = tier_sum_matches(proj.get("breakdown", {}), proj.get("aws_monthly_balanced")) + check(msg is None, f"Property-16 (projected_costs): {msg}") + + # --- Tiebreak handling: both paths priced --- + t = exp["tiebreak"] + alt = est.get("tiebreak_alternative") + check(isinstance(alt, dict) and bool(alt), "tiebreak_alternative absent — [A,B] outcome must price BOTH paths") + if isinstance(alt, dict): + for f in t["alternative_required_fields"]: + check(f in alt, f"tiebreak_alternative.{f} missing") + if is_num(alt.get("aws_monthly_balanced")): + check(alt["aws_monthly_balanced"] > 0, "tiebreak_alternative balanced not positive") + msg = tier_sum_matches(alt.get("breakdown", {}), alt.get("aws_monthly_balanced")) + check(msg is None, f"Property-16 (tiebreak_alternative): {msg}") + summary_txt = ( + json.dumps(est.get("financial_summary", {})) + json.dumps(est.get("recommendation", {}).get("path_label", "")) + ).lower() + for word in t["summary_must_mention"]: + check(word.lower() in summary_txt, f"financial_summary/path_label does not mention '{word}'") + + # --- Cost comparison consistency --- + comp = est.get("cost_comparison") + check(isinstance(comp, dict) and bool(comp), "cost_comparison absent — a baseline exists in this scenario") + if isinstance(comp, dict): + check( + close(comp.get("vercel_monthly"), exp["cost_comparison"]["vercel_monthly"]), + f"cost_comparison.vercel_monthly={comp.get('vercel_monthly')}", + ) + if exp["cost_comparison"]["delta_consistent"]: + aws = comp.get("aws_monthly_balanced") + delta = comp.get("monthly_delta") + if is_num(aws) and is_num(delta): + check( + close(delta, aws - exp["cost_comparison"]["vercel_monthly"]), + f"monthly_delta {delta} != {aws} - {exp['cost_comparison']['vercel_monthly']}", + ) + + # --- Peripherals priced --- + breakdown_txt = json.dumps(proj.get("breakdown", {})).lower() + json.dumps(alt or {}).lower() + for p in exp["peripheral_services_must_be_priced"]: + aliases = { + "postgres": ["rds", "postgres"], + "kv": ["elasticache", "redis", "kv"], + "cron": ["eventbridge", "cron", "scheduler"], + }[p] + check(any(a in breakdown_txt for a in aliases), f"peripheral '{p}' not visible in any cost breakdown") + + # --- Enums --- + rec = est.get("recommendation", {}) + check(rec.get("path") in exp["recommendation_path_enum"], f"recommendation.path={rec.get('path')}") + check(bool(str(rec.get("path_label", "")).strip()), "recommendation.path_label empty") + for f in ("migrate_if", "stay_if"): + check(isinstance(rec.get(f), list) and len(rec[f]) > 0, f"recommendation.{f} not a non-empty array") + check(est.get("complexity_tier") in exp["complexity_tier_enum"], f"complexity_tier={est.get('complexity_tier')}") + ps = est.get("pricing_source", {}) + check(ps.get("status") in exp["pricing_source_status_enum"], f"pricing_source.status={ps.get('status')}") + + # --- Must-not-exist --- + check(cur.get("source") != "api_billing_data", "source claims api_billing_data with no billing data in scenario") + for pat in ("*.tf",): + tf = list(run_dir.rglob(pat)) + check(not tf, f"estimate wrote terraform files: {[str(p) for p in tf][:3]} (_forbids_files)") + for bad in ("sk_live", "vcp_", "Bearer ", "postgres://", "rediss://"): + check(bad not in doc, f"possible secret/token material: {bad}") + + if FAILS: + print(f"FAIL ({len(FAILS)}):") + for f in FAILS: + print(f" - {f}") + return 1 + print("PASS — expected-estimate.json assertions hold") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/check_expected_generate.py b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/check_expected_generate.py new file mode 100644 index 00000000..a78e90eb --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/check_expected_generate.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Assert a Generate run's output against expected-generate.json. + +Usage: + python3 check_expected_generate.py + +Where contains the terraform/, scripts/, docs, and +generation-warnings.json produced by a replay seeded from seed-generate/ +(unresolved [A, B] tiebreak; the replay founder picks Outcome A). Exits 0 on +PASS, 1 on FAIL with one line per failed assertion. Stdlib only. +""" + +import json +import re +import sys +from pathlib import Path + +FAILS: list = [] + + +def check(cond, msg): + 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-generate.json").read_text()) + + # --- File inventory --- + for f in exp["mandatory_files"]: + check((run_dir / f).is_file(), f"mandatory file missing: {f}") + for f in exp["outcome_a_files_must_exist"]: + check((run_dir / f).is_file(), f"Outcome A artifact missing: {f} (founder picked A)") + for f in exp["outcome_b_files_must_not_exist"]: + check(not (run_dir / f).exists(), f"mutual exclusion broken: {f} exists alongside the OpenNext path") + for f in exp["peripheral_files_must_exist"]: + check((run_dir / f).is_file(), f"peripheral terraform missing: {f}") + + scripts = sorted((run_dir / "scripts").glob("*.sh")) if (run_dir / "scripts").is_dir() else [] + check(len(scripts) >= exp["scripts_dir_min_files"], f"scripts/: {len(scripts)} files, want >= {exp['scripts_dir_min_files']}") + check( + any(exp["scripts_must_include_substring"] in p.name for p in scripts), + f"no script name contains '{exp['scripts_must_include_substring']}'", + ) + + # --- main.tf provider pin + variables --- + main_tf = (run_dir / "terraform/main.tf").read_text() if (run_dir / "terraform/main.tf").is_file() else "" + for s in exp["main_tf_must_contain"]: + check(s in main_tf, f"main.tf missing '{s}'") + variables_tf = (run_dir / "terraform/variables.tf").read_text() if (run_dir / "terraform/variables.tf").is_file() else "" + for v in exp["variables_must_declare"]: + check(re.search(rf'variable\s+"{v}"', variables_tf), f"variables.tf does not declare {v}") + + # --- Placeholder scan (backend block exempt) --- + if exp["no_placeholder_tokens_outside_backend"]: + for tf in sorted((run_dir / "terraform").glob("*.tf")): + text = tf.read_text() + # strip backend block(s) before scanning + stripped = re.sub(r'backend\s+"[^"]+"\s*\{[^}]*\}', "", text, flags=re.DOTALL) + for m in re.finditer(r"\{\{[A-Za-z0-9_]+\}\}", stripped): + check(False, f"placeholder token {m.group(0)} in {tf.name}") + + # --- terraform validate warning (environment-dependent Step 6) --- + gw = json.loads((run_dir / "generation-warnings.json").read_text()) if (run_dir / "generation-warnings.json").is_file() else {} + warnings = gw.get("warnings", []) + if exp["terraform_validate_warning"]["at_most_one"]: + tv = [w for w in warnings if w.get("service") == "terraform_validate"] + check(len(tv) <= 1, f"expected at most one terraform_validate warning entry, found {len(tv)}") + for w in tv: + check(bool(str(w.get("action", "")).strip()), "terraform_validate warning has no founder-facing action") + + # --- Compliance: Q8 'none' => no Config recorder / Security Hub --- + if exp["compliance_none_means_no_config_recorder"]: + baseline = (run_dir / "terraform/baseline.tf").read_text() if (run_dir / "terraform/baseline.tf").is_file() else "" + for bad in ("aws_config_configuration_recorder", "aws_securityhub_account"): + check(bad not in baseline, f"baseline.tf contains {bad} despite Q8 compliance = none") + + # --- Cross-reference: every estimated service accounted for --- + est = json.loads((run_dir / "estimation-infra.json").read_text()) + tf_text = "".join(p.read_text() for p in (run_dir / "terraform").glob("*.tf")) + ( + (run_dir / "sst.config.ts").read_text() if (run_dir / "sst.config.ts").is_file() else "" + ) + scripts_text = "".join(p.read_text() for p in scripts) + warn_text = json.dumps(warnings) + covered_corpus = (tf_text + scripts_text + warn_text).lower() + aliases = { + "rds_postgresql": ["aws_db_instance", "rds", "postgres"], + "elasticache_redis": ["aws_elasticache", "elasticache", "redis"], + "cron_eventbridge_lambda": ["aws_scheduler", "aws_cloudwatch_event", "eventbridge", "cron"], + "nat_gateway": ["aws_nat_gateway", "nat"], + "secrets_manager": ["secretsmanager", "secrets_manager", "secrets"], + "cloudfront": ["cloudfront"], + "lambda": ["lambda"], + "s3": ["s3", "bucket"], + "eventbridge": ["eventbridge", "scheduler", "revalidation"], + } + for svc in est.get("projected_costs", {}).get("breakdown", {}): + if svc == "total": + continue + candidates = aliases.get(svc, [svc.lower(), svc.lower().replace("_", "")]) + check( + any(a in covered_corpus for a in candidates), + f"estimated service '{svc}' not found in terraform/scripts/warnings (aliases tried: {candidates})", + ) + + # --- Secret hygiene --- + all_docs = tf_text + scripts_text + json.dumps(gw) + for p in ("MIGRATION_GUIDE.md", "README.md"): + if (run_dir / p).is_file(): + all_docs += (run_dir / p).read_text() + for bad in ("sk_live", "vcp_", "Bearer "): + check(bad not in all_docs, f"possible secret/token material: {bad}") + + if FAILS: + print(f"FAIL ({len(FAILS)}):") + for f in FAILS: + print(f" - {f}") + return 1 + print("PASS — expected-generate.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/expected-estimate.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/expected-estimate.json new file mode 100644 index 00000000..9570d14d --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/expected-estimate.json @@ -0,0 +1,36 @@ +{ + "_comment": "Expected Estimate-phase outcomes for a replay seeded from seed-estimate/ (the validated discover->clarify->recommend chain outputs: Q6 spend answered $200-1000, recommendation an unresolved [A,B] tiebreak). Asserts the user-provided baseline midpoint, the both-paths tiebreak pricing (projected_costs = Outcome A, tiebreak_alternative = Outcome B, Property-16 on each), and the tiebreak honesty in the summary fields. Checked by check_expected_estimate.py.", + "current_costs": { + "source": "user_provided", + "vercel_monthly": 600.0, + "_math": "Q6_vercel_spend answer '$200-1000' -> documented midpoint $600 (estimate-cost-engine.md Part 1 rung 2)" + }, + "tiebreak": { + "projected_costs_is_outcome_a": true, + "alternative_present": true, + "alternative_required_fields": [ + "aws_monthly_premium", + "aws_monthly_balanced", + "aws_monthly_optimized", + "breakdown" + ], + "summary_must_mention": ["tiebreak", "log drain"] + }, + "projected_costs": { + "tiers_positive": ["aws_monthly_premium", "aws_monthly_balanced", "aws_monthly_optimized"], + "balanced_equals_breakdown_sum": true + }, + "cost_comparison": { + "vercel_monthly": 600.0, + "delta_consistent": true + }, + "peripheral_services_must_be_priced": ["postgres", "kv", "cron"], + "recommendation_path_enum": ["migrate_optimized", "migrate_phased", "stay"], + "complexity_tier_enum": ["small", "medium", "large"], + "pricing_source_status_enum": ["cached", "cached_stale", "live", "cached_fallback", "unavailable"], + "must_not_exist": [ + "terraform files (estimate _forbids_files)", + "any env var VALUE or token material", + "an api_billing_data source claim (no billing data exists in this scenario)" + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/expected-generate.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/expected-generate.json new file mode 100644 index 00000000..ba129e9b --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/expected-generate.json @@ -0,0 +1,34 @@ +{ + "_comment": "Expected Generate-phase outcomes for a replay seeded from seed-generate/ (estimate completed; recommendation is the unresolved [A,B] tiebreak, so Generate must ASK the founder to pick — the replay founder picks Outcome A / OpenNext). Checked by check_expected_generate.py.", + "mandatory_files": [ + "terraform/main.tf", + "terraform/variables.tf", + "terraform/outputs.tf", + "terraform/baseline.tf", + "terraform/vpc.tf", + "terraform/security.tf", + "terraform/.gitignore", + "terraform/terraform.tfvars.example", + "terraform/README.md", + "MIGRATION_GUIDE.md", + "README.md", + "generation-warnings.json" + ], + "outcome_a_files_must_exist": ["sst.config.ts"], + "outcome_b_files_must_not_exist": ["terraform/compute.tf"], + "peripheral_files_must_exist": ["terraform/database.tf", "terraform/cache.tf", "terraform/scheduling.tf"], + "scripts_dir_min_files": 3, + "scripts_must_include_substring": "secrets", + "main_tf_must_contain": ["hashicorp/aws", "5.80"], + "variables_must_declare": ["aws_region", "project_name", "environment", "migration_id"], + "no_placeholder_tokens_outside_backend": true, + "terraform_validate_warning": { + "at_most_one": true, + "reason": "Step 6 is environment-dependent: a pass records NO warning entry (validated in the reference replay, where a local provider cache let terraform init -backend=false + validate succeed offline); a failed or skipped run records exactly ONE entry with a founder-facing action. More than one entry, or an entry without an action, is always wrong." + }, + "compliance_none_means_no_config_recorder": true, + "must_not_exist_anywhere": [ + "any env var VALUE or token material (sk_live, vcp_, Bearer)", + "a second compute path's artifacts (mutual exclusion)" + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/.phase-status.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/.phase-status.json new file mode 100644 index 00000000..3a5913f6 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/.phase-status.json @@ -0,0 +1,14 @@ +{ + "migration_id": "0721-1725", + "last_updated": "2026-07-21T17:56:00Z", + "current_phase": "estimate", + "phases": { + "prescan": "completed", + "discover": "completed", + "clarify": "completed", + "recommend": "completed", + "estimate": "pending", + "generate": "pending", + "report": "pending" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/assessment-state.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/assessment-state.json new file mode 100644 index 00000000..55b85313 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/assessment-state.json @@ -0,0 +1,342 @@ +{ + "schema_version": "1.0", + "migration_id": "0721-1725", + "last_updated": "2026-07-21T17:55: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": { + "discovery.route_disposition": { + "value": { + "/": "static", + "/dashboard": "dynamic", + "/blog/[slug]": "isr (revalidate 3600)", + "/api/checkout": "dynamic" + }, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.manifest_metadata": { + "value": { + "adapter_api_used": false, + "next_version": "15.1.0", + "manifest_availability": "available", + "fallback_reason": "next_version < 16.2" + }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.next_config": { + "value": { + "image_remote_patterns": ["cdn.acme-shop.com"], + "has_output_file_tracing_excludes": false, + "route_segment_configs": [] + }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.middleware_analysis": { + "value": { + "per_matcher_pattern": [ + { "pattern": "/dashboard/:path*", "classification": "auth_gating" }, + { "pattern": "/old-blog/:path*", "classification": "rewrite" } + ], + "classification": null, + "geo_ip_headers_used": [] + }, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.vercel_json_config": { + "value": { + "headers": 1, + "function_config": { "app/api/checkout/route.ts": { "maxDuration": 30 } }, + "crons": [{ "path": "/api/cron/digest", "schedule": "0 8 * * *" }] + }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.deployments": { + "value": { "production_ready": 1, "recent_previews": 2, "preview_error": 1 }, + "confidence": "MEDIUM", + "upgrade_input": "7-14 day log drain/observability export", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["vercel_api_token"] + }, + "discovery.env_var_names": { + "value": [ + "DATABASE_URL", + "KV_REST_API_TOKEN", + "KV_REST_API_URL", + "NEXT_PUBLIC_SITE_URL", + "NODE_ENV", + "STRIPE_SECRET_KEY", + "STRIPE_WEBHOOK_SECRET" + ], + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["vercel_api_token"] + }, + "discovery.domains": { + "value": ["acme-shop.vercel.app", "www.acme-shop.com"], + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["vercel_api_token"] + }, + "discovery.storage_integrations": { + "value": [ + { "type": "kv", "name": "acme-cart-cache", "sizeBytes": 52428800 }, + { "type": "postgres", "name": "acme-orders-db", "sizeBytes": 3221225472 } + ], + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["vercel_api_token"] + }, + "discovery.crons": { + "value": [{ "path": "/api/cron/digest", "schedule": "0 8 * * *", "source": "vercel.json" }], + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.api_routes": { + "value": ["/api/checkout", "/api/cron/digest"], + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.backend_service_detected": { + "value": false, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access", "vercel_api_token"] + }, + "discovery.usage_metrics": { + "value": "unavailable: no usage aggregates exposed on hobby/pro plan without observability", + "confidence": "LOW", + "upgrade_input": "7-14 day log drain/observability export", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["vercel_api_token", "log_drain_export"] + }, + "discovery.streaming_routes_with_empty_body_risk": { + "value": [], + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.isr": { + "value": true, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.edge_middleware": { + "value": true, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.edge_runtime_routes": { + "value": false, + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.image_optimization": { + "value": true, + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.streaming_ssr": { + "value": false, + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.server_actions_version_skew": { + "value": "unknown", + "confidence": "LOW", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.preview_deployments": { + "value": "pending_clarify", + "confidence": "LOW", + "upgrade_input": "Clarify Q4 answer (preview-deployment workflow dependence)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["clarify_q4_preview_dependence"] + }, + "coupling.vercel_managed_stores": { + "value": true, + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["vercel_api_token"] + }, + "coupling.vercel_injected_headers": { + "value": false, + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.M1": { + "value": { + "detected": true, + "severity": "HIGH", + "driver": "/old-blog/:path* rewrite pattern x ISR route /blog/[slug]" + }, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.M2": { + "value": { "detected": false, "severity": "NONE" }, + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.B1": { + "value": { "detected": false, "severity": "NONE" }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.B2": { + "value": { "detected": false, "severity": "NONE" }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.B3": { + "value": { "detected": false, "severity": "NONE" }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.B4": { + "value": { "detected": false, "severity": "NONE" }, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.S1": { + "value": { "detected": false, "severity": "NONE" }, + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.I1": { + "value": { "detected": true, "severity": "MEDIUM", "severity_by_outcome_computed": true }, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.O1": { + "value": { "detected": "advisory", "severity": "advisory" }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "recommend.fired_rule": { + "value": { + "fired_rule": 4, + "outcome": ["A", "B"], + "tiebreak": true, + "backend_shape": null + }, + "confidence": "LOW", + "upgrade_input": "14 days of log drain data", + "computed_at": "2026-07-21T17:55:00Z", + "computed_from_inputs": ["clarify_q1_traffic_shape", "clarify_q4_preview_dependence", "log_drain_export"] + }, + "preflight.U1": { + "value": { "detected": "unknown", "severity": "informational" }, + "confidence": "LOW", + "upgrade_input": "7-14 day log drain/observability export", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access", "vercel_api_token", "log_drain_export"] + } + }, + "clarify_answers": { + "Q1_traffic_shape": { + "prompt": "Roughly, is your traffic spiky (occasional bursts far above typical) or sustained (fairly steady load)? If you have a rough peak-to-median ratio in mind, share it — otherwise a general sense is fine.", + "answer": "declined", + "design_consequence": "feeds recommend phase rule 3 (traffic shape decides A vs. B) and rule 4 (tiebreak fires if this answer is vague and no log drain exists)", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q2_migration_trigger": { + "prompt": "What actually triggered you to consider this migration? (bill size, bill variance/unpredictability, running out of credits, data locality requirements, hitting a platform limit, something else)", + "answer": "bill size", + "design_consequence": "motivational context for the report's decision traceability appendix; frames the recommendation's framing; feeds report-render.md's what-you-gain section", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q3_devops_bandwidth": { + "prompt": "Who owns production when something breaks? Do you have dedicated DevOps capacity, or is this a founder/small-team responsibility?", + "answer": "founder/small-team responsibility - no dedicated DevOps capacity; no debuggability preference stated; no existing separate API service mentioned", + "design_consequence": "feeds recommend phase rule 3 (small team favors Outcome A; a stated debuggability preference favors Outcome B) and rule 2 (an existing separate API service, if mentioned here, feeds the Lambda-hostility check)", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q4_preview_dependence": { + "prompt": "How load-bearing are PR preview deployments for your team's workflow? (e.g. 'we review every PR live before merge' vs. 'we barely use them')", + "answer": "declined", + "design_consequence": "feeds recommend phase rule 1 (preview-dependence + separability check) - the FIRST precedence rule evaluated", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q5_nextjs_upgrade": { + "prompt": "Your app is on Next.js 15.1.0. Upgrading to 16.2+ would unlock the Adapter API's typed build output for higher-confidence discovery, and positions you for the future verified AWS adapter when it reaches GA. This is entirely optional — my default recommendation path (migrate now on OpenNext v3) works regardless of your current version. Interested, or would you rather stick with your current version for now?", + "answer": "stick with the current version for now - proceed on the default path (migrate now on OpenNext v3)", + "design_consequence": "confidence-upgrade offer only, per Requirement 3.4 - NEVER feeds a precedence rule as a gate; recorded for the report's Next Steps section (Requirement 9.8)", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q6_vercel_spend": { + "prompt": "What's your approximate monthly Vercel spend? This helps me compare AWS costs against your current bill. (Just a ballpark is fine — $0-50, $50-200, $200-1000, or $1000+)", + "answer": "$200-1000", + "design_consequence": "feeds estimate phase current_costs.vercel_monthly as the Vercel baseline for cost comparison", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q7_database_size": { + "prompt": "Approximately how large is your Vercel Postgres database? This determines which migration tool I'll set up in the scripts. (A) Less than 1 GB (B) 1-10 GB (C) 10-100 GB (D) More than 100 GB", + "answer": "B", + "design_consequence": "feeds generate phase migration script selection — pg_dump for < 10 GB, AWS DMS for >= 10 GB — and RDS instance sizing in the estimate (db.t4g.small for 1-10 GB)", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q8_compliance": { + "prompt": "Do you have compliance requirements that your AWS environment needs to meet? (Select all that apply, or 'None') (A) SOC 2 (B) PCI DSS (C) HIPAA (D) FedRAMP (E) None", + "answer": "none", + "design_consequence": "feeds generate phase baseline.tf — compliance answer drives the conditional section and CloudTrail log retention period (90 days default)", + "answered_at": "2026-07-21T17:50:00Z" + } + }, + "report_history": [] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/clarify-answers.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/clarify-answers.json new file mode 100644 index 00000000..2dcda8d9 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/clarify-answers.json @@ -0,0 +1,53 @@ +{ + "phase": "clarify", + "timestamp": "2026-07-21T17:50:00Z", + "replay_note": "No live founder in this replay run. Each answer is the skill's documented default when one exists, otherwise the most conservative documented option; the rationale is recorded per entry in answer_rationale.", + "Q1_traffic_shape": { + "prompt": "Roughly, is your traffic spiky (occasional bursts far above typical) or sustained (fairly steady load)? If you have a rough peak-to-median ratio in mind, share it — otherwise a general sense is fine.", + "answer": "declined", + "answer_rationale": "No documented default traffic shape exists in the skill. The recommendation engine's documented conservative behavior for an absent/vague Q1 with no log drain is the Step 4 tiebreak ('present both paths rather than guessing'), and clarify.md's error handling documents declined answers explicitly ('record answer: declined, apply the fallback per vercel-recommendation-engine.md § Fallback Behavior'). Declining invokes exactly that documented fallback without fabricating a traffic profile.", + "design_consequence": "feeds recommend phase rule 3 (traffic shape decides A vs. B) and rule 4 (tiebreak fires if this answer is vague and no log drain exists)" + }, + "Q2_migration_trigger": { + "prompt": "What actually triggered you to consider this migration? (bill size, bill variance/unpredictability, running out of credits, data locality requirements, hitting a platform limit, something else)", + "answer": "bill size", + "answer_rationale": "SKILL.md's documented default persona is the cost-driven founder ('the default path for a cost-driven founder is migrate now on OpenNext v3'), consistent with the stipulated $200-1000 monthly spend answer for Q6.", + "design_consequence": "motivational context for the report's decision traceability appendix; does not directly drive a precedence rule but frames the recommendation's framing (e.g. variance-driven founders lean toward Outcome B's predictability pitch); also feeds report-render.md's what-you-gain section - a credits/funding-pressure answer here surfaces the AWS Activate eligibility callout" + }, + "Q3_devops_bandwidth": { + "prompt": "Who owns production when something breaks? Do you have dedicated DevOps capacity, or is this a founder/small-team responsibility?", + "answer": "founder/small-team responsibility - no dedicated DevOps capacity; no debuggability preference stated; no existing separate API service mentioned", + "answer_rationale": "No documented default exists. The most conservative documented option is not to assume undocumented capabilities (dedicated DevOps) or preferences (debuggability) - the question's own framing offers 'founder/small-team responsibility' as the baseline case, matching the skill's founder-persona framing throughout.", + "design_consequence": "feeds recommend phase rule 3 (small team favors Outcome A; a stated debuggability preference favors Outcome B) and rule 2 (an existing separate API service, if mentioned here, feeds the Lambda-hostility check)" + }, + "Q4_preview_dependence": { + "prompt": "How load-bearing are PR preview deployments for your team's workflow? (e.g. 'we review every PR live before merge' vs. 'we barely use them')", + "answer": "declined", + "answer_rationale": "The recommendation engine documents an explicit default for an unanswered/declined Q4: 'Treat as not load-bearing, fall through to Step 2. Note in reasons: preview dependence unanswered - assumed not load-bearing by default.' Declining invokes exactly that documented default without fabricating workflow facts (the deployments API capture shows 2 recent preview deployments, so asserting 'we barely use them' would contradict evidence).", + "design_consequence": "feeds recommend phase rule 1 (preview-dependence + separability check) - the FIRST precedence rule evaluated; a load-bearing answer here can short-circuit the entire recommendation to Outcome C or stay-on-Vercel before any other signal is consulted" + }, + "Q5_nextjs_upgrade": { + "prompt": "Your app is on Next.js 15.1.0. Upgrading to 16.2+ would unlock the Adapter API's typed build output for higher-confidence discovery, and positions you for the future verified AWS adapter when it reaches GA. This is entirely optional — my default recommendation path (migrate now on OpenNext v3) works regardless of your current version. Interested, or would you rather stick with your current version for now?", + "answer": "stick with the current version for now - proceed on the default path (migrate now on OpenNext v3)", + "answer_rationale": "SKILL.md documents the default path explicitly: 'The default path for a cost-driven founder is migrate now on OpenNext v3, whatever their current Next.js version.' The upgrade is a confidence-upgrade offer, never a gate (Requirement 3.3-3.5).", + "design_consequence": "confidence-upgrade offer only, per Requirement 3.4 - NEVER feeds a precedence rule as a gate; recorded for the report's Next Steps section (Requirement 9.8)" + }, + "Q6_vercel_spend": { + "prompt": "What's your approximate monthly Vercel spend? This helps me compare AWS costs against your current bill. (Just a ballpark is fine — $0-50, $50-200, $200-1000, or $1000+)", + "answer": "$200-1000", + "answer_rationale": "Stipulated founder answer for this replay run (Q6-style Vercel monthly spend question: the founder's answer is the $200-1000 range).", + "design_consequence": "feeds estimate phase current_costs.vercel_monthly as the Vercel baseline for cost comparison; when skipped, estimate derives the baseline from Vercel API usage metrics instead" + }, + "Q7_database_size": { + "prompt": "Approximately how large is your Vercel Postgres database? This determines which migration tool I'll set up in the scripts. (A) Less than 1 GB (B) 1-10 GB (C) 10-100 GB (D) More than 100 GB", + "answer": "B", + "answer_rationale": "Consistent with the Vercel API stores capture (acme-orders-db, sizeBytes 3221225472 ≈ 3 GB, squarely in the 1-10 GB bracket) and yields the same conservative tooling/sizing (pg_dump, db.t4g.small) as the documented 'unknown' fallback would.", + "design_consequence": "feeds generate phase migration script selection — pg_dump for < 10 GB, AWS DMS for >= 10 GB — and RDS instance sizing in the estimate (db.t4g.micro for < 1 GB, db.t4g.small for 1-10 GB, db.r6g.large for 10-100 GB, db.r6g.xlarge for > 100 GB)" + }, + "Q8_compliance": { + "prompt": "Do you have compliance requirements that your AWS environment needs to meet? (Select all that apply, or 'None') (A) SOC 2 (B) PCI DSS (C) HIPAA (D) FedRAMP (E) None", + "answer": "none", + "answer_rationale": "No documented default exists; 'none' is the documented error-handling option ('baseline.tf emits the always-on resources only, no compliance-conditional section') and asserting compliance obligations with no supporting evidence would fabricate requirements.", + "design_consequence": "feeds generate phase baseline.tf — compliance answer drives the conditional section (AWS Config recorder, Security Hub + standards subscriptions) and CloudTrail log retention period (90 days default, up to 2190 for HIPAA)" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/coupling-score.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/coupling-score.json new file mode 100644 index 00000000..e5489878 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/coupling-score.json @@ -0,0 +1,100 @@ +{ + "phase": "discover", + "timestamp": "2026-07-21T17:42:00Z", + "items": [ + { + "id": "isr", + "title": "ISR / on-demand revalidation", + "detected": true, + "detection_method": "prerender-manifest.json shows /blog/[slug] prerendered with initialRevalidateSeconds: 3600 (2 paths); no revalidateTag/revalidatePath calls found in the available source configs", + "weight_rationale": "Portable via OpenNext but requires the tag cache and revalidation queue to be provisioned as a unit, not independently - see Pre-Flight Check I1.", + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "edge_middleware", + "title": "Edge middleware", + "detected": true, + "detection_method": "middleware.ts exists (prescan has_middleware: true); matcher covers /dashboard/:path* (auth_gating) and /old-blog/:path* (rewrite)", + "weight_rationale": "Behavioral divergence on every AWS outcome - see Pre-Flight Check M1 (flagship).", + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "edge_runtime_routes", + "title": "Edge runtime routes", + "detected": false, + "detection_method": "no runtime: 'edge' declarations found in next.config.js or available route segment configs", + "weight_rationale": "Edge runtime routes hit Lambda@Edge-style constraints (smaller runtime, limited Node API surface) when migrated.", + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "image_optimization", + "title": "Image optimization", + "detected": true, + "detection_method": "next.config.js images.remotePatterns configured for cdn.acme-shop.com; next/image component usage not directly verifiable (app/ source tree not present in workspace)", + "weight_rationale": "Portable - OpenNext ships its own sharp-based image optimization; see Pre-Flight Check B3 for the duplicate-dependency conflict.", + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "streaming_ssr", + "title": "Streaming SSR", + "detected": false, + "detection_method": "no streaming response patterns (ReadableStream, Response with stream body) found; route handler source files not present in workspace to scan", + "weight_rationale": "A known Lambda workaround exists for empty-body streams (see Pre-Flight Check S1); next start (Outcome B) streams natively.", + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "server_actions_version_skew", + "title": "Server Actions / version skew exposure", + "detected": "unknown", + "detection_method": "Next.js 15.1.0 (App Router in use per app-path-routes-manifest.json) supports Server Actions, but Server Actions usage in source cannot be verified - app/ source tree not present in workspace", + "weight_rationale": "Vercel handles skew protection (mismatched client/server deploy versions) automatically; the AWS path must address this explicitly regardless of outcome.", + "confidence": "LOW", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "preview_deployments", + "title": "Preview deployments", + "detected": "pending_clarify", + "detection_method": "Clarify Q4 (team workflow) - not detectable from code alone; note: the deployments API capture shows 2 recent preview deployments, suggesting active use, but the resolved value comes from clarify-answers.json", + "weight_rationale": "No AWS equivalent exists; drives the Outcome C (Hybrid) precedence rule. The single heaviest 'what you lose' item in every report.", + "confidence": "LOW", + "upgrade_input": "Clarify Q4 answer (preview-deployment workflow dependence)", + "computed_from_inputs": ["clarify_q4_preview_dependence"] + }, + { + "id": "vercel_managed_stores", + "title": "Vercel KV / Postgres / Blob / Edge Config / Cron", + "detected": true, + "detection_method": "Vercel API stores capture: KV store 'acme-cart-cache' (50 MB, iad1) and Postgres store 'acme-orders-db' (3 GB, iad1); one cron in vercel.json; @vercel/kv and @vercel/postgres are package.json dependencies", + "weight_rationale": "Clean peripheral mappings exist for all of these - see knowledge/peripheral-mappings.json.", + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_from_inputs": ["vercel_api_token"] + }, + { + "id": "vercel_injected_headers", + "title": "Vercel-injected headers (geo, IP)", + "detected": false, + "detection_method": "grep for x-vercel-ip-* and similar headers across middleware.ts and available server code found no usage", + "weight_rationale": "Needs a CloudFront-headers rewrite mapping - see Pre-Flight Check M2.", + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + } + ], + "phased_migration_candidate": { + "flagged": false, + "note": "coupling is spread across ISR, middleware, and managed stores at ordinary levels - no single component is at unusually high coupling relative to the rest" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/discovery.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/discovery.json new file mode 100644 index 00000000..a0ddae02 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/discovery.json @@ -0,0 +1,205 @@ +{ + "phase": "discover", + "timestamp": "2026-07-21T17:42:00Z", + "signal_source_used": "manifest_fallback", + "route_disposition": [ + { + "route": "/", + "disposition": "static", + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "source": "prerender-manifest.json (initialRevalidateSeconds: false)", + "computed_from_inputs": ["repo_access"] + }, + { + "route": "/dashboard", + "disposition": "dynamic", + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "note": "routes-manifest.json places /dashboard in staticRoutes (fixed path shape) but it is ABSENT from prerender-manifest.json entirely, so it renders per-request; consistent with the session-cookie auth gate middleware applies to /dashboard/:path*", + "source": "prerender-manifest.json (absent) resolved over routes-manifest.json staticRoutes membership", + "computed_from_inputs": ["repo_access"] + }, + { + "route": "/blog/[slug]", + "disposition": "isr", + "revalidate_seconds": 3600, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "note": "prerender-manifest.json contains /blog/launch-post and /blog/hiring with initialRevalidateSeconds: 3600 (srcRoute /blog/[slug]) and a dynamicRoutes entry with fallback: null - genuinely ISR per the build's own decision", + "source": "prerender-manifest.json routes + dynamicRoutes", + "computed_from_inputs": ["repo_access"] + }, + { + "route": "/api/checkout", + "disposition": "dynamic", + "confidence": "LOW", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output, which classifies Route Handlers directly (this fragment's manifest sources only classify page routes, so this value is inferred from the absence of a static-export declaration rather than manifest-sourced)", + "note": "API Route Handler from app-path-routes-manifest.json ('/api/checkout/route'); no 'export const dynamic = \"force-static\"' declaration found (route source file not present in workspace to inspect), so default dynamic", + "source": "app-path-routes-manifest.json", + "computed_from_inputs": ["repo_access"] + } + ], + "manifest_metadata": { + "adapter_api_used": false, + "next_version": "15.1.0", + "manifest_availability": "available", + "fallback_reason": "next_version < 16.2", + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + "next_config": { + "route_segment_configs": [], + "route_segment_configs_note": "no route source files (app/ tree) present in the workspace to cross-reference; no revalidate/dynamic/runtime:'edge' declarations found", + "image_config": { + "remotePatterns": [{ "protocol": "https", "hostname": "cdn.acme-shop.com" }], + "domains": [], + "custom_loader": false + }, + "has_output_file_tracing_excludes": false, + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + "middleware_analysis": { + "matcher": ["/dashboard/:path*", "/old-blog/:path*"], + "per_matcher_pattern": [ + { + "pattern": "/dashboard/:path*", + "classification": "auth_gating", + "evidence": "middleware body branches on pathname.startsWith('/dashboard'); missing 'session' cookie triggers NextResponse.redirect to /login", + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)" + }, + { + "pattern": "/old-blog/:path*", + "classification": "rewrite", + "evidence": "middleware body branches on pathname.startsWith('/old-blog/'); NextResponse.rewrite to the /blog/ equivalent path on every request", + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)" + } + ], + "classification": null, + "classification_note": "per_matcher_pattern entries diverge (auth_gating vs rewrite), so the collapsed classification is null per discover-configs.md Step 5", + "geo_ip_headers_used": [], + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_from_inputs": ["repo_access"] + }, + "vercel_json_config": { + "headers": [ + { + "source": "/(.*)", + "headers": [{ "key": "X-Frame-Options", "value": "DENY" }] + } + ], + "redirects": [], + "rewrites": [], + "function_config": { + "app/api/checkout/route.ts": { "maxDuration": 30 } + }, + "regions": [], + "crons": [{ "path": "/api/cron/digest", "schedule": "0 8 * * *" }], + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + "streaming_routes_with_empty_body_risk": [], + "streaming_scan_note": "no route handler source files present in the workspace to scan; no streaming patterns found", + "deployments": [ + { "uid": "dpl_prod042", "state": "READY", "target": "production", "created": 1784050000000 }, + { "uid": "dpl_prev107", "state": "READY", "target": "preview", "created": 1784040000000 }, + { "uid": "dpl_prev106", "state": "ERROR", "target": "preview", "created": 1784030000000 } + ], + "deployments_meta": { + "confidence": "MEDIUM", + "upgrade_input": "7-14 day log drain/observability export", + "note": "1 production deployment READY; 2 recent preview deployments (one READY, one ERROR) - the team actively uses preview deployments", + "computed_from_inputs": ["vercel_api_token"] + }, + "env_var_names": [ + "DATABASE_URL", + "KV_REST_API_TOKEN", + "KV_REST_API_URL", + "NEXT_PUBLIC_SITE_URL", + "NODE_ENV", + "STRIPE_SECRET_KEY", + "STRIPE_WEBHOOK_SECRET" + ], + "env_var_names_meta": { + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "note": "key names only per Requirement 1.6 - values never fetched or persisted", + "computed_from_inputs": ["vercel_api_token"] + }, + "domains": ["acme-shop.vercel.app", "www.acme-shop.com"], + "domains_meta": { + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_from_inputs": ["vercel_api_token"] + }, + "crons": [ + { + "path": "/api/cron/digest", + "schedule": "0 8 * * *", + "source": "vercel.json", + "confidence": "HIGH", + "note": "API cron capture was skipped (manifest: 'no GET endpoint with a crons path segment found via vercel api list - crons from vercel.json only'); this cron path /api/cron/digest does NOT appear in app-path-routes-manifest.json - discrepancy recorded", + "computed_from_inputs": ["repo_access"] + } + ], + "crons_api_capture": "unavailable: no GET endpoint with a crons path segment found via vercel api list - crons from vercel.json only", + "storage_integrations": [ + { + "type": "kv", + "metadata": { "id": "store_kv_01", "name": "acme-cart-cache", "region": "iad1", "sizeBytes": 52428800 }, + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_from_inputs": ["vercel_api_token"] + }, + { + "type": "postgres", + "metadata": { "id": "store_pg_01", "name": "acme-orders-db", "region": "iad1", "sizeBytes": 3221225472 }, + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_from_inputs": ["vercel_api_token"] + } + ], + "peripherals": [ + { + "type": "kv", + "source": "vercel_api", + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_from_inputs": ["vercel_api_token"] + }, + { + "type": "postgres", + "source": "vercel_api", + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_from_inputs": ["vercel_api_token"] + }, + { "type": "cron", "source": "vercel.json", "confidence": "HIGH", "computed_from_inputs": ["repo_access"] } + ], + "api_routes": ["/api/checkout", "/api/cron/digest"], + "api_routes_meta": { + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "note": "/api/checkout from app-path-routes-manifest.json; /api/cron/digest referenced only by vercel.json crons and absent from the build manifest", + "computed_from_inputs": ["repo_access"] + }, + "backend_service_detected": false, + "backend_service_detected_meta": { + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "note": "app/api tree is 1-2 routes (checkout, cron digest) - not substantial enough to stand alone as a separable backend service; no separate backend Vercel project in the projects capture", + "computed_from_inputs": ["repo_access", "vercel_api_token"] + }, + "usage_metrics": { + "status": "unavailable: no usage aggregates exposed on hobby/pro plan without observability", + "confidence": "LOW", + "upgrade_input": "7-14 day log drain/observability export", + "computed_from_inputs": ["vercel_api_token", "log_drain_export"] + }, + "header_probe_results": [], + "probe_limitations": ["probe not attempted - Tier 2 production URL + throwaway test account not supplied"] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/preflight-findings.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/preflight-findings.json new file mode 100644 index 00000000..0e229a21 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/preflight-findings.json @@ -0,0 +1,158 @@ +{ + "phase": "discover", + "timestamp": "2026-07-21T17:42:00Z", + "checks": [ + { + "id": "M1", + "title": "Cached-route x middleware intersection", + "flagship": true, + "detected": true, + "severity": "HIGH", + "applies_to": ["A", "B", "C"], + "adapter_generation": "independent", + "detail": "matcher pattern \"/old-blog/:path*\" (classification: rewrite, per-request) intersects the ISR route /blog/[slug] via its rewrite target - a CloudFront-cached response under /old-blog/* skips the middleware rewrite entirely, and cached /old-blog copies fork from /blog ISR revalidation. This pattern/route intersection alone drives the HIGH verdict. Matcher pattern \"/dashboard/:path*\" (classification: auth_gating) intersects only /dashboard, which is dynamic (rendered per-request, absent from prerender-manifest.json) and therefore NOT cacheable - it contributes nothing to this check's severity despite its auth_gating classification.", + "remediations": [ + "Make affected routes dynamic", + "Move logic server-side", + "OpenNext external middleware option (Outcome A only)", + "CloudFront Functions for simple header/redirect logic (Outcome B)", + "Accept the behavior change knowingly" + ], + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "M2", + "title": "Geo/IP header dependence", + "flagship": false, + "detected": false, + "severity": "NONE", + "applies_to": ["A", "B", "C"], + "adapter_generation": "independent", + "detail": "no Vercel-injected geo/IP headers (x-vercel-ip-country, x-vercel-ip-city, etc.) found in middleware.ts or available server code", + "remediations": [], + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "B1", + "title": "Monorepo lockfile conflicts", + "flagship": false, + "detected": false, + "severity": "NONE", + "applies_to": ["A"], + "adapter_generation": "v3", + "suppressed_on": ["B"], + "detail": "exactly one lockfile at repo root (pnpm-lock.yaml, per prescan lockfile_census) - no conflict", + "remediations": [], + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "B2", + "title": "Yarn packageManager pin", + "flagship": false, + "detected": false, + "severity": "NONE", + "applies_to": ["A"], + "adapter_generation": "v3", + "detail": "package.json packageManager is \"pnpm@9.1.0\" - not yarn", + "remediations": [], + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "B3", + "title": "sharp as a direct dependency", + "flagship": false, + "detected": false, + "severity": "NONE", + "applies_to": ["A"], + "adapter_generation": "v3", + "suppressed_on": ["B"], + "detail": "sharp not present in package.json dependencies or devDependencies (prescan has_sharp_dependency: false)", + "remediations": [], + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "B4", + "title": "Bundle contamination", + "flagship": false, + "detected": false, + "severity": "NONE", + "applies_to": ["A"], + "adapter_generation": "v3", + "detail": "no known-problematic packages in dependencies (@vercel/kv, @vercel/postgres, next, react, react-dom, stripe); next.config.js has no outputFileTracingExcludes and none appears needed", + "remediations": [], + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "S1", + "title": "Streaming routes with potentially-empty bodies", + "flagship": false, + "detected": false, + "severity": "NONE", + "applies_to": ["A"], + "adapter_generation": "v3", + "suppressed_on": ["B"], + "detail": "no streaming response patterns found; route handler source files not present in workspace to scan directly", + "remediations": [], + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "I1", + "title": "ISR / on-demand revalidation completeness", + "flagship": false, + "detected": true, + "severity_by_outcome": { + "A": "HIGH if the scaffold omits either the tag cache or the revalidation queue - /blog/[slug] is ISR (revalidate 3600), so the tag cache AND revalidation queue must be provisioned together, not just the incremental cache", + "B": "conditional - a single-container next start handles ISR in-process; HIGH only if autoscaling >1 task with ISR present and no shared cache strategy (scaling posture unknown at this point in the pipeline)" + }, + "severity": "MEDIUM", + "applies_to": ["A", "B"], + "adapter_generation": "v3", + "detail": "ISR detected via prerender-manifest.json (/blog/[slug], initialRevalidateSeconds 3600, 2 prerendered paths); both outcome severity rules computed now per severity_rule_by_outcome - report-render selects wording by the eventual recommendation", + "remediations": [ + "Outcome A: provision the tag cache AND revalidation queue together (scaffold-opennext.md)", + "Outcome B: add a shared cache handler for multi-instance deployments, or drop ISR in favor of cache-control headers + CloudFront invalidation" + ], + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "O1", + "title": "Build environment consistency", + "flagship": false, + "detected": "advisory", + "severity": "advisory", + "applies_to": ["A"], + "adapter_generation": "v3", + "detail": "no direct detection - advisory check, always surfaced when Outcome A applies: local vs. CI OpenNext builds can chunk-mismatch, causing site-wide 500s", + "remediations": ["Deploy only from CI, never from a local OpenNext build"], + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "U1", + "title": "Uncached high-invocation routes (cost driver flag)", + "flagship": false, + "detected": "unknown", + "severity": "informational", + "applies_to": ["A", "B", "C"], + "adapter_generation": "independent", + "detail": "/api/checkout is uncached (dynamic Route Handler, maxDuration 30), but invocation counts are unavailable - the usage API capture was skipped ('no usage aggregates exposed on hobby/pro plan without observability'), so the cache-config x invocation cross-reference cannot be completed; no dollar figure produced", + "remediations": ["Add appropriate caching (revalidate, Cache-Control headers) to the flagged route(s)"], + "confidence": "LOW", + "upgrade_input": "7-14 day log drain/observability export", + "computed_from_inputs": ["repo_access", "vercel_api_token", "log_drain_export"] + } + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/recommendation.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/recommendation.json new file mode 100644 index 00000000..66e71ffb --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-estimate/recommendation.json @@ -0,0 +1,18 @@ +{ + "phase": "recommend", + "timestamp": "2026-07-21T17:55:00Z", + "outcome": ["A", "B"], + "fired_rule": 4, + "tiebreak": true, + "backend_shape": null, + "backend_tiebreak": false, + "backend_resolving_input": null, + "confidence": "low", + "reasons": [ + "preview dependence unanswered - assumed not load-bearing by default (Q4 was declined), so Rule 1 fell through", + "No Lambda-hostile workload signals: no websocket routes in the route analysis, no long-running jobs (the only function config is /api/checkout at maxDuration 30 seconds), and no existing separate API service detected (backend_service_detected: false) or mentioned in the Q3 answer - Rule 2 fell through", + "Traffic shape could not be classified: Q1 was declined and no log drain was supplied, so traffic-shape confidence is LOW; Step 3's row 1 (spiky + high coupling + small team) could not match without a spiky classification even though ISR and edge middleware are both detected (2 of 3 high-coupling items) and the team is small (founder/small-team per Q3), and row 2 could not match (no sustained classification and no stated debuggability preference in Q3)", + "Presenting both paths (OpenNext/SST serverless and ECS Fargate containers) side by side rather than guessing - 14 days of log drain data would resolve the pick" + ], + "resolving_input": "14 days of log drain data" +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/.phase-status.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/.phase-status.json new file mode 100644 index 00000000..bf3b2505 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/.phase-status.json @@ -0,0 +1,14 @@ +{ + "migration_id": "0721-1725", + "last_updated": "2026-07-19T20:41:09Z", + "current_phase": "generate", + "phases": { + "prescan": "completed", + "discover": "completed", + "clarify": "completed", + "recommend": "completed", + "estimate": "completed", + "generate": "pending", + "report": "pending" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/assessment-state.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/assessment-state.json new file mode 100644 index 00000000..55b85313 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/assessment-state.json @@ -0,0 +1,342 @@ +{ + "schema_version": "1.0", + "migration_id": "0721-1725", + "last_updated": "2026-07-21T17:55: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": { + "discovery.route_disposition": { + "value": { + "/": "static", + "/dashboard": "dynamic", + "/blog/[slug]": "isr (revalidate 3600)", + "/api/checkout": "dynamic" + }, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.manifest_metadata": { + "value": { + "adapter_api_used": false, + "next_version": "15.1.0", + "manifest_availability": "available", + "fallback_reason": "next_version < 16.2" + }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.next_config": { + "value": { + "image_remote_patterns": ["cdn.acme-shop.com"], + "has_output_file_tracing_excludes": false, + "route_segment_configs": [] + }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.middleware_analysis": { + "value": { + "per_matcher_pattern": [ + { "pattern": "/dashboard/:path*", "classification": "auth_gating" }, + { "pattern": "/old-blog/:path*", "classification": "rewrite" } + ], + "classification": null, + "geo_ip_headers_used": [] + }, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.vercel_json_config": { + "value": { + "headers": 1, + "function_config": { "app/api/checkout/route.ts": { "maxDuration": 30 } }, + "crons": [{ "path": "/api/cron/digest", "schedule": "0 8 * * *" }] + }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.deployments": { + "value": { "production_ready": 1, "recent_previews": 2, "preview_error": 1 }, + "confidence": "MEDIUM", + "upgrade_input": "7-14 day log drain/observability export", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["vercel_api_token"] + }, + "discovery.env_var_names": { + "value": [ + "DATABASE_URL", + "KV_REST_API_TOKEN", + "KV_REST_API_URL", + "NEXT_PUBLIC_SITE_URL", + "NODE_ENV", + "STRIPE_SECRET_KEY", + "STRIPE_WEBHOOK_SECRET" + ], + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["vercel_api_token"] + }, + "discovery.domains": { + "value": ["acme-shop.vercel.app", "www.acme-shop.com"], + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["vercel_api_token"] + }, + "discovery.storage_integrations": { + "value": [ + { "type": "kv", "name": "acme-cart-cache", "sizeBytes": 52428800 }, + { "type": "postgres", "name": "acme-orders-db", "sizeBytes": 3221225472 } + ], + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["vercel_api_token"] + }, + "discovery.crons": { + "value": [{ "path": "/api/cron/digest", "schedule": "0 8 * * *", "source": "vercel.json" }], + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.api_routes": { + "value": ["/api/checkout", "/api/cron/digest"], + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "discovery.backend_service_detected": { + "value": false, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access", "vercel_api_token"] + }, + "discovery.usage_metrics": { + "value": "unavailable: no usage aggregates exposed on hobby/pro plan without observability", + "confidence": "LOW", + "upgrade_input": "7-14 day log drain/observability export", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["vercel_api_token", "log_drain_export"] + }, + "discovery.streaming_routes_with_empty_body_risk": { + "value": [], + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.isr": { + "value": true, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.edge_middleware": { + "value": true, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.edge_runtime_routes": { + "value": false, + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.image_optimization": { + "value": true, + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.streaming_ssr": { + "value": false, + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.server_actions_version_skew": { + "value": "unknown", + "confidence": "LOW", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "coupling.preview_deployments": { + "value": "pending_clarify", + "confidence": "LOW", + "upgrade_input": "Clarify Q4 answer (preview-deployment workflow dependence)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["clarify_q4_preview_dependence"] + }, + "coupling.vercel_managed_stores": { + "value": true, + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["vercel_api_token"] + }, + "coupling.vercel_injected_headers": { + "value": false, + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.M1": { + "value": { + "detected": true, + "severity": "HIGH", + "driver": "/old-blog/:path* rewrite pattern x ISR route /blog/[slug]" + }, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.M2": { + "value": { "detected": false, "severity": "NONE" }, + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.B1": { + "value": { "detected": false, "severity": "NONE" }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.B2": { + "value": { "detected": false, "severity": "NONE" }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.B3": { + "value": { "detected": false, "severity": "NONE" }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.B4": { + "value": { "detected": false, "severity": "NONE" }, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.S1": { + "value": { "detected": false, "severity": "NONE" }, + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.I1": { + "value": { "detected": true, "severity": "MEDIUM", "severity_by_outcome_computed": true }, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "preflight.O1": { + "value": { "detected": "advisory", "severity": "advisory" }, + "confidence": "HIGH", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access"] + }, + "recommend.fired_rule": { + "value": { + "fired_rule": 4, + "outcome": ["A", "B"], + "tiebreak": true, + "backend_shape": null + }, + "confidence": "LOW", + "upgrade_input": "14 days of log drain data", + "computed_at": "2026-07-21T17:55:00Z", + "computed_from_inputs": ["clarify_q1_traffic_shape", "clarify_q4_preview_dependence", "log_drain_export"] + }, + "preflight.U1": { + "value": { "detected": "unknown", "severity": "informational" }, + "confidence": "LOW", + "upgrade_input": "7-14 day log drain/observability export", + "computed_at": "2026-07-21T17:42:00Z", + "computed_from_inputs": ["repo_access", "vercel_api_token", "log_drain_export"] + } + }, + "clarify_answers": { + "Q1_traffic_shape": { + "prompt": "Roughly, is your traffic spiky (occasional bursts far above typical) or sustained (fairly steady load)? If you have a rough peak-to-median ratio in mind, share it — otherwise a general sense is fine.", + "answer": "declined", + "design_consequence": "feeds recommend phase rule 3 (traffic shape decides A vs. B) and rule 4 (tiebreak fires if this answer is vague and no log drain exists)", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q2_migration_trigger": { + "prompt": "What actually triggered you to consider this migration? (bill size, bill variance/unpredictability, running out of credits, data locality requirements, hitting a platform limit, something else)", + "answer": "bill size", + "design_consequence": "motivational context for the report's decision traceability appendix; frames the recommendation's framing; feeds report-render.md's what-you-gain section", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q3_devops_bandwidth": { + "prompt": "Who owns production when something breaks? Do you have dedicated DevOps capacity, or is this a founder/small-team responsibility?", + "answer": "founder/small-team responsibility - no dedicated DevOps capacity; no debuggability preference stated; no existing separate API service mentioned", + "design_consequence": "feeds recommend phase rule 3 (small team favors Outcome A; a stated debuggability preference favors Outcome B) and rule 2 (an existing separate API service, if mentioned here, feeds the Lambda-hostility check)", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q4_preview_dependence": { + "prompt": "How load-bearing are PR preview deployments for your team's workflow? (e.g. 'we review every PR live before merge' vs. 'we barely use them')", + "answer": "declined", + "design_consequence": "feeds recommend phase rule 1 (preview-dependence + separability check) - the FIRST precedence rule evaluated", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q5_nextjs_upgrade": { + "prompt": "Your app is on Next.js 15.1.0. Upgrading to 16.2+ would unlock the Adapter API's typed build output for higher-confidence discovery, and positions you for the future verified AWS adapter when it reaches GA. This is entirely optional — my default recommendation path (migrate now on OpenNext v3) works regardless of your current version. Interested, or would you rather stick with your current version for now?", + "answer": "stick with the current version for now - proceed on the default path (migrate now on OpenNext v3)", + "design_consequence": "confidence-upgrade offer only, per Requirement 3.4 - NEVER feeds a precedence rule as a gate; recorded for the report's Next Steps section (Requirement 9.8)", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q6_vercel_spend": { + "prompt": "What's your approximate monthly Vercel spend? This helps me compare AWS costs against your current bill. (Just a ballpark is fine — $0-50, $50-200, $200-1000, or $1000+)", + "answer": "$200-1000", + "design_consequence": "feeds estimate phase current_costs.vercel_monthly as the Vercel baseline for cost comparison", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q7_database_size": { + "prompt": "Approximately how large is your Vercel Postgres database? This determines which migration tool I'll set up in the scripts. (A) Less than 1 GB (B) 1-10 GB (C) 10-100 GB (D) More than 100 GB", + "answer": "B", + "design_consequence": "feeds generate phase migration script selection — pg_dump for < 10 GB, AWS DMS for >= 10 GB — and RDS instance sizing in the estimate (db.t4g.small for 1-10 GB)", + "answered_at": "2026-07-21T17:50:00Z" + }, + "Q8_compliance": { + "prompt": "Do you have compliance requirements that your AWS environment needs to meet? (Select all that apply, or 'None') (A) SOC 2 (B) PCI DSS (C) HIPAA (D) FedRAMP (E) None", + "answer": "none", + "design_consequence": "feeds generate phase baseline.tf — compliance answer drives the conditional section and CloudTrail log retention period (90 days default)", + "answered_at": "2026-07-21T17:50:00Z" + } + }, + "report_history": [] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/clarify-answers.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/clarify-answers.json new file mode 100644 index 00000000..2dcda8d9 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/clarify-answers.json @@ -0,0 +1,53 @@ +{ + "phase": "clarify", + "timestamp": "2026-07-21T17:50:00Z", + "replay_note": "No live founder in this replay run. Each answer is the skill's documented default when one exists, otherwise the most conservative documented option; the rationale is recorded per entry in answer_rationale.", + "Q1_traffic_shape": { + "prompt": "Roughly, is your traffic spiky (occasional bursts far above typical) or sustained (fairly steady load)? If you have a rough peak-to-median ratio in mind, share it — otherwise a general sense is fine.", + "answer": "declined", + "answer_rationale": "No documented default traffic shape exists in the skill. The recommendation engine's documented conservative behavior for an absent/vague Q1 with no log drain is the Step 4 tiebreak ('present both paths rather than guessing'), and clarify.md's error handling documents declined answers explicitly ('record answer: declined, apply the fallback per vercel-recommendation-engine.md § Fallback Behavior'). Declining invokes exactly that documented fallback without fabricating a traffic profile.", + "design_consequence": "feeds recommend phase rule 3 (traffic shape decides A vs. B) and rule 4 (tiebreak fires if this answer is vague and no log drain exists)" + }, + "Q2_migration_trigger": { + "prompt": "What actually triggered you to consider this migration? (bill size, bill variance/unpredictability, running out of credits, data locality requirements, hitting a platform limit, something else)", + "answer": "bill size", + "answer_rationale": "SKILL.md's documented default persona is the cost-driven founder ('the default path for a cost-driven founder is migrate now on OpenNext v3'), consistent with the stipulated $200-1000 monthly spend answer for Q6.", + "design_consequence": "motivational context for the report's decision traceability appendix; does not directly drive a precedence rule but frames the recommendation's framing (e.g. variance-driven founders lean toward Outcome B's predictability pitch); also feeds report-render.md's what-you-gain section - a credits/funding-pressure answer here surfaces the AWS Activate eligibility callout" + }, + "Q3_devops_bandwidth": { + "prompt": "Who owns production when something breaks? Do you have dedicated DevOps capacity, or is this a founder/small-team responsibility?", + "answer": "founder/small-team responsibility - no dedicated DevOps capacity; no debuggability preference stated; no existing separate API service mentioned", + "answer_rationale": "No documented default exists. The most conservative documented option is not to assume undocumented capabilities (dedicated DevOps) or preferences (debuggability) - the question's own framing offers 'founder/small-team responsibility' as the baseline case, matching the skill's founder-persona framing throughout.", + "design_consequence": "feeds recommend phase rule 3 (small team favors Outcome A; a stated debuggability preference favors Outcome B) and rule 2 (an existing separate API service, if mentioned here, feeds the Lambda-hostility check)" + }, + "Q4_preview_dependence": { + "prompt": "How load-bearing are PR preview deployments for your team's workflow? (e.g. 'we review every PR live before merge' vs. 'we barely use them')", + "answer": "declined", + "answer_rationale": "The recommendation engine documents an explicit default for an unanswered/declined Q4: 'Treat as not load-bearing, fall through to Step 2. Note in reasons: preview dependence unanswered - assumed not load-bearing by default.' Declining invokes exactly that documented default without fabricating workflow facts (the deployments API capture shows 2 recent preview deployments, so asserting 'we barely use them' would contradict evidence).", + "design_consequence": "feeds recommend phase rule 1 (preview-dependence + separability check) - the FIRST precedence rule evaluated; a load-bearing answer here can short-circuit the entire recommendation to Outcome C or stay-on-Vercel before any other signal is consulted" + }, + "Q5_nextjs_upgrade": { + "prompt": "Your app is on Next.js 15.1.0. Upgrading to 16.2+ would unlock the Adapter API's typed build output for higher-confidence discovery, and positions you for the future verified AWS adapter when it reaches GA. This is entirely optional — my default recommendation path (migrate now on OpenNext v3) works regardless of your current version. Interested, or would you rather stick with your current version for now?", + "answer": "stick with the current version for now - proceed on the default path (migrate now on OpenNext v3)", + "answer_rationale": "SKILL.md documents the default path explicitly: 'The default path for a cost-driven founder is migrate now on OpenNext v3, whatever their current Next.js version.' The upgrade is a confidence-upgrade offer, never a gate (Requirement 3.3-3.5).", + "design_consequence": "confidence-upgrade offer only, per Requirement 3.4 - NEVER feeds a precedence rule as a gate; recorded for the report's Next Steps section (Requirement 9.8)" + }, + "Q6_vercel_spend": { + "prompt": "What's your approximate monthly Vercel spend? This helps me compare AWS costs against your current bill. (Just a ballpark is fine — $0-50, $50-200, $200-1000, or $1000+)", + "answer": "$200-1000", + "answer_rationale": "Stipulated founder answer for this replay run (Q6-style Vercel monthly spend question: the founder's answer is the $200-1000 range).", + "design_consequence": "feeds estimate phase current_costs.vercel_monthly as the Vercel baseline for cost comparison; when skipped, estimate derives the baseline from Vercel API usage metrics instead" + }, + "Q7_database_size": { + "prompt": "Approximately how large is your Vercel Postgres database? This determines which migration tool I'll set up in the scripts. (A) Less than 1 GB (B) 1-10 GB (C) 10-100 GB (D) More than 100 GB", + "answer": "B", + "answer_rationale": "Consistent with the Vercel API stores capture (acme-orders-db, sizeBytes 3221225472 ≈ 3 GB, squarely in the 1-10 GB bracket) and yields the same conservative tooling/sizing (pg_dump, db.t4g.small) as the documented 'unknown' fallback would.", + "design_consequence": "feeds generate phase migration script selection — pg_dump for < 10 GB, AWS DMS for >= 10 GB — and RDS instance sizing in the estimate (db.t4g.micro for < 1 GB, db.t4g.small for 1-10 GB, db.r6g.large for 10-100 GB, db.r6g.xlarge for > 100 GB)" + }, + "Q8_compliance": { + "prompt": "Do you have compliance requirements that your AWS environment needs to meet? (Select all that apply, or 'None') (A) SOC 2 (B) PCI DSS (C) HIPAA (D) FedRAMP (E) None", + "answer": "none", + "answer_rationale": "No documented default exists; 'none' is the documented error-handling option ('baseline.tf emits the always-on resources only, no compliance-conditional section') and asserting compliance obligations with no supporting evidence would fabricate requirements.", + "design_consequence": "feeds generate phase baseline.tf — compliance answer drives the conditional section (AWS Config recorder, Security Hub + standards subscriptions) and CloudTrail log retention period (90 days default, up to 2190 for HIPAA)" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/coupling-score.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/coupling-score.json new file mode 100644 index 00000000..e5489878 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/coupling-score.json @@ -0,0 +1,100 @@ +{ + "phase": "discover", + "timestamp": "2026-07-21T17:42:00Z", + "items": [ + { + "id": "isr", + "title": "ISR / on-demand revalidation", + "detected": true, + "detection_method": "prerender-manifest.json shows /blog/[slug] prerendered with initialRevalidateSeconds: 3600 (2 paths); no revalidateTag/revalidatePath calls found in the available source configs", + "weight_rationale": "Portable via OpenNext but requires the tag cache and revalidation queue to be provisioned as a unit, not independently - see Pre-Flight Check I1.", + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "edge_middleware", + "title": "Edge middleware", + "detected": true, + "detection_method": "middleware.ts exists (prescan has_middleware: true); matcher covers /dashboard/:path* (auth_gating) and /old-blog/:path* (rewrite)", + "weight_rationale": "Behavioral divergence on every AWS outcome - see Pre-Flight Check M1 (flagship).", + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "edge_runtime_routes", + "title": "Edge runtime routes", + "detected": false, + "detection_method": "no runtime: 'edge' declarations found in next.config.js or available route segment configs", + "weight_rationale": "Edge runtime routes hit Lambda@Edge-style constraints (smaller runtime, limited Node API surface) when migrated.", + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "image_optimization", + "title": "Image optimization", + "detected": true, + "detection_method": "next.config.js images.remotePatterns configured for cdn.acme-shop.com; next/image component usage not directly verifiable (app/ source tree not present in workspace)", + "weight_rationale": "Portable - OpenNext ships its own sharp-based image optimization; see Pre-Flight Check B3 for the duplicate-dependency conflict.", + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "streaming_ssr", + "title": "Streaming SSR", + "detected": false, + "detection_method": "no streaming response patterns (ReadableStream, Response with stream body) found; route handler source files not present in workspace to scan", + "weight_rationale": "A known Lambda workaround exists for empty-body streams (see Pre-Flight Check S1); next start (Outcome B) streams natively.", + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "server_actions_version_skew", + "title": "Server Actions / version skew exposure", + "detected": "unknown", + "detection_method": "Next.js 15.1.0 (App Router in use per app-path-routes-manifest.json) supports Server Actions, but Server Actions usage in source cannot be verified - app/ source tree not present in workspace", + "weight_rationale": "Vercel handles skew protection (mismatched client/server deploy versions) automatically; the AWS path must address this explicitly regardless of outcome.", + "confidence": "LOW", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "preview_deployments", + "title": "Preview deployments", + "detected": "pending_clarify", + "detection_method": "Clarify Q4 (team workflow) - not detectable from code alone; note: the deployments API capture shows 2 recent preview deployments, suggesting active use, but the resolved value comes from clarify-answers.json", + "weight_rationale": "No AWS equivalent exists; drives the Outcome C (Hybrid) precedence rule. The single heaviest 'what you lose' item in every report.", + "confidence": "LOW", + "upgrade_input": "Clarify Q4 answer (preview-deployment workflow dependence)", + "computed_from_inputs": ["clarify_q4_preview_dependence"] + }, + { + "id": "vercel_managed_stores", + "title": "Vercel KV / Postgres / Blob / Edge Config / Cron", + "detected": true, + "detection_method": "Vercel API stores capture: KV store 'acme-cart-cache' (50 MB, iad1) and Postgres store 'acme-orders-db' (3 GB, iad1); one cron in vercel.json; @vercel/kv and @vercel/postgres are package.json dependencies", + "weight_rationale": "Clean peripheral mappings exist for all of these - see knowledge/peripheral-mappings.json.", + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_from_inputs": ["vercel_api_token"] + }, + { + "id": "vercel_injected_headers", + "title": "Vercel-injected headers (geo, IP)", + "detected": false, + "detection_method": "grep for x-vercel-ip-* and similar headers across middleware.ts and available server code found no usage", + "weight_rationale": "Needs a CloudFront-headers rewrite mapping - see Pre-Flight Check M2.", + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + } + ], + "phased_migration_candidate": { + "flagged": false, + "note": "coupling is spread across ISR, middleware, and managed stores at ordinary levels - no single component is at unusually high coupling relative to the rest" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/discovery.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/discovery.json new file mode 100644 index 00000000..a0ddae02 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/discovery.json @@ -0,0 +1,205 @@ +{ + "phase": "discover", + "timestamp": "2026-07-21T17:42:00Z", + "signal_source_used": "manifest_fallback", + "route_disposition": [ + { + "route": "/", + "disposition": "static", + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "source": "prerender-manifest.json (initialRevalidateSeconds: false)", + "computed_from_inputs": ["repo_access"] + }, + { + "route": "/dashboard", + "disposition": "dynamic", + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "note": "routes-manifest.json places /dashboard in staticRoutes (fixed path shape) but it is ABSENT from prerender-manifest.json entirely, so it renders per-request; consistent with the session-cookie auth gate middleware applies to /dashboard/:path*", + "source": "prerender-manifest.json (absent) resolved over routes-manifest.json staticRoutes membership", + "computed_from_inputs": ["repo_access"] + }, + { + "route": "/blog/[slug]", + "disposition": "isr", + "revalidate_seconds": 3600, + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "note": "prerender-manifest.json contains /blog/launch-post and /blog/hiring with initialRevalidateSeconds: 3600 (srcRoute /blog/[slug]) and a dynamicRoutes entry with fallback: null - genuinely ISR per the build's own decision", + "source": "prerender-manifest.json routes + dynamicRoutes", + "computed_from_inputs": ["repo_access"] + }, + { + "route": "/api/checkout", + "disposition": "dynamic", + "confidence": "LOW", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output, which classifies Route Handlers directly (this fragment's manifest sources only classify page routes, so this value is inferred from the absence of a static-export declaration rather than manifest-sourced)", + "note": "API Route Handler from app-path-routes-manifest.json ('/api/checkout/route'); no 'export const dynamic = \"force-static\"' declaration found (route source file not present in workspace to inspect), so default dynamic", + "source": "app-path-routes-manifest.json", + "computed_from_inputs": ["repo_access"] + } + ], + "manifest_metadata": { + "adapter_api_used": false, + "next_version": "15.1.0", + "manifest_availability": "available", + "fallback_reason": "next_version < 16.2", + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + "next_config": { + "route_segment_configs": [], + "route_segment_configs_note": "no route source files (app/ tree) present in the workspace to cross-reference; no revalidate/dynamic/runtime:'edge' declarations found", + "image_config": { + "remotePatterns": [{ "protocol": "https", "hostname": "cdn.acme-shop.com" }], + "domains": [], + "custom_loader": false + }, + "has_output_file_tracing_excludes": false, + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + "middleware_analysis": { + "matcher": ["/dashboard/:path*", "/old-blog/:path*"], + "per_matcher_pattern": [ + { + "pattern": "/dashboard/:path*", + "classification": "auth_gating", + "evidence": "middleware body branches on pathname.startsWith('/dashboard'); missing 'session' cookie triggers NextResponse.redirect to /login", + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)" + }, + { + "pattern": "/old-blog/:path*", + "classification": "rewrite", + "evidence": "middleware body branches on pathname.startsWith('/old-blog/'); NextResponse.rewrite to the /blog/ equivalent path on every request", + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)" + } + ], + "classification": null, + "classification_note": "per_matcher_pattern entries diverge (auth_gating vs rewrite), so the collapsed classification is null per discover-configs.md Step 5", + "geo_ip_headers_used": [], + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_from_inputs": ["repo_access"] + }, + "vercel_json_config": { + "headers": [ + { + "source": "/(.*)", + "headers": [{ "key": "X-Frame-Options", "value": "DENY" }] + } + ], + "redirects": [], + "rewrites": [], + "function_config": { + "app/api/checkout/route.ts": { "maxDuration": 30 } + }, + "regions": [], + "crons": [{ "path": "/api/cron/digest", "schedule": "0 8 * * *" }], + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + "streaming_routes_with_empty_body_risk": [], + "streaming_scan_note": "no route handler source files present in the workspace to scan; no streaming patterns found", + "deployments": [ + { "uid": "dpl_prod042", "state": "READY", "target": "production", "created": 1784050000000 }, + { "uid": "dpl_prev107", "state": "READY", "target": "preview", "created": 1784040000000 }, + { "uid": "dpl_prev106", "state": "ERROR", "target": "preview", "created": 1784030000000 } + ], + "deployments_meta": { + "confidence": "MEDIUM", + "upgrade_input": "7-14 day log drain/observability export", + "note": "1 production deployment READY; 2 recent preview deployments (one READY, one ERROR) - the team actively uses preview deployments", + "computed_from_inputs": ["vercel_api_token"] + }, + "env_var_names": [ + "DATABASE_URL", + "KV_REST_API_TOKEN", + "KV_REST_API_URL", + "NEXT_PUBLIC_SITE_URL", + "NODE_ENV", + "STRIPE_SECRET_KEY", + "STRIPE_WEBHOOK_SECRET" + ], + "env_var_names_meta": { + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "note": "key names only per Requirement 1.6 - values never fetched or persisted", + "computed_from_inputs": ["vercel_api_token"] + }, + "domains": ["acme-shop.vercel.app", "www.acme-shop.com"], + "domains_meta": { + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_from_inputs": ["vercel_api_token"] + }, + "crons": [ + { + "path": "/api/cron/digest", + "schedule": "0 8 * * *", + "source": "vercel.json", + "confidence": "HIGH", + "note": "API cron capture was skipped (manifest: 'no GET endpoint with a crons path segment found via vercel api list - crons from vercel.json only'); this cron path /api/cron/digest does NOT appear in app-path-routes-manifest.json - discrepancy recorded", + "computed_from_inputs": ["repo_access"] + } + ], + "crons_api_capture": "unavailable: no GET endpoint with a crons path segment found via vercel api list - crons from vercel.json only", + "storage_integrations": [ + { + "type": "kv", + "metadata": { "id": "store_kv_01", "name": "acme-cart-cache", "region": "iad1", "sizeBytes": 52428800 }, + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_from_inputs": ["vercel_api_token"] + }, + { + "type": "postgres", + "metadata": { "id": "store_pg_01", "name": "acme-orders-db", "region": "iad1", "sizeBytes": 3221225472 }, + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_from_inputs": ["vercel_api_token"] + } + ], + "peripherals": [ + { + "type": "kv", + "source": "vercel_api", + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_from_inputs": ["vercel_api_token"] + }, + { + "type": "postgres", + "source": "vercel_api", + "confidence": "MEDIUM", + "upgrade_input": "infrastructure-pointing env var hostnames shared explicitly by the founder (Tier 3)", + "computed_from_inputs": ["vercel_api_token"] + }, + { "type": "cron", "source": "vercel.json", "confidence": "HIGH", "computed_from_inputs": ["repo_access"] } + ], + "api_routes": ["/api/checkout", "/api/cron/digest"], + "api_routes_meta": { + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "note": "/api/checkout from app-path-routes-manifest.json; /api/cron/digest referenced only by vercel.json crons and absent from the build manifest", + "computed_from_inputs": ["repo_access"] + }, + "backend_service_detected": false, + "backend_service_detected_meta": { + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "note": "app/api tree is 1-2 routes (checkout, cron digest) - not substantial enough to stand alone as a separable backend service; no separate backend Vercel project in the projects capture", + "computed_from_inputs": ["repo_access", "vercel_api_token"] + }, + "usage_metrics": { + "status": "unavailable: no usage aggregates exposed on hobby/pro plan without observability", + "confidence": "LOW", + "upgrade_input": "7-14 day log drain/observability export", + "computed_from_inputs": ["vercel_api_token", "log_drain_export"] + }, + "header_probe_results": [], + "probe_limitations": ["probe not attempted - Tier 2 production URL + throwaway test account not supplied"] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/estimation-infra.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/estimation-infra.json new file mode 100644 index 00000000..739ccc05 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/estimation-infra.json @@ -0,0 +1,261 @@ +{ + "phase": "estimate", + "design_source": "recommendation.json", + "timestamp": "2026-07-21T18:00:00Z", + "pricing_source": { + "status": "cached_fallback", + "message": "Pricing source: stale cache only (updated 2026-06-14). The awspricing MCP server is unreachable (3 attempts per Step 0b are not possible in this no-network environment; documented fallback applied). Proceeding with cached pricing; accuracy +/-5-10% for infrastructure.", + "fallback_staleness": { + "cache_last_updated": "2026-06-14", + "checked_on": "2026-07-19", + "days_stale": 35, + "staleness_threshold_days": 30, + "note": "Infrastructure rates remain reliable past the staleness threshold per the pricing cache's own staleness_note." + }, + "services_by_source": { + "live": [], + "fallback": [ + "cloudfront", + "s3", + "eventbridge", + "secrets_manager", + "nat_gateway", + "rds_postgresql", + "elasticache_redis", + "fargate", + "alb" + ], + "estimated": ["lambda", "ecr", "cron_eventbridge_lambda"] + }, + "services_with_missing_fallback": [] + }, + "accuracy_confidence": "+/-5-10% for cached infrastructure pricing", + "current_costs": { + "source": "user_provided", + "vercel_monthly": 600, + "baseline_note": "Q6_vercel_spend answered '$200-1000' -> $600 midpoint per Part 1 priority 2 (user-provided range). discovery.json.usage_metrics has no billing_data (status: unavailable), so API billing (priority 1) was not applicable." + }, + "projected_costs": { + "_outcome_note": "recommendation.json.outcome is the unresolved tiebreak ['A','B']; per the cost engine's Part 2 tiebreak handling, projected_costs and everything downstream reflect Outcome A (OpenNext/SST serverless). Outcome B (Fargate) is priced in tiebreak_alternative.", + "aws_monthly_premium": 172.14, + "aws_monthly_balanced": 121.23, + "aws_monthly_optimized": 98.07, + "aws_annual_optimized": 1176.84, + "breakdown": { + "total": { "premium": 172.14, "mid": 121.23, "low": 98.07 }, + "lambda": { + "premium": 9.53, + "mid": 4.05, + "low": 3.3, + "pricing_source": "estimated", + "note": "Rates from the vendored cost-engine formula (requests * $0.20/1M + GB-seconds * $0.0000166667); Lambda is not in aws-infra-pricing.json. Assumed 1.5M invocations/mo, 512 MB avg, 300 ms avg duration = 225,000 GB-s -> $0.30 + $3.75. Premium adds ~$5.48 provisioned concurrency (0.5 GB always-on); Optimized applies Graviton arm64 (-20% on duration)." + }, + "cloudfront": { + "premium": 14.75, + "mid": 14.75, + "low": 14.75, + "pricing_source": "cached_fallback", + "note": "Assumed 150 GB egress/mo * $0.085/GB (cache rate) = $12.75 + 2M requests * ~$0.01/10K = $2.00. Egress volume is traffic-driven and held constant across tiers." + }, + "s3": { + "premium": 1.0, + "mid": 1.0, + "low": 0.9, + "pricing_source": "cached_fallback", + "note": "Static assets + ISR cache (2 ISR paths). Assumed ~5 GB * $0.023/GB (cache rate) + light request volume. Optimized applies S3 Intelligent-Tiering (~10% off)." + }, + "eventbridge": { + "premium": 1.0, + "mid": 1.0, + "low": 1.0, + "pricing_source": "cached_fallback", + "note": "ISR revalidation queue: 2 ISR paths at revalidate 3600s is ~1,500 events/mo (near-zero at $1.00/1M); cache monthly_baseline_est of $1 used as the conservative floor." + }, + "secrets_manager": { + "premium": 2.85, + "mid": 2.85, + "low": 2.85, + "pricing_source": "cached_fallback", + "note": "7 secrets (discovery.env_var_names count) * $0.40/mo = $2.80 + ~$0.05 API calls." + }, + "nat_gateway": { + "premium": 67.5, + "mid": 33.75, + "low": 33.75, + "pricing_source": "cached_fallback", + "note": "$32.85/mo fixed (cache rate) + assumed 20 GB/mo processed * $0.045/GB = $0.90. Premium doubles for dedicated NAT per AZ (2x); Balanced/Optimized use a single NAT." + }, + "rds_postgresql": { + "premium": 52.05, + "mid": 52.05, + "low": 33.83, + "pricing_source": "cached_fallback", + "note": "db.t4g.small per Q7 answer B (1-10 GB; store is ~3 GB): $0.065/hr * 730 = $47.45 + 20 GB GP3 allocated * $0.23/GB = $4.60. Cache rate is Multi-AZ baked-in (multi_az_handling: baked_in) so NO 2x multiplier at Premium and the same conservative rate is used at Balanced. Optimized applies 1-yr reserved (~35% off; t4g is already Graviton)." + }, + "elasticache_redis": { + "premium": 23.36, + "mid": 11.68, + "low": 7.59, + "pricing_source": "cached_fallback", + "note": "cache.t4g.micro single node for the 50 MB KV store: $0.016/hr * 730 = $11.68. multi_az_handling is multiplier_x2, so Premium (multi-AZ) doubles to $23.36. Optimized applies 1-yr reserved (~35% off; t4g is already Graviton)." + }, + "cron_eventbridge_lambda": { + "premium": 0.1, + "mid": 0.1, + "low": 0.1, + "pricing_source": "estimated", + "note": "Cron peripheral (/api/cron/digest, daily): ~30 EventBridge schedule events + 30 Lambda invocations/mo is effectively free; $0.10 conservative floor so the peripheral is explicitly priced." + } + } + }, + "cost_comparison": { + "vercel_monthly": 600, + "aws_monthly_balanced": 121.23, + "monthly_delta": -478.77, + "monthly_delta_pct": -79.8, + "breakeven_note": "AWS is cheaper by $478.77/mo under Outcome A (OpenNext/SST, Balanced $121.23/mo). Under Outcome B (Fargate, Balanced $173.69/mo) AWS is still cheaper by $426.31/mo. Both candidate outcomes are a clear financial win against the ~$600/mo Vercel baseline." + }, + "roi_analysis": { + "monthly_savings_balanced": 478.77, + "annual_savings_balanced": 5745.24, + "monthly_savings_balanced_outcome_b": 426.31, + "annual_savings_balanced_outcome_b": 5115.72, + "migration_cost_one_time": "medium complexity tier -> 2-4 engineer-weeks of effort (labor dollar costs are out of scope for this phase), plus one-time costs listed in migration_cost_considerations", + "payback_period": "Under either outcome, roughly one month of savings covers the parallel-run overlap cost (~$120-175 of extra AWS spend), so payback on hard costs is ~1 month.", + "non_financial_value": [ + "Operational control over the full stack (VPC, scaling, observability)", + "Vendor independence / reduced platform lock-in", + "Access to the broader AWS ecosystem (managed databases, queues, ML services) as the product grows" + ] + }, + "optimization_opportunities": [ + "Graviton (ARM64) compute - ~20% cost reduction on Lambda/Fargate (already defaulted in the Optimized tier; Premium keeps x86 for compatibility)", + "1-year reserved capacity on RDS and ElastiCache - 30-40% savings once instance sizes stabilize", + "Spot capacity for fault-tolerant/non-critical tasks - 60-70% savings (relevant mainly under Outcome B for background workloads)", + "S3 lifecycle policies / Intelligent-Tiering - automatic tiering for infrequently accessed static assets and ISR cache entries", + "CloudFront caching optimization - longer TTLs where ISR revalidation handles freshness, reducing origin hits and Lambda/Fargate invocations" + ], + "migration_cost_considerations": { + "estimated_effort": "medium complexity tier -> 2-4 engineer-weeks", + "one_time_costs": "Data transfer for the ~3 GB Postgres store via pg_dump (negligible transfer cost) and the ~50 MB KV export; a parallel-run period of ~2-4 weeks running Vercel and AWS side by side adds roughly one month of AWS spend ($120-175 depending on the outcome picked)." + }, + "tiebreak_alternative": { + "outcome": "B", + "_outcome_note": "Outcome B (ECS Fargate + ALB) priced with the SAME tier structure and the same peripheral set; Property-16 verified independently against this breakdown.", + "aws_monthly_premium": 237.14, + "aws_monthly_balanced": 173.69, + "aws_monthly_optimized": 144.17, + "breakdown": { + "total": { "premium": 237.14, "mid": 173.69, "low": 144.17 }, + "fargate": { + "premium": 54.06, + "mid": 36.04, + "low": 28.83, + "pricing_source": "cached_fallback", + "note": "Assumed 2 always-on tasks * 0.5 vCPU / 1 GB each: 730 vCPU-hrs * $0.04048 = $29.55 + 1,460 GB-hrs * $0.004445 = $6.49. Premium applies 1.5x larger tasks; Optimized applies Graviton (-20%)." + }, + "alb": { + "premium": 22.27, + "mid": 22.27, + "low": 22.27, + "pricing_source": "cached_fallback", + "note": "$16.43/mo fixed (cache rate) + assumed ~1 avg LCU: 730 hrs * $0.008 = $5.84. Always-on for web traffic; constant across tiers." + }, + "cloudfront": { + "premium": 14.75, + "mid": 14.75, + "low": 14.75, + "pricing_source": "cached_fallback", + "note": "Same sizing as Outcome A: 150 GB egress * $0.085/GB + 2M requests * ~$0.01/10K." + }, + "ecr": { + "premium": 0.2, + "mid": 0.2, + "low": 0.2, + "pricing_source": "estimated", + "note": "ECR is not in aws-infra-pricing.json; standard published rate $0.10/GB-month * assumed ~2 GB of container images." + }, + "nat_gateway": { + "premium": 67.5, + "mid": 33.75, + "low": 33.75, + "pricing_source": "cached_fallback", + "note": "Same as Outcome A: $32.85 fixed + 20 GB/mo * $0.045; Premium doubles for NAT per AZ." + }, + "secrets_manager": { + "premium": 2.85, + "mid": 2.85, + "low": 2.85, + "pricing_source": "cached_fallback", + "note": "Same as Outcome A: 7 secrets * $0.40 + light API calls." + }, + "rds_postgresql": { + "premium": 52.05, + "mid": 52.05, + "low": 33.83, + "pricing_source": "cached_fallback", + "note": "Peripheral, same as Outcome A: db.t4g.small (Q7=B) + 20 GB GP3; Multi-AZ baked into the cache rate, no Premium multiplier; Optimized 1-yr reserved (~35% off)." + }, + "elasticache_redis": { + "premium": 23.36, + "mid": 11.68, + "low": 7.59, + "pricing_source": "cached_fallback", + "note": "Peripheral, same as Outcome A: cache.t4g.micro single node; Premium 2x per multiplier_x2; Optimized 1-yr reserved (~35% off)." + }, + "cron_eventbridge_lambda": { + "premium": 0.1, + "mid": 0.1, + "low": 0.1, + "pricing_source": "estimated", + "note": "Peripheral, same as Outcome A: daily cron via EventBridge + Lambda; $0.10 conservative floor. (Under Outcome B, ISR revalidation is handled by next start in the container, so no separate revalidation-queue EventBridge line is needed.)" + } + } + }, + "complexity_tier": "medium", + "complexity_inputs": { + "service_count": 8, + "service_count_note": "Distinct AWS services in the Outcome A design: Lambda, CloudFront, S3, EventBridge, Secrets Manager, NAT Gateway, RDS PostgreSQL, ElastiCache (the cron peripheral reuses EventBridge + Lambda). Outcome B is 9 distinct services (Fargate, ALB, CloudFront, ECR, NAT, Secrets Manager, RDS, ElastiCache, EventBridge+Lambda cron) but complexity, like all downstream fields, is classified from Outcome A per the tiebreak handling.", + "monthly_spend": 121.23, + "multi_region": false, + "compliance_present": false, + "has_databases": true, + "availability_multi_az": false, + "classification_note": "Not large (service_count < 9, spend <= $10k, single-region, Q8 compliance = none). Medium matches on service_count in [4,8] AND has_databases (Postgres peripheral). Tier: medium." + }, + "financial_summary": { + "one_liner": "Unresolved A-vs-B tiebreak: AWS Balanced is ~$121.23/mo on Outcome A (OpenNext/SST serverless) or ~$173.69/mo on Outcome B (ECS Fargate) vs Vercel ~$600/mo - roughly 80% or 71% savings either way. The Generate phase will ask you to pick; 14 days of log drain data would also resolve it.", + "verdict": "cheaper" + }, + "recommendation": { + "path": "migrate_optimized", + "path_label": "Migrate - clear financial win under BOTH candidate outcomes of the unresolved A-vs-B tiebreak: Outcome A (OpenNext/SST) Balanced ~$121.23/mo vs Outcome B (Fargate) Balanced ~$173.69/mo, both far below Vercel's ~$600/mo. Generate will ask you to pick between A and B; 14 days of log drain data would also resolve the tiebreak.", + "resolving_input": "14 days of log drain data", + "migrate_if": [ + "Your ~$600/mo Vercel bill is the primary pain (Q2: bill size) - AWS Balanced is $426-479/mo cheaper under either outcome", + "You are comfortable owning production operations at founder/small-team scale (managed services + Terraform keep the operational load modest)", + "PR preview deployments are genuinely not load-bearing (Q4 was declined and defaulted to not load-bearing - revisit if that default is wrong)", + "You want vendor independence and room to grow into the AWS ecosystem" + ], + "stay_if": [ + "PR preview deployments turn out to be load-bearing for your team's workflow (no first-class AWS equivalent; this was assumed not load-bearing by default)", + "You cannot absorb 2-4 engineer-weeks of migration effort right now", + "Vercel's zero-ops DX is worth more to you than ~$5,100-5,750/yr in savings", + "Traffic turns out to be far spikier or heavier than the conservative assumptions here (re-estimate with 14 days of log drain data before committing)" + ] + }, + "assumptions": [ + "Traffic shape is unknown (Q1 declined, usage_metrics unavailable). Conservative small founder-scale profile assumed, consistent with the $200-1000/mo Vercel spend bracket: ~2M CloudFront requests/mo, ~150 GB CDN egress/mo, ~1.5M Lambda invocations/mo (Outcome A) at 512 MB avg / 300 ms avg, ~20 GB/mo NAT-processed data, ~1 avg ALB LCU (Outcome B).", + "RDS storage allocated at 20 GB GP3 for the ~3 GB database (sensible minimum allocation with growth headroom).", + "Fargate sizing (Outcome B): 2 always-on tasks at 0.5 vCPU / 1 GB - the minimum HA posture for a small always-on Next.js service.", + "ECR image storage assumed ~2 GB.", + "Vercel baseline is the $600 midpoint of the founder's $200-1000 answer, not a measured bill." + ], + "warnings": [ + "Pricing cache is 35 days stale (updated 2026-06-14, threshold 30 days) and the awspricing MCP server is unreachable; all cache-covered services use pricing_source cached_fallback per the pricing hierarchy (accuracy +/-5-10% for infrastructure).", + "Lambda rates are not in aws-infra-pricing.json; they come from the vendored cost-engine formula constants ($0.20/1M requests, $0.0000166667/GB-s) and are marked 'estimated'.", + "ECR is not in aws-infra-pricing.json; priced at the standard published $0.10/GB-month and marked 'estimated'.", + "All invocation/bandwidth figures are assumptions, not measurements - 14 days of log drain data would materially tighten this estimate and also resolves the A-vs-B tiebreak.", + "No suspected prompt-injection content was found in the scanned artifacts." + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/preflight-findings.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/preflight-findings.json new file mode 100644 index 00000000..0e229a21 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/preflight-findings.json @@ -0,0 +1,158 @@ +{ + "phase": "discover", + "timestamp": "2026-07-21T17:42:00Z", + "checks": [ + { + "id": "M1", + "title": "Cached-route x middleware intersection", + "flagship": true, + "detected": true, + "severity": "HIGH", + "applies_to": ["A", "B", "C"], + "adapter_generation": "independent", + "detail": "matcher pattern \"/old-blog/:path*\" (classification: rewrite, per-request) intersects the ISR route /blog/[slug] via its rewrite target - a CloudFront-cached response under /old-blog/* skips the middleware rewrite entirely, and cached /old-blog copies fork from /blog ISR revalidation. This pattern/route intersection alone drives the HIGH verdict. Matcher pattern \"/dashboard/:path*\" (classification: auth_gating) intersects only /dashboard, which is dynamic (rendered per-request, absent from prerender-manifest.json) and therefore NOT cacheable - it contributes nothing to this check's severity despite its auth_gating classification.", + "remediations": [ + "Make affected routes dynamic", + "Move logic server-side", + "OpenNext external middleware option (Outcome A only)", + "CloudFront Functions for simple header/redirect logic (Outcome B)", + "Accept the behavior change knowingly" + ], + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "M2", + "title": "Geo/IP header dependence", + "flagship": false, + "detected": false, + "severity": "NONE", + "applies_to": ["A", "B", "C"], + "adapter_generation": "independent", + "detail": "no Vercel-injected geo/IP headers (x-vercel-ip-country, x-vercel-ip-city, etc.) found in middleware.ts or available server code", + "remediations": [], + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "B1", + "title": "Monorepo lockfile conflicts", + "flagship": false, + "detected": false, + "severity": "NONE", + "applies_to": ["A"], + "adapter_generation": "v3", + "suppressed_on": ["B"], + "detail": "exactly one lockfile at repo root (pnpm-lock.yaml, per prescan lockfile_census) - no conflict", + "remediations": [], + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "B2", + "title": "Yarn packageManager pin", + "flagship": false, + "detected": false, + "severity": "NONE", + "applies_to": ["A"], + "adapter_generation": "v3", + "detail": "package.json packageManager is \"pnpm@9.1.0\" - not yarn", + "remediations": [], + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "B3", + "title": "sharp as a direct dependency", + "flagship": false, + "detected": false, + "severity": "NONE", + "applies_to": ["A"], + "adapter_generation": "v3", + "suppressed_on": ["B"], + "detail": "sharp not present in package.json dependencies or devDependencies (prescan has_sharp_dependency: false)", + "remediations": [], + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "B4", + "title": "Bundle contamination", + "flagship": false, + "detected": false, + "severity": "NONE", + "applies_to": ["A"], + "adapter_generation": "v3", + "detail": "no known-problematic packages in dependencies (@vercel/kv, @vercel/postgres, next, react, react-dom, stripe); next.config.js has no outputFileTracingExcludes and none appears needed", + "remediations": [], + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "S1", + "title": "Streaming routes with potentially-empty bodies", + "flagship": false, + "detected": false, + "severity": "NONE", + "applies_to": ["A"], + "adapter_generation": "v3", + "suppressed_on": ["B"], + "detail": "no streaming response patterns found; route handler source files not present in workspace to scan directly", + "remediations": [], + "confidence": "MEDIUM", + "upgrade_input": "full app/ source tree access (only build manifests and top-level configs are present in the workspace)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "I1", + "title": "ISR / on-demand revalidation completeness", + "flagship": false, + "detected": true, + "severity_by_outcome": { + "A": "HIGH if the scaffold omits either the tag cache or the revalidation queue - /blog/[slug] is ISR (revalidate 3600), so the tag cache AND revalidation queue must be provisioned together, not just the incremental cache", + "B": "conditional - a single-container next start handles ISR in-process; HIGH only if autoscaling >1 task with ISR present and no shared cache strategy (scaling posture unknown at this point in the pipeline)" + }, + "severity": "MEDIUM", + "applies_to": ["A", "B"], + "adapter_generation": "v3", + "detail": "ISR detected via prerender-manifest.json (/blog/[slug], initialRevalidateSeconds 3600, 2 prerendered paths); both outcome severity rules computed now per severity_rule_by_outcome - report-render selects wording by the eventual recommendation", + "remediations": [ + "Outcome A: provision the tag cache AND revalidation queue together (scaffold-opennext.md)", + "Outcome B: add a shared cache handler for multi-instance deployments, or drop ISR in favor of cache-control headers + CloudFront invalidation" + ], + "confidence": "MEDIUM", + "upgrade_input": "upgrade to Next.js >= 16.2 for the Adapter API's typed build output (a confidence upgrade offer, never a migration prerequisite)", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "O1", + "title": "Build environment consistency", + "flagship": false, + "detected": "advisory", + "severity": "advisory", + "applies_to": ["A"], + "adapter_generation": "v3", + "detail": "no direct detection - advisory check, always surfaced when Outcome A applies: local vs. CI OpenNext builds can chunk-mismatch, causing site-wide 500s", + "remediations": ["Deploy only from CI, never from a local OpenNext build"], + "confidence": "HIGH", + "computed_from_inputs": ["repo_access"] + }, + { + "id": "U1", + "title": "Uncached high-invocation routes (cost driver flag)", + "flagship": false, + "detected": "unknown", + "severity": "informational", + "applies_to": ["A", "B", "C"], + "adapter_generation": "independent", + "detail": "/api/checkout is uncached (dynamic Route Handler, maxDuration 30), but invocation counts are unavailable - the usage API capture was skipped ('no usage aggregates exposed on hobby/pro plan without observability'), so the cache-config x invocation cross-reference cannot be completed; no dollar figure produced", + "remediations": ["Add appropriate caching (revalidate, Cache-Control headers) to the flagged route(s)"], + "confidence": "LOW", + "upgrade_input": "7-14 day log drain/observability export", + "computed_from_inputs": ["repo_access", "vercel_api_token", "log_drain_export"] + } + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/recommendation.json b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/recommendation.json new file mode 100644 index 00000000..66e71ffb --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed-generate/recommendation.json @@ -0,0 +1,18 @@ +{ + "phase": "recommend", + "timestamp": "2026-07-21T17:55:00Z", + "outcome": ["A", "B"], + "fired_rule": 4, + "tiebreak": true, + "backend_shape": null, + "backend_tiebreak": false, + "backend_resolving_input": null, + "confidence": "low", + "reasons": [ + "preview dependence unanswered - assumed not load-bearing by default (Q4 was declined), so Rule 1 fell through", + "No Lambda-hostile workload signals: no websocket routes in the route analysis, no long-running jobs (the only function config is /api/checkout at maxDuration 30 seconds), and no existing separate API service detected (backend_service_detected: false) or mentioned in the Q3 answer - Rule 2 fell through", + "Traffic shape could not be classified: Q1 was declined and no log drain was supplied, so traffic-shape confidence is LOW; Step 3's row 1 (spiky + high coupling + small team) could not match without a spiky classification even though ISR and edge middleware are both detected (2 of 3 high-coupling items) and the team is small (founder/small-team per Q3), and row 2 could not match (no sustained classification and no stated debuggability preference in Q3)", + "Presenting both paths (OpenNext/SST serverless and ECS Fargate containers) side by side rather than guessing - 14 days of log drain data would resolve the pick" + ], + "resolving_input": "14 days of log drain data" +} 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..7dd1e705 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/vercel-capture/seed/.phase-status.json @@ -0,0 +1,14 @@ +{ + "migration_id": "0721-1725", + "last_updated": "2026-07-21T17:26:00Z", + "current_phase": "discover", + "phases": { + "prescan": "completed", + "discover": "in_progress", + "clarify": "pending", + "recommend": "pending", + "estimate": "pending", + "generate": "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..90e17471 --- /dev/null +++ b/migrate/plugins/migration-to-aws/scripts/validate-assessment-report.py @@ -0,0 +1,628 @@ +#!/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'", + "cost-comparison": "estimation-infra.json exists (estimate phase completed)", + "artifacts-generated": "estimation-infra.json exists (generate phase completed)", +} + +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, + estimation_infra: dict | None = 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)" + ) + + # Cost-comparison and artifacts-generated are required when estimation-infra.json exists + if estimation_infra is not None: + if counts.get("cost-comparison", 0) < 1: + errors.append( + 'estimation-infra.json exists but no
' + "(the cost comparison section is required when an estimate has been produced)" + ) + if counts.get("artifacts-generated", 0) < 1: + errors.append( + 'estimation-infra.json exists but no
' + "(the artifacts summary section is required when generation has completed)" + ) + + 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, + estimation_infra: 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, estimation_infra) + ) + + if require_toc: + if not _toc_hrefs(html): + errors.append('missing