AI Deployment Readiness Checker
Combine deterministic static analysis with Claude structured outputs.
Introduction
30 Second Summary
Every team hits the same moment before a launch: someone asks "are we actually ready to ship this?" and nobody has a clear answer. Config files are scattered across Dockerfiles, CI workflows, and Kubernetes manifests. Problems hide in the gaps between them.
In this project, you will build a deployment readiness checker that runs deterministic rules across your deployment artifacts, then uses Claude's structured outputs to transform raw pass/fail findings into a prioritized readiness report with scores, risk explanations, and an actionable launch checklist.
What You'll Build
You paste deployment configs into a multi-artifact editor, hit analyze, and get back a clear verdict: an overall readiness score, the exact gaps that would stop you from launching safely, and a checklist telling you what to fix first.
By the end of this project, you'll have:
- A deterministic checker engine that catches concrete problems (running as root, unpinned versions, missing health checks) across 7 production-readiness categories with zero ambiguity.
- A structured readiness report powered by Claude's structured outputs that scores your deployment, explains WHY each gap matters in production, and divides next steps into blockers, before-launch tasks, and post-launch improvements.
- A graceful degradation pattern where the deterministic report remains fully available even when the AI layer is unavailable, proving that AI augments rather than replaces your validation.
- Secret Mission: Cross-artifact correlation checks that detect inconsistencies between your deployment files, like a Dockerfile exposing one port while your Kubernetes manifest expects another.
Are there any prerequisites?
You need Node.js 22.x installed, an Anthropic API key, and VS Code. Familiarity with TypeScript and basic deployment concepts (containers, CI/CD) will help you get the most out of this project.
Before We Start
Before we start writing code, let's get clear on what you're building and why it matters. This is your moment to commit to the project goal before any hands-on work begins.
Scaffold the Project and Install Dependencies
Before you can build any analysis logic, you need a working project with the right tools wired up.
In this step, you will scaffold a Next.js project with TypeScript and the App Router, then install the Anthropic SDK and Zod for structured output validation.
In this step, get ready to:
- Scaffold a Next.js project with TypeScript, Tailwind CSS, and the App Router.
- Install the Anthropic SDK and Zod as project dependencies.
- Configure your API key and verify the dev server starts cleanly.
Create the Next.js project
The project needs a home on your Desktop so you can find it easily later.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your Next.js project by running:
npx create-next-app@latest deployment-checker
What will this command ask?
The CLI will first ask you to confirm the project name. Then it asks whether you want to use the recommended Next.js defaults.
The recommended defaults include TypeScript, ESLint, Tailwind CSS, App Router, and AGENTS.md. These are exactly what you need for this project.
- When prompted with the project name deployment-checker, press Enter to confirm.
- When asked about recommended defaults, select Yes, use recommended defaults and press Enter.
The CLI creates your project folder, installs dependencies, and initializes a Git repository. This takes about 30 seconds.
- Move into your new project folder by running:
cd deployment-checker
- Confirm the project folder exists by running:
ls
You should see files like package.json, app/, tsconfig.json, and tailwind.config.ts in the output.
Command not found or network error?
If you see a permission error, try running with sudo on macOS/Linux or running your terminal as Administrator on Windows.
If the download stalls, check your internet connection and try again.
Why can't I scaffold my Next.js project?
Install the Anthropic SDK and Zod
Your project needs two additional packages. The Anthropic SDK communicates with Claude, and Zod defines typed schemas for structured outputs.
- Install both packages by running:
npm install @anthropic-ai/sdk zod
- Confirm the packages are installed by running:
cat package.json | grep -A 2 anthropic
You should see @anthropic-ai/sdk listed in your dependencies.
Install failed?
Make sure you are inside the deployment-checker folder (not one level above). Run pwd to check your current directory.
Help me fix my npm install error
Configure the API key and start the dev server
Next.js loads environment variables from a .env.local file automatically. This is where your Anthropic API key lives.
- Open the project in VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the deployment-checker folder, and click Open.
- Create a new file by clicking File then New File.
- Name the file .env.local and save it in the project root (the deployment-checker folder).
- Paste the following into .env.local:
ANTHROPIC_API_KEY=your-api-key-here
- Replace your-api-key-here with your actual Anthropic API key (the one starting with sk-ant-).
- Save the file with Cmd+S (macOS) or Ctrl+S (Windows).
Why .env.local?
Next.js automatically reads .env.local and makes its values available as server-side environment variables. It is also excluded from Git by default, so your key stays out of version control.
Now verify everything works by starting the development server.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on both platforms).
- Before you run the next command, what do you expect to see if the project is set up correctly?
- Start the dev server by running:
npm run dev
You should see output indicating the server is ready, with a URL pointing to localhost:3000.
- Open your browser and go to http://localhost:3000.
You should see the Next.js starter page with the Next.js logo and links to documentation.
Don't see the starter page?
Make sure your terminal is inside the deployment-checker folder. If port 3000 is already in use, Next.js will suggest an alternative port in the terminal output.
If you see an error about missing modules, run npm install again and then restart with npm run dev.
Help me get my Next.js dev server running
✔️ Awesome, I've got everything!
Great. Make sure you have saved .env.local and that the dev server is still running in your terminal.
ⓧ I'd like to double check the full code
Your .env.local file should look exactly like this (with your own key substituted):
ANTHROPIC_API_KEY=your-api-key-here
Your development environment is ready. Next up, you will build the multi-artifact input UI where users paste their deployment configs for analysis.
Build the Multi-Artifact Input UI
Your dev server is running, but the default Next.js page tells users nothing about deployment readiness. To build a useful checker, you need a flexible input surface where users can add multiple deployment artifacts of different types.
In this step, you will build a multi-artifact editor that lets users dynamically add and remove deployment configs, then POST them to an API route for analysis. This gives the checker its input surface without locking it to a single file type.
In this step, get ready to:
- Create a client component with dynamic add/remove artifact inputs.
- Wire up a submit button that POSTs to an API route with loading and error states.
- Create a Route Handler stub that accepts artifacts and returns a placeholder response.
Create the artifact editor component
The artifact editor is where users add deployment files for analysis. Each artifact needs a type selector (so the checker knows what rules apply) and a textarea for the file content.
- Create a new folder app/components in your project.
- Create a new file app/components/ArtifactEditor.tsx and add the type definitions and labels:
"use client";
export type ArtifactType = "dockerfile" | "github-actions" | "kubernetes" | "terraform" | "env-config";
export interface Artifact {
id: string;
type: ArtifactType;
name: string;
content: string;
}
const ARTIFACT_LABELS: Record<ArtifactType, string> = {
dockerfile: "Dockerfile",
"github-actions": "GitHub Actions Workflow",
kubernetes: "Kubernetes Manifest",
terraform: "Terraform Config",
"env-config": "Environment / Config",
};
interface Props {
artifacts: Artifact[];
onChange: (artifacts: Artifact[]) => void;
}
What does this code do?
ArtifactType defines the five deployment file types the checker supports. Each type maps to different validation rules later.
ARTIFACT_LABELS provides human-readable names for the type selector dropdown. The Props interface defines the contract between this editor and its parent component.
- Below the Props interface in the same file, add the component function with its add/remove/update logic and the rendered UI:
export function ArtifactEditor({ artifacts, onChange }: Props) {
function addArtifact() {
onChange([...artifacts, { id: crypto.randomUUID(), type: "dockerfile", name: "", content: "" }]);
}
function removeArtifact(id: string) {
onChange(artifacts.filter((a) => a.id !== id));
}
function updateArtifact(id: string, updates: Partial<Artifact>) {
onChange(artifacts.map((a) => (a.id === id ? { ...a, ...updates } : a)));
}
return (
<div className="space-y-4">
{artifacts.map((artifact) => (
<div key={artifact.id} className="border border-gray-700 rounded-lg p-4 bg-gray-900">
<div className="flex items-center gap-3 mb-3">
<select value={artifact.type} onChange={(e) => updateArtifact(artifact.id, { type: e.target.value as ArtifactType })} className="bg-gray-800 border border-gray-600 rounded px-3 py-1.5 text-sm">
{Object.entries(ARTIFACT_LABELS).map(([value, label]) => (<option key={value} value={value}>{label}</option>))}
</select>
<input type="text" placeholder="Filename (optional)" value={artifact.name} onChange={(e) => updateArtifact(artifact.id, { name: e.target.value })} className="bg-gray-800 border border-gray-600 rounded px-3 py-1.5 text-sm flex-1" />
<button onClick={() => removeArtifact(artifact.id)} className="text-red-400 hover:text-red-300 text-sm px-2">Remove</button>
</div>
<textarea value={artifact.content} onChange={(e) => updateArtifact(artifact.id, { content: e.target.value })} placeholder={`Paste your ${ARTIFACT_LABELS[artifact.type]} content here...`} className="w-full h-48 bg-gray-950 border border-gray-700 rounded p-3 font-mono text-sm resize-y" />
</div>
))}
<button onClick={addArtifact} className="px-4 py-2 border border-dashed border-gray-600 rounded-lg text-gray-400 hover:text-gray-200 hover:border-gray-400 transition-colors w-full">+ Add Artifact</button>
</div>
);
}
What does this code do?
The three helper functions manage the artifacts array immutably. addArtifact appends a blank entry, removeArtifact filters by ID, and updateArtifact spreads partial updates over the matching item.
The rendered UI maps each artifact to a card with a type selector dropdown, an optional filename input, a remove button, and a monospace textarea for pasting file contents.
- Save app/components/ArtifactEditor.tsx.
- Confirm the file appears in your VS Code Explorer sidebar under app/components/.
File not showing in the sidebar?
Make sure you created the components folder inside the app directory, not at the project root. The path should be app/components/ArtifactEditor.tsx.
help me find where to create this file in my project
Wire up the analyzer app with submit
Now you need a parent component that holds the artifacts state, renders the editor, and sends everything to the backend when the user clicks submit.
- Create a new file app/components/AnalyzerApp.tsx and add the imports and default sample artifacts:
"use client";
import { useState } from "react";
import { ArtifactEditor, type Artifact } from "./ArtifactEditor";
// Sample artifacts with intentional deployment problems for testing
const DEFAULT_ARTIFACTS: Artifact[] = [
{ id: crypto.randomUUID(), type: "dockerfile", name: "Dockerfile", content: "FROM node:20\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nENV DATABASE_URL=postgres://admin:secret@db:5432/myapp\nEXPOSE 8080\nCMD [\"node\", \"server.js\"]" },
{ id: crypto.randomUUID(), type: "github-actions", name: ".github/workflows/deploy.yml", content: "name: Deploy\non:\n push:\n branches: [main]\njobs:\n deploy:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@main\n - uses: aws-actions/configure-aws-credentials@main\n with:\n aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}\n aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n aws-region: us-east-1\n - run: npm ci\n - run: npm run build\n - run: aws ecs update-service --cluster prod --service api --force-new-deployment" },
];
Why pre-populate with broken examples?
These default artifacts contain intentional deployment problems. The Dockerfile runs as root, leaks a secret in ENV, and has no HEALTHCHECK. The GitHub Actions workflow uses unpinned actions (@main) and static AWS credentials.
Pre-populating lets you immediately test the checker without pasting anything manually.
- Below the DEFAULT_ARTIFACTS constant in the same file, add the component function with state management, the submit handler, and the rendered UI:
export function AnalyzerApp() {
const [artifacts, setArtifacts] = useState<Artifact[]>(DEFAULT_ARTIFACTS);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleAnalyze() {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ artifacts: artifacts.filter((a) => a.content.trim().length > 0) }) });
if (!res.ok) throw new Error(`Analysis failed: ${res.statusText}`);
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error");
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<ArtifactEditor artifacts={artifacts} onChange={setArtifacts} />
<button onClick={handleAnalyze} disabled={loading || artifacts.every((a) => !a.content.trim())} className="px-6 py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 rounded-lg font-medium transition-colors">{loading ? "Analyzing..." : "Run Readiness Check"}</button>
{error && <div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300">{error}</div>}
</div>
);
}
What does this code do?
handleAnalyze filters out empty artifacts, POSTs the rest to /api/analyze, and manages loading/error state. The button disables itself while loading or when all textareas are empty.
The ArtifactEditor receives the artifacts state and a setter, so adding or removing artifacts re-renders everything through React's normal flow.
- Save app/components/AnalyzerApp.tsx.
Now connect this component to the page so it actually renders in the browser.
- Open app/page.tsx and replace its entire contents with:
import { AnalyzerApp } from "./components/AnalyzerApp";
export default function Home() {
return (
<main className="min-h-screen bg-gray-950 text-gray-100 p-8">
<div className="max-w-5xl mx-auto">
<h1 className="text-3xl font-bold mb-2">Deployment Readiness Checker</h1>
<p className="text-gray-400 mb-8">
Paste your deployment artifacts below. Deterministic checks run first,
then Claude analyzes findings to produce a prioritized readiness report.
</p>
<AnalyzerApp />
</div>
</main>
);
}
- Save app/page.tsx.
- Switch to your browser and refresh localhost:3000.
You should see a dark page titled "Deployment Readiness Checker" with two pre-populated artifact cards (a Dockerfile and a GitHub Actions workflow), each with a type dropdown, filename input, and textarea. A blue "Run Readiness Check" button sits below the cards, plus a dashed "+ Add Artifact" button at the bottom.
- Click the "+ Add Artifact" button to confirm a new empty card appears.
- Click "Remove" on one of the cards to confirm it disappears.
Page showing an error instead of the UI?
Check your terminal for the compilation error message. A common cause is a missing "use client" directive at the top of AnalyzerApp.tsx or ArtifactEditor.tsx. Both files use useState or browser APIs, so they must be client components.
Also verify that the import path in AnalyzerApp.tsx matches the filename exactly: ./ArtifactEditor (no extension, capital A and E).
help me debug my Next.js compilation error with these components
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Your app/components/ArtifactEditor.tsx should have two sections in order:
- The type definitions, Artifact interface, ARTIFACT_LABELS constant, and Props interface (from chunk 1).
- The ArtifactEditor function with addArtifact, removeArtifact, updateArtifact, and the JSX return (from chunk 2).
Your app/components/AnalyzerApp.tsx should have:
- The "use client" directive, imports, and DEFAULT_ARTIFACTS constant (from chunk 1).
- The AnalyzerApp function with state, handleAnalyze, and the JSX return (from chunk 2).
Your app/page.tsx should be:
import { AnalyzerApp } from "./components/AnalyzerApp";
export default function Home() {
return (
<main className="min-h-screen bg-gray-950 text-gray-100 p-8">
<div className="max-w-5xl mx-auto">
<h1 className="text-3xl font-bold mb-2">Deployment Readiness Checker</h1>
<p className="text-gray-400 mb-8">
Paste your deployment artifacts below. Deterministic checks run first,
then Claude analyzes findings to produce a prioritized readiness report.
</p>
<AnalyzerApp />
</div>
</main>
);
}
Create the Route Handler stub
The submit button POSTs to /api/analyze, but that route does not exist yet. You will create a stub that accepts the request and echoes back the artifacts so you can confirm the full round trip works.
- Create the folder app/api/analyze/ and a new file app/api/analyze/route.ts with this stub handler:
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ message: "stub", artifacts: body.artifacts });
}
What does this code do?
This is a Route Handler in the Next.js App Router. The file path app/api/analyze/route.ts maps directly to the /api/analyze URL. The exported POST function handles POST requests.
For now it just echoes back the artifacts. You will replace this stub with the real deterministic checker in the next step.
- Save app/api/analyze/route.ts.
Before you click the button, what do you expect to happen? The submit should succeed (no error banner), but you will not see any visible result yet because the stub just returns JSON silently.
- Switch back to your browser at localhost:3000.
- Open your browser DevTools (Cmd+Option+I on macOS or Ctrl+Shift+I on Windows) and switch to the Network tab.
- Click the Run Readiness Check button.
In the Network tab, you should see a POST request to /api/analyze with a 200 status. Click the request and inspect the response. You will see the stub JSON with "message": "stub" and your artifacts echoed back.
Seeing a 404 or the button stays on "Analyzing..."?
A 404 means the route file is in the wrong place. The path must be exactly app/api/analyze/route.ts (not app/api/analyze.ts or pages/api/analyze.ts).
If the button says "Analyzing..." forever with no network request, check that handleAnalyze is wired to the button's onClick prop in AnalyzerApp.tsx.
help me debug why my /api/analyze route returns 404
Your input surface is complete. Users can add any combination of deployment files, and the submit button successfully reaches your backend. Next up, you will replace the stub with a real deterministic checker engine that scans those artifacts for production-readiness problems.
Build the Deterministic Checker Engine
Your UI can submit artifacts, but the Route Handler just echoes them back. The core question remains unanswered: given these deployment files, what concrete problems exist?
In this step you will build a deterministic checker engine that scans artifacts for verifiable issues. These checks are the source of truth for the entire app. They catch concrete problems like running as root, unpinned image versions, and missing health checks with zero ambiguity.
In this step, get ready to:
- Define Zod schemas for check findings, categories, and the readiness report structure.
- Implement 15+ check rules across 7 production-readiness categories.
- Wire the checker into the Route Handler and return real findings.
Define the type schemas
Every check produces a structured finding with a rule ID, category, severity, pass/fail status, evidence string, and an optional line number. You need Zod schemas to validate both the inputs from the frontend and the shape of your checker output.
- Create a new file at lib/schemas.ts inside your project root.
- Add the artifact and check finding schemas by pasting this code:
import { z } from "zod";
export const ArtifactTypeSchema = z.enum(["dockerfile","github-actions","kubernetes","terraform","env-config"]);
export type ArtifactType = z.infer<typeof ArtifactTypeSchema>;
export const ArtifactSchema = z.object({ id: z.string(), type: ArtifactTypeSchema, name: z.string(), content: z.string() });
export type ArtifactInput = z.infer<typeof ArtifactSchema>;
export const CategorySchema = z.enum(["security","reliability","observability","secrets","containerization","cicd","health"]);
export type Category = z.infer<typeof CategorySchema>;
export const CheckStatusSchema = z.enum(["PASS","FAIL","NOT_EVALUATED"]);
export const CheckFindingSchema = z.object({
ruleId: z.string(),
category: CategorySchema,
severity: z.enum(["critical","high","medium","low"]),
status: CheckStatusSchema,
artifactName: z.string(),
artifactType: ArtifactTypeSchema,
evidence: z.string(),
line: z.number().nullable(),
});
export type CheckFinding = z.infer<typeof CheckFindingSchema>;
What does this code do?
This defines the vocabulary for your entire checker system.
- ArtifactTypeSchema constrains artifact types to the 5 supported kinds.
- CategorySchema defines the 7 production-readiness categories each rule belongs to.
- CheckFindingSchema is the shape of every individual result. The three-way PASS | FAIL | NOT_EVALUATED status lets you distinguish "checked and passed" from "not applicable".
- Below the CheckFinding type export, add the readiness report schema and response interface:
export const ReadinessReportSchema = z.object({
overallScore: z.number().describe("Overall readiness score from 0 to 100"),
readinessLevel: z.enum(["not_ready","needs_work","almost_ready","ready"]),
categoryScores: z.object({ security: z.number(), reliability: z.number(), observability: z.number(), secrets: z.number(), containerization: z.number(), cicd: z.number(), health: z.number() }),
prioritizedRisks: z.array(z.object({ severity: z.enum(["critical","high","medium","low"]), category: CategorySchema, title: z.string(), productionImpact: z.string(), recommendedFix: z.string() })),
launchChecklist: z.object({ blockers: z.array(z.string()), beforeLaunch: z.array(z.string()), postLaunch: z.array(z.string()) }),
});
export type ReadinessReport = z.infer<typeof ReadinessReportSchema>;
export interface AnalysisResponse {
findings: CheckFinding[];
aiReport: ReadinessReport | null;
aiAvailable: boolean;
}
What does the ReadinessReportSchema define?
This is the exact shape Claude will produce via structured outputs in a later step. It includes an overall score, per-category scores, prioritized risks with production impact explanations, and a launch checklist split into blockers, before-launch, and post-launch items.
The AnalysisResponse interface combines deterministic findings with the optional AI report. The aiAvailable flag enables graceful degradation when Claude is unavailable.
- Save lib/schemas.ts.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm both chunks are present in order: the first chunk (import through CheckFinding) followed by the second chunk (ReadinessReportSchema through AnalysisResponse).
Implement the check rules
Each rule targets a specific artifact type, runs a regex or string check against the content, and returns a PASS or FAIL with evidence explaining what it found.
- Create a new file at lib/checker.ts.
- Add the imports, rule interface, and line-finder helper by pasting this code:
import type { ArtifactInput, CheckFinding, Category } from "./schemas";
interface CheckRule {
ruleId: string;
category: Category;
severity: "critical" | "high" | "medium" | "low";
appliesTo: ArtifactInput["type"][];
check: (content: string, artifact: ArtifactInput) => { status: "PASS" | "FAIL"; evidence: string; line: number | null; };
}
function findLine(content: string, pattern: RegExp | string): number | null {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
if (typeof pattern === "string") { if (lines[i].includes(pattern)) return i + 1; }
else { if (pattern.test(lines[i])) return i + 1; }
}
return null;
}
What does this set up?
- CheckRule defines the contract for every rule: a unique ID, which categories and artifact types it applies to, and a check function that returns a status with evidence.
- findLine scans the artifact content line-by-line so findings can point to the exact line number of a problem.
- Below the findLine function, add the first batch of rules covering containerization, health, and security:
const RULES: CheckRule[] = [
// Containerization: non-root user
{ ruleId: "DOCKER-001", category: "containerization", severity: "high", appliesTo: ["dockerfile"], check: (content) => { const hasUser = /^USER\s+(?!root)/m.test(content); return { status: hasUser ? "PASS" : "FAIL", evidence: hasUser ? "Non-root USER directive found" : "No non-root USER directive. Container runs as root by default.", line: hasUser ? findLine(content, /^USER\s/) : null }; } },
// Containerization: pinned base image
{ ruleId: "DOCKER-002", category: "containerization", severity: "medium", appliesTo: ["dockerfile"], check: (content) => { const fromLines = content.match(/^FROM\s+.+/gm) || []; const unpinned = fromLines.filter((line) => !/@sha256:/.test(line) && !/:[\.\w][\w.-]+/.test(line)); if (unpinned.length > 0) return { status: "FAIL", evidence: `Unpinned base image: ${unpinned[0].trim()}`, line: findLine(content, unpinned[0]) }; const usesLatest = fromLines.filter((line) => /:latest/.test(line)); if (usesLatest.length > 0) return { status: "FAIL", evidence: `Base image uses :latest tag: ${usesLatest[0].trim()}`, line: findLine(content, usesLatest[0]) }; return { status: "PASS", evidence: "Base images are version-pinned", line: null }; } },
// Containerization: multi-stage build
{ ruleId: "DOCKER-003", category: "containerization", severity: "low", appliesTo: ["dockerfile"], check: (content) => { const hasMultiStage = (content.match(/^FROM\s/gm) || []).length > 1; return { status: hasMultiStage ? "PASS" : "FAIL", evidence: hasMultiStage ? "Multi-stage build detected" : "Single-stage build. Consider multi-stage for smaller images.", line: null }; } },
// Health: Dockerfile HEALTHCHECK
{ ruleId: "HEALTH-001", category: "health", severity: "high", appliesTo: ["dockerfile"], check: (content) => { const hasHealthcheck = /^HEALTHCHECK\s/m.test(content); return { status: hasHealthcheck ? "PASS" : "FAIL", evidence: hasHealthcheck ? "HEALTHCHECK instruction present" : "No HEALTHCHECK instruction.", line: hasHealthcheck ? findLine(content, /^HEALTHCHECK/) : null }; } },
// Health: K8s probes
{ ruleId: "HEALTH-002", category: "health", severity: "high", appliesTo: ["kubernetes"], check: (content) => { const hasLiveness = /livenessProbe/m.test(content); const hasReadiness = /readinessProbe/m.test(content); if (hasLiveness && hasReadiness) return { status: "PASS", evidence: "Both liveness and readiness probes defined", line: null }; const missing = []; if (!hasLiveness) missing.push("livenessProbe"); if (!hasReadiness) missing.push("readinessProbe"); return { status: "FAIL", evidence: `Missing: ${missing.join(", ")}.`, line: null }; } },
// Security: secrets in ENV
{ ruleId: "SEC-001", category: "security", severity: "critical", appliesTo: ["dockerfile"], check: (content) => { const envSecrets = content.match(/^ENV\s+\w*(SECRET|PASSWORD|KEY|TOKEN)\w*\s*=\s*.+/gim); if (envSecrets && envSecrets.length > 0) return { status: "FAIL", evidence: `Secrets in ENV: ${envSecrets[0].trim()}`, line: findLine(content, envSecrets[0]) }; return { status: "PASS", evidence: "No secrets detected in ENV directives", line: null }; } },
// Security: K8s runAsNonRoot
{ ruleId: "SEC-002", category: "security", severity: "high", appliesTo: ["kubernetes"], check: (content) => { const hasSecCtx = /runAsNonRoot:\s*true/m.test(content); return { status: hasSecCtx ? "PASS" : "FAIL", evidence: hasSecCtx ? "runAsNonRoot security context set" : "No runAsNonRoot: true in securityContext.", line: hasSecCtx ? findLine(content, "runAsNonRoot") : null }; } },
What do these rules check?
- DOCKER-001 through DOCKER-003 check containerization best practices: non-root user, pinned base image versions, and multi-stage builds.
- HEALTH-001 and HEALTH-002 verify health checks exist in Dockerfiles (HEALTHCHECK instruction) and Kubernetes manifests (liveness/readiness probes).
- SEC-001 catches secrets hardcoded in Dockerfile ENV directives. SEC-002 checks Kubernetes pods for non-root security context.
- Directly below the last rule (no blank line), add the remaining rules for secrets, CI/CD, reliability, and observability. Close the array with ]; at the end:
// Secrets: hardcoded credentials in Actions
{ ruleId: "SECRETS-001", category: "secrets", severity: "critical", appliesTo: ["github-actions"], check: (content) => { const hardcoded = content.match(/aws[_-]?(access[_-]?key|secret)[^:]*:\s*(?!.*\$\{\{).+/gi); if (hardcoded && hardcoded.length > 0) return { status: "FAIL", evidence: `Potentially hardcoded credential: ${hardcoded[0].trim()}`, line: findLine(content, hardcoded[0]) }; return { status: "PASS", evidence: "No hardcoded credentials detected", line: null }; } },
// Secrets: real values in env config
{ ruleId: "SECRETS-002", category: "secrets", severity: "high", appliesTo: ["env-config"], check: (content) => { const realValues = content.match(/^(?!#)\w*(SECRET|PASSWORD|KEY|TOKEN)\w*\s*=\s*(?!<|{|\$|your-|changeme|xxx|placeholder).{8,}/gim); if (realValues && realValues.length > 0) return { status: "FAIL", evidence: `Possible real secret value: ${realValues[0].split("=")[0].trim()}`, line: findLine(content, realValues[0]) }; return { status: "PASS", evidence: "Secret values appear to use placeholders", line: null }; } },
// CI/CD: unpinned actions
{ ruleId: "CICD-001", category: "cicd", severity: "medium", appliesTo: ["github-actions"], check: (content) => { const usesLines = content.match(/uses:\s*.+/g) || []; const unpinned = usesLines.filter((line) => /@main/.test(line) || /@master/.test(line) || !/@/.test(line)); if (unpinned.length > 0) return { status: "FAIL", evidence: `Unpinned action: ${unpinned[0].trim()}.`, line: findLine(content, unpinned[0].trim()) }; return { status: "PASS", evidence: "All actions are version-pinned", line: null }; } },
// CI/CD: static credentials vs OIDC
{ ruleId: "CICD-002", category: "cicd", severity: "high", appliesTo: ["github-actions"], check: (content) => { const usesOidc = /role-to-assume/i.test(content) || /oidc/i.test(content); const usesStaticKeys = /aws-access-key-id/i.test(content) && !usesOidc; if (usesStaticKeys) return { status: "FAIL", evidence: "Uses static AWS credentials instead of OIDC.", line: findLine(content, /aws-access-key-id/i) }; return { status: "PASS", evidence: "Uses OIDC or no static credentials detected", line: null }; } },
// Reliability: resource limits
{ ruleId: "REL-001", category: "reliability", severity: "high", appliesTo: ["kubernetes"], check: (content) => { const hasLimits = /resources:[\s\S]*?limits:/m.test(content); return { status: hasLimits ? "PASS" : "FAIL", evidence: hasLimits ? "Resource limits defined" : "No resource limits.", line: null }; } },
// Reliability: replicas
{ ruleId: "REL-002", category: "reliability", severity: "medium", appliesTo: ["kubernetes"], check: (content) => { const hasReplicas = /replicas:\s*[2-9]/.test(content) || /replicas:\s*\d{2,}/.test(content); return { status: hasReplicas ? "PASS" : "FAIL", evidence: hasReplicas ? "Multiple replicas configured" : "Single replica or replicas not specified.", line: findLine(content, /replicas:/) }; } },
// Observability: logging
{ ruleId: "OBS-001", category: "observability", severity: "medium", appliesTo: ["kubernetes"], check: (content) => { const hasLogging = /fluentd|fluentbit|datadog|cloudwatch|loki/i.test(content) || /logging|log-driver/i.test(content); return { status: hasLogging ? "PASS" : "FAIL", evidence: hasLogging ? "Logging configuration detected" : "No logging sidecar or configuration detected.", line: null }; } },
// Observability: monitoring
{ ruleId: "OBS-002", category: "observability", severity: "medium", appliesTo: ["kubernetes","terraform"], check: (content) => { const hasMonitoring = /prometheus|grafana|datadog|cloudwatch|newrelic|metrics/i.test(content); return { status: hasMonitoring ? "PASS" : "FAIL", evidence: hasMonitoring ? "Monitoring/metrics configuration detected" : "No monitoring or metrics references found.", line: null }; } },
];
What do these rules check?
- SECRETS-001 detects AWS credentials used outside ${{ secrets.* }} in GitHub Actions. SECRETS-002 flags real-looking values in environment config files.
- CICD-001 catches unpinned actions (using @main instead of a SHA or version tag). CICD-002 flags static AWS keys when OIDC is available.
- REL-001 and REL-002 check Kubernetes for resource limits and replica redundancy.
- OBS-001 and OBS-002 look for logging and monitoring references in infrastructure manifests.
- Below the closing ]; of the RULES array, add the runChecks function that executes applicable rules for each artifact:
export function runChecks(artifacts: ArtifactInput[]): CheckFinding[] {
const findings: CheckFinding[] = [];
const evaluatedCategories = new Set<string>();
for (const artifact of artifacts) {
const applicableRules = RULES.filter((r) => r.appliesTo.includes(artifact.type));
for (const rule of applicableRules) {
evaluatedCategories.add(rule.category);
const result = rule.check(artifact.content, artifact);
findings.push({ ruleId: rule.ruleId, category: rule.category, severity: rule.severity, status: result.status, artifactName: artifact.name || `Unnamed ${artifact.type}`, artifactType: artifact.type, evidence: result.evidence, line: result.line });
}
}
const allCategories: Category[] = ["security","reliability","observability","secrets","containerization","cicd","health"];
for (const cat of allCategories) {
if (!evaluatedCategories.has(cat)) {
findings.push({ ruleId: `${cat.toUpperCase()}-NA`, category: cat, severity: "low", status: "NOT_EVALUATED", artifactName: "N/A", artifactType: "dockerfile", evidence: `No applicable artifacts provided for ${cat} checks.`, line: null });
}
}
return findings;
}
type Category = "security" | "reliability" | "observability" | "secrets" | "containerization" | "cicd" | "health";
What does runChecks do?
- For each submitted artifact, it filters the RULES array to only rules that apply to that artifact type, then runs each matching rule.
- It tracks which categories were evaluated. Any category with no applicable artifacts gets a NOT_EVALUATED entry so the system knows those categories were not penalized, just absent.
- Save lib/checker.ts.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before continuing.
ⓧ I'd like to double check the full code
Scroll up and confirm these three sections are present in order:
- The imports, CheckRule interface, and findLine helper.
- The full RULES array with 15 rules (DOCKER-001 through OBS-002).
- The runChecks function and the local Category type alias at the bottom.
Wire the checker into the Route Handler
The checker engine is ready but the Route Handler still returns a stub response. You need to import runChecks, validate the incoming request with Zod, and return the real findings array.
- Open app/api/analyze/route.ts and replace its entire contents with this code:
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { ArtifactSchema } from "@/lib/schemas";
import { runChecks } from "@/lib/checker";
// Validate incoming request shape
const RequestSchema = z.object({ artifacts: z.array(ArtifactSchema).min(1).max(10) });
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { artifacts } = RequestSchema.parse(body);
// Run deterministic checks (source of truth)
const findings = runChecks(artifacts);
return NextResponse.json({ findings, aiReport: null, aiAvailable: false });
} catch (error) {
if (error instanceof z.ZodError) return NextResponse.json({ error: "Invalid request", details: error.errors }, { status: 400 });
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
What changed in the Route Handler?
- RequestSchema validates the incoming body with Zod before any processing happens. Invalid requests get a 400 response.
- runChecks(artifacts) runs every applicable rule and returns the full findings array.
- The response already includes aiReport: null and aiAvailable: false placeholders. The next step will fill these in with Claude's structured output.
- Save app/api/analyze/route.ts.
Before you test, what do you think the response will contain for the default Dockerfile? It has no USER directive, an unpinned base image, and a secret in an ENV line.
- Test the checker by running this curl command in a new terminal:
curl -s -X POST http://localhost:3000/api/analyze \
-H "Content-Type: application/json" \
-d '{"artifacts":[{"id":"1","type":"dockerfile","name":"Dockerfile","content":"FROM node:20\nWORKDIR /app\nCOPY . .\nENV DATABASE_URL=postgres://admin:secret@db:5432/myapp\nCMD [\"node\",\"server.js\"]"}]}' | python3 -m json.tool
You should see a JSON response with a findings array containing multiple entries. Look for DOCKER-001 with status FAIL (no non-root USER), DOCKER-002 with FAIL (unpinned base image FROM node:20), and SEC-001 with FAIL (secret in ENV). Categories like reliability and observability should show NOT_EVALUATED since no Kubernetes manifest was provided.
Seeing a 500 error or module not found?
- Check that lib/schemas.ts and lib/checker.ts are saved in the lib/ folder at the project root (not inside app/).
- Confirm the imports in route.ts use @/lib/schemas and @/lib/checker. The @/ alias points to the project root in Next.js.
- If your dev server shows type errors, stop it with Ctrl+C and restart with npm run dev.
Still stuck? Help me debug a module not found error in my Next.js Route Handler.
Your deterministic engine is live. It catches real, verifiable problems with zero ambiguity. In the next step, you will build the frontend to display these findings grouped by category, making the raw data easy to scan.
Display Raw Check Results
Your checker engine produces real findings. Every artifact gets evaluated against concrete rules, and the response includes PASS, FAIL, or NOT_EVALUATED for each one.
In this step, you will render those findings in the UI so you can see them grouped by category. The results are accurate, but pay attention to what is missing: there is no overall score, no priority ordering, and no explanation of why a failure matters in production.
In this step, get ready to:
- Build a ReportDisplay component that renders findings grouped by category with status badges.
- Wire AnalyzerApp to store and display the analysis response.
Create the results display component
Each finding from the checker has a status, category, rule ID, evidence string, and optional line number. The display component groups these by category and color-codes each status.
- Create a new file at app/components/ReportDisplay.tsx and add the file setup, constants, and FindingsTable helper:
"use client";
import type { AnalysisResponse, CheckFinding } from "@/lib/schemas";
// Color classes for each check status
const STATUS_COLORS: Record<string, string> = {
PASS: "text-green-400 bg-green-900/30",
FAIL: "text-red-400 bg-red-900/30",
NOT_EVALUATED: "text-gray-400 bg-gray-800",
};
// Human-readable labels for the 7 check categories
const CATEGORY_LABELS: Record<string, string> = {
security: "Security", reliability: "Reliability", observability: "Observability",
secrets: "Configuration / Secrets", containerization: "Containerization",
cicd: "CI/CD", health: "Health / Resilience",
};
// Renders a list of findings as status-badged rows
function FindingsTable({ findings }: { findings: CheckFinding[] }) {
return (
<div className="space-y-2">
{findings.map((f, i) => (
<div key={i} className="flex items-start gap-3 p-3 bg-gray-900 rounded border border-gray-800">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[f.status]}`}>{f.status}</span>
<div className="flex-1 min-w-0"><div className="text-sm font-medium">{f.ruleId}</div><div className="text-sm text-gray-400">{f.evidence}</div>{f.line && <div className="text-xs text-gray-500 mt-1">Line {f.line} in {f.artifactName}</div>}</div>
<span className="text-xs text-gray-500 uppercase">{f.category}</span>
</div>
))}
</div>
);
}
- Below the FindingsTable function, add the main ReportDisplay component that groups findings by category:
// Main component that groups findings and renders each category
export function ReportDisplay({ result }: { result: AnalysisResponse }) {
const { findings } = result;
const grouped = findings.reduce<Record<string, CheckFinding[]>>((acc, f) => { (acc[f.category] ||= []).push(f); return acc; }, {});
return (
<div className="space-y-8 mt-8">
<section>
<h3 className="text-lg font-medium mb-4">Deterministic Findings</h3>
{Object.entries(grouped).map(([category, catFindings]) => (
<div key={category} className="mb-6">
<h4 className="text-sm font-medium text-gray-400 uppercase mb-2">{CATEGORY_LABELS[category] || category}</h4>
<FindingsTable findings={catFindings} />
</div>
))}
</section>
</div>
);
}
What does this component do?
The reduce call buckets findings into a dictionary keyed by category (security, cicd, health, etc.).
Each category section renders its own FindingsTable, where every finding row shows a color-coded status badge, the rule ID, the evidence string explaining what was found, and the affected line number when available.
- Save app/components/ReportDisplay.tsx.
Wire AnalyzerApp to display results
The component exists, but AnalyzerApp does not import it or store the API response yet. You need to connect the two.
- In app/components/AnalyzerApp.tsx, find the existing imports at the top of the file:
import { ArtifactEditor, type Artifact } from "./ArtifactEditor";
- Add two new import lines directly below it, so the imports section looks like this:
import { ArtifactEditor, type Artifact } from "./ArtifactEditor";
import { ReportDisplay } from "./ReportDisplay";
import type { AnalysisResponse } from "@/lib/schemas";
- Inside the AnalyzerApp function, find this line:
const [loading, setLoading] = useState(false);
- Add a new state variable directly above it to store the analysis response:
const [result, setResult] = useState<AnalysisResponse | null>(null);
const [loading, setLoading] = useState(false);
- Inside handleAnalyze, find this line:
if (!res.ok) throw new Error(`Analysis failed: ${res.statusText}`);
- Add two lines directly below it to parse and store the response:
if (!res.ok) throw new Error(`Analysis failed: ${res.statusText}`);
const data: AnalysisResponse = await res.json();
setResult(data);
- Finally, find the error display div near the bottom of the return statement:
{error && <div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300">{error}</div>}
- Add the ReportDisplay render directly below it:
{error && <div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300">{error}</div>}
{result && <ReportDisplay result={result} />}
What changed in AnalyzerApp?
You added a result state variable typed as AnalysisResponse | null. When the fetch succeeds, setResult(data) stores the full response (findings array plus the AI fields).
The {result && <ReportDisplay result={result} />} line renders the findings only after a successful analysis. Until then, result is null and nothing appears.
- Save app/components/AnalyzerApp.tsx.
Seeing a type error in VS Code?
Make sure the import type { AnalysisResponse } from "@/lib/schemas" line is present. Without it, TypeScript cannot type the result state.
Also confirm the ReportDisplay import path matches the file you just created: ./ReportDisplay (no .tsx extension).
help me fix a type error in AnalyzerApp.tsx
Run the analysis and observe the raw results
The default artifacts are pre-populated with intentional problems: the Dockerfile runs as root, has an unpinned base image, and leaks a secret in an ENV directive. The GitHub Actions workflow uses unpinned actions and static AWS credentials.
- Switch back to your browser at localhost:3000.
- Before you click the button, predict: how many FAIL findings do you expect to see across the two default artifacts?
- Click Run Readiness Check.
You should see findings appear grouped under categories like Containerization, Security, CI/CD, and others. Multiple FAIL badges appear in red, each with an evidence string explaining the detected problem and the affected line number.
What is missing from this view?
The findings are correct, but look at what you cannot answer just by reading them: Is this application ready to launch? Which failures are blockers vs nice-to-haves? What would actually break in production?
You have 8+ individual FAIL findings across multiple categories, but no overall verdict, no priority ordering, and no explanation of production impact. That gap is exactly what Claude's structured output will fill in the next step.
Not seeing any findings?
Open the browser DevTools console and check for errors. A common cause is a typo in the fetch URL (/api/analyze) or a missing setResult(data) call in handleAnalyze.
If the button says "Analyzing..." but never finishes, check your terminal for server-side errors in the Route Handler.
help me debug why my findings are not appearing in the UI
✔️ Awesome, I've got everything!
Great. Double check you have saved both ReportDisplay.tsx and AnalyzerApp.tsx.
ⓧ I'd like to double check the full code
Here is the full app/components/ReportDisplay.tsx:
"use client";
import type { AnalysisResponse, CheckFinding } from "@/lib/schemas";
const STATUS_COLORS: Record<string, string> = {
PASS: "text-green-400 bg-green-900/30",
FAIL: "text-red-400 bg-red-900/30",
NOT_EVALUATED: "text-gray-400 bg-gray-800",
};
const CATEGORY_LABELS: Record<string, string> = {
security: "Security",
reliability: "Reliability",
observability: "Observability",
secrets: "Configuration / Secrets",
containerization: "Containerization",
cicd: "CI/CD",
health: "Health / Resilience",
};
function FindingsTable({ findings }: { findings: CheckFinding[] }) {
return (
<div className="space-y-2">
{findings.map((f, i) => (
<div key={i} className="flex items-start gap-3 p-3 bg-gray-900 rounded border border-gray-800">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[f.status]}`}>{f.status}</span>
<div className="flex-1 min-w-0"><div className="text-sm font-medium">{f.ruleId}</div><div className="text-sm text-gray-400">{f.evidence}</div>{f.line && <div className="text-xs text-gray-500 mt-1">Line {f.line} in {f.artifactName}</div>}</div>
<span className="text-xs text-gray-500 uppercase">{f.category}</span>
</div>
))}
</div>
);
}
export function ReportDisplay({ result }: { result: AnalysisResponse }) {
const { findings } = result;
const grouped = findings.reduce<Record<string, CheckFinding[]>>((acc, f) => { (acc[f.category] ||= []).push(f); return acc; }, {});
return (
<div className="space-y-8 mt-8">
<section>
<h3 className="text-lg font-medium mb-4">Deterministic Findings</h3>
{Object.entries(grouped).map(([category, catFindings]) => (
<div key={category} className="mb-6">
<h4 className="text-sm font-medium text-gray-400 uppercase mb-2">{CATEGORY_LABELS[category] || category}</h4>
<FindingsTable findings={catFindings} />
</div>
))}
</section>
</div>
);
}
And here is the updated app/components/AnalyzerApp.tsx:
"use client";
import { useState } from "react";
import { ArtifactEditor, type Artifact } from "./ArtifactEditor";
import { ReportDisplay } from "./ReportDisplay";
import type { AnalysisResponse } from "@/lib/schemas";
const DEFAULT_ARTIFACTS: Artifact[] = [
{ id: crypto.randomUUID(), type: "dockerfile", name: "Dockerfile", content: "FROM node:20\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nENV DATABASE_URL=postgres://admin:secret@db:5432/myapp\nEXPOSE 8080\nCMD [\"node\", \"server.js\"]" },
{ id: crypto.randomUUID(), type: "github-actions", name: ".github/workflows/deploy.yml", content: "name: Deploy\non:\n push:\n branches: [main]\njobs:\n deploy:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@main\n - uses: aws-actions/configure-aws-credentials@main\n with:\n aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}\n aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n aws-region: us-east-1\n - run: npm ci\n - run: npm run build\n - run: aws ecs update-service --cluster prod --service api --force-new-deployment" },
];
export function AnalyzerApp() {
const [artifacts, setArtifacts] = useState<Artifact[]>(DEFAULT_ARTIFACTS);
const [result, setResult] = useState<AnalysisResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleAnalyze() {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ artifacts: artifacts.filter((a) => a.content.trim().length > 0) }) });
if (!res.ok) throw new Error(`Analysis failed: ${res.statusText}`);
const data: AnalysisResponse = await res.json();
setResult(data);
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error");
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<ArtifactEditor artifacts={artifacts} onChange={setArtifacts} />
<button onClick={handleAnalyze} disabled={loading || artifacts.every((a) => !a.content.trim())} className="px-6 py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 rounded-lg font-medium transition-colors">{loading ? "Analyzing..." : "Run Readiness Check"}</button>
{error && <div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300">{error}</div>}
{result && <ReportDisplay result={result} />}
</div>
);
}
You now have a working readiness checker that runs deterministic rules and displays structured findings. Next up, you will add Claude's structured output to transform these raw results into a prioritized readiness report with scores, risk explanations, and a launch checklist.
Add Claude Structured Output Analysis
Your deterministic checker correctly identifies every gap in the submitted artifacts. But right now, the output is a flat list of 15+ findings with no sense of priority.
A team preparing to ship needs a single answer: "Am I ready to launch, and if not, what should I fix first?" In this step, you will connect Claude's structured outputs to transform raw pass/fail data into a scored readiness report with prioritized risks and an actionable checklist.
In this step, get ready to:
- Build the Claude integration module with a system prompt and graceful degradation.
- Wire Claude into the Route Handler so findings flow through AI analysis.
- Update the frontend to display the full readiness report with scores, risks, and a launch checklist.
Create the Claude integration module
The Claude module lives in its own file. It constructs a system prompt that constrains Claude to only analyze the provided findings, then calls client.messages.parse() with zodOutputFormat() to get a fully typed, schema-conformant response.
- Create a new file at lib/claude.ts and paste the following code:
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { ReadinessReportSchema, type ReadinessReport, type CheckFinding } from "./schemas";
// Initialize the Anthropic client (reads ANTHROPIC_API_KEY from env)
const client = new Anthropic();
// Instruct Claude to analyze only provided findings, never invent new ones
const SYSTEM_PROMPT = `You are a deployment readiness analyst. Analyze ONLY the provided findings. Never invent failures not detected by the checker. Score overall readiness (0-100) and each of 7 categories. Determine readiness level. Prioritize findings by production impact. Explain WHY each gap matters. Generate a launch checklist with blockers, beforeLaunch, and postLaunch items.`;
// Format findings into a readable string for the Claude prompt
function formatFindings(findings: CheckFinding[]): string {
return findings.map((f) => `[${f.status}] ${f.ruleId} (${f.category}/${f.severity}): ${f.evidence}${f.line ? ` [line ${f.line}]` : ""} — artifact: ${f.artifactName} (${f.artifactType})`).join("\n");
}
// Call Claude with structured output, gracefully degrading on failure
export async function analyzeWithClaude(findings: CheckFinding[]): Promise<{ report: ReadinessReport | null; available: boolean }> {
try {
const response = await client.messages.parse({
model: "claude-sonnet-4-6",
max_tokens: 4096,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: `Analyze these deployment readiness findings:\n\n${formatFindings(findings)}` }],
output_config: { format: zodOutputFormat(ReadinessReportSchema) },
});
return { report: response.parsed_output, available: true };
} catch (error) {
console.error("Claude analysis failed:", error);
return { report: null, available: false };
}
}
What does this code do?
The SYSTEM_PROMPT constrains Claude to only reference findings the deterministic engine already detected. This prevents hallucinated failures that would erode trust in the report.
The zodOutputFormat(ReadinessReportSchema) call transforms your Zod schema into a JSON schema that Claude must conform to. The SDK validates the response against your original Zod schema and exposes the result as response.parsed_output with full TypeScript types.
The try/catch is the graceful degradation pattern. If Claude is unreachable or the API key is invalid, the function returns { report: null, available: false } instead of crashing the entire request.
Why structured outputs instead of free-text parsing?
With structured outputs, Claude guarantees the response matches your Zod schema on every call. You never need to parse markdown, regex out scores, or handle malformed JSON. The SDK does the validation for you and gives you a typed object immediately.
The first request for a new schema has slightly higher latency because the API compiles a grammar. After that, the compiled grammar is cached for 24 hours.
- Save lib/claude.ts.
Wire Claude into the Route Handler
The Route Handler currently returns hardcoded aiReport: null values. You need to import analyzeWithClaude and call it after the deterministic checks complete.
- Replace the entire contents of app/api/analyze/route.ts with this updated version:
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { ArtifactSchema } from "@/lib/schemas";
import { runChecks } from "@/lib/checker";
import { analyzeWithClaude } from "@/lib/claude";
const RequestSchema = z.object({ artifacts: z.array(ArtifactSchema).min(1).max(10) });
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { artifacts } = RequestSchema.parse(body);
// Step 1: Run deterministic checks (source of truth)
const findings = runChecks(artifacts);
// Step 2: Attempt Claude analysis (graceful degradation)
const { report, available } = await analyzeWithClaude(findings);
return NextResponse.json({ findings, aiReport: report, aiAvailable: available });
} catch (error) {
if (error instanceof z.ZodError) return NextResponse.json({ error: "Invalid request", details: error.errors }, { status: 400 });
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
What changed?
Two things changed. First, you added the import { analyzeWithClaude } from "@/lib/claude" line at the top. Second, you replaced the hardcoded aiReport: null, aiAvailable: false with the live result from analyzeWithClaude(findings).
The deterministic checks always run first. Claude only receives the completed findings. If Claude fails, the response still includes the full findings array.
- Save app/api/analyze/route.ts.
- Switch to your browser at localhost:3000 and click Run Readiness Check.
- Notice the request now takes 3-5 seconds instead of returning instantly. Open DevTools (F12), go to the Network tab, find the analyze request, and inspect the response JSON.
- Confirm that aiAvailable is true and aiReport contains an overallScore, categoryScores, prioritizedRisks, and launchChecklist.
Seeing a 500 error or aiAvailable: false?
- Check that your ANTHROPIC_API_KEY in .env.local is valid and has available credits.
- Make sure the import path in route.ts matches exactly: @/lib/claude.
- Restart your dev server if it shows module resolution errors. Stop it with Ctrl+C and rerun npm run dev.
Why is my Claude call failing with a 500 error?
Update ReportDisplay to render the AI report
The API now returns a full readiness report, but the frontend only renders raw findings. You need to add a score bar component and update ReportDisplay to show the overall score, per-category breakdowns, prioritized risks, and the launch checklist.
- In app/components/ReportDisplay.tsx, find the FindingsTable function. Add the following ScoreBar function directly above it:
function ScoreBar({ score, label }: { score: number; label: string }) {
// Color shifts from red to yellow to green based on score threshold
const color = score >= 80 ? "bg-green-500" : score >= 50 ? "bg-yellow-500" : "bg-red-500";
return (
<div className="flex items-center gap-3">
<span className="text-sm w-40 text-gray-300">{label}</span>
<div className="flex-1 h-3 bg-gray-800 rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${score}%` }} />
</div>
<span className="text-sm font-mono w-10 text-right">{score}</span>
</div>
);
}
What does ScoreBar do?
Each category in the readiness report gets a 0-100 score. ScoreBar renders a horizontal bar that fills proportionally. Scores below 50 render red, 50-79 render yellow, and 80+ render green. This gives instant visual feedback on which categories need attention.
- Now replace the entire export function ReportDisplay function (from its opening line to its closing brace) with this updated version:
export function ReportDisplay({ result }: { result: AnalysisResponse }) {
const { findings, aiReport, aiAvailable } = result;
const grouped = findings.reduce<Record<string, CheckFinding[]>>((acc, f) => {
(acc[f.category] ||= []).push(f);
return acc;
}, {});
return (
<div className="space-y-8 mt-8">
{/* Degradation notice when Claude is unavailable */}
{!aiAvailable && (
<div className="p-4 bg-yellow-900/30 border border-yellow-700 rounded-lg text-yellow-300">
AI analysis unavailable. Showing deterministic results only.
</div>
)}
{/* AI readiness report section */}
{aiReport && (
<section className="space-y-6">
{/* Overall score and readiness level */}
<div className="flex items-center gap-4">
<div className="text-5xl font-bold">{aiReport.overallScore}</div>
<div>
<div className="text-lg font-medium capitalize">
{aiReport.readinessLevel.replace("_", " ")}
</div>
<div className="text-gray-400 text-sm">Overall Readiness Score</div>
</div>
</div>
What is this rendering?
This first part destructures aiReport and aiAvailable from the response. When aiAvailable is false, a yellow banner warns the learner. When aiReport exists, it shows the overall numeric score and the readiness level label (like "needs work" or "almost ready").
- Continuing directly below the code you just pasted (still inside the <section> tag), add the category scores, risks, and checklist:
{/* Per-category score bars */}
<div className="space-y-2">
{Object.entries(aiReport.categoryScores).map(([cat, score]) => (
<ScoreBar key={cat} label={CATEGORY_LABELS[cat] || cat} score={score} />
))}
</div>
{/* Prioritized risks with production impact */}
{aiReport.prioritizedRisks.length > 0 && (
<div>
<h3 className="text-lg font-medium mb-3">Prioritized Risks</h3>
<div className="space-y-3">
{aiReport.prioritizedRisks.map((risk, i) => (
<div key={i} className="p-4 bg-gray-900 rounded-lg border border-gray-800">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-2 py-0.5 rounded bg-red-900/40 text-red-300 uppercase">{risk.severity}</span>
<span className="text-xs text-gray-500 uppercase">{risk.category}</span>
</div>
<div className="font-medium">{risk.title}</div>
<div className="text-sm text-gray-400 mt-1">{risk.productionImpact}</div>
<div className="text-sm text-blue-300 mt-2">Fix: {risk.recommendedFix}</div>
</div>
))}
</div>
</div>
)}
- Continuing directly below, add the launch checklist and close out the component:
{/* Launch checklist split into priority tiers */}
<div>
<h3 className="text-lg font-medium mb-3">Launch Checklist</h3>
{aiReport.launchChecklist.blockers.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-medium text-red-400 mb-2">Blockers</h4>
<ul className="space-y-1">
{aiReport.launchChecklist.blockers.map((item, i) => (
<li key={i} className="text-sm text-gray-300 flex gap-2">
<span className="text-red-400">✗</span> {item}
</li>
))}
</ul>
</div>
)}
{aiReport.launchChecklist.beforeLaunch.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-medium text-yellow-400 mb-2">Before Launch</h4>
<ul className="space-y-1">
{aiReport.launchChecklist.beforeLaunch.map((item, i) => (
<li key={i} className="text-sm text-gray-300 flex gap-2">
<span className="text-yellow-400">○</span> {item}
</li>
))}
</ul>
</div>
)}
- Finally, add the post-launch section, close the checklist <div>, close the <section>, and keep the deterministic findings section at the bottom:
{aiReport.launchChecklist.postLaunch.length > 0 && (
<div>
<h4 className="text-sm font-medium text-blue-400 mb-2">Post-Launch</h4>
<ul className="space-y-1">
{aiReport.launchChecklist.postLaunch.map((item, i) => (
<li key={i} className="text-sm text-gray-300 flex gap-2">
<span className="text-blue-400">○</span> {item}
</li>
))}
</ul>
</div>
)}
</div>
</section>
)}
{/* Deterministic findings always shown at the bottom */}
<section>
<h3 className="text-lg font-medium mb-4">Deterministic Findings</h3>
{Object.entries(grouped).map(([category, catFindings]) => (
<div key={category} className="mb-6">
<h4 className="text-sm font-medium text-gray-400 uppercase mb-2">
{CATEGORY_LABELS[category] || category}
</h4>
<FindingsTable findings={catFindings} />
</div>
))}
</section>
</div>
);
}
What does the launch checklist show?
Claude splits its recommendations into three tiers. Blockers are issues that would cause outages, security breaches, or data loss. Before Launch items significantly reduce risk but are not immediate show-stoppers. Post-Launch items are improvements for operational maturity you can address after shipping.
- Save app/components/ReportDisplay.tsx.
Seeing a TypeScript error about missing properties?
- Make sure you replaced the entire ReportDisplay function, including the opening export function ReportDisplay line and the final closing brace.
- Verify the ScoreBar function is placed above ReportDisplay (not inside it). It must be a sibling function.
- Check that you still have the CATEGORY_LABELS constant and FindingsTable function unchanged from the prior step.
Help me fix a TypeScript error in ReportDisplay.tsx
Before you test, predict: given the default Dockerfile that runs as root with secrets in ENV, and the GitHub Actions workflow with unpinned actions and static AWS keys, do you think Claude will rate this as "ready" or "not ready"?
- Switch to your browser and click Run Readiness Check.
After a few seconds, you should see a large readiness score (likely below 40), colored score bars for each category, a list of prioritized risks explaining why each problem matters in production, and a launch checklist dividing fixes into blockers versus nice-to-haves.
Below the AI report, the raw deterministic findings still appear. Both layers are visible at once.
Verify graceful degradation
The whole point of the try/catch pattern in lib/claude.ts is that the app must remain useful when Claude is unreachable. You will break the API key on purpose to prove this works.
- Open .env.local and temporarily change your ANTHROPIC_API_KEY value to invalid-key-test.
- Restart the dev server (Ctrl+C, then npm run dev) so it picks up the new env value.
- Click Run Readiness Check again.
You should see a yellow banner saying "AI analysis unavailable. Showing deterministic results only." The raw findings still render correctly below it. The app did not crash.
- Restore your real API key in .env.local and restart the dev server again.
Not seeing the yellow degradation banner?
- Make sure you restarted the dev server after editing .env.local. Next.js only reads env files at startup.
- Check that ReportDisplay destructures aiAvailable from result and renders the conditional banner when it is false.
Why is my degradation banner not appearing when Claude fails?
✔️ Awesome, I've got everything!
Great. Confirm you have saved all three files: lib/claude.ts, app/api/analyze/route.ts, and app/components/ReportDisplay.tsx.
ⓧ I'd like to double check the full code
Scroll up and confirm each chunk is present in the correct file, in order:
- lib/claude.ts: imports, client initialization, SYSTEM_PROMPT, formatFindings, and analyzeWithClaude.
- app/api/analyze/route.ts: imports include analyzeWithClaude, the POST handler calls runChecks then analyzeWithClaude, response returns findings, aiReport, aiAvailable.
- app/components/ReportDisplay.tsx: ScoreBar function above FindingsTable, ReportDisplay destructures aiReport and aiAvailable, renders degradation notice, overall score, category bars via ScoreBar, prioritized risks, launch checklist (blockers, beforeLaunch, postLaunch), then deterministic findings.
You now have a complete two-layer analysis system. Deterministic checks are the source of truth. Claude adds the prioritization, risk context, and cross-category reasoning that raw findings cannot provide. And when the AI layer goes down, the tool keeps working.
Secret mission
Cross-Artifact Correlation Checks
Your checker currently analyzes each file in isolation. But real deployment failures often come from inconsistencies BETWEEN files. A Dockerfile exposes port 8080 while the Kubernetes manifest expects port 3000. A CI/CD pipeline deploys to production but no health check exists anywhere. Can you detect these cross-artifact gaps?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing cloud costs. The only expense was the Claude API calls during development (approximately $0.10-$0.30 total).
Resources you used:
- Next.js project directory on your Desktop (all source files, node_modules, config).
- The .env.local file containing your Anthropic API key.
- The running Next.js dev server process at localhost:3000.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep analyzing your own deployment configs.
- Your dev server continues running at localhost:3000.
- Your .env.local file keeps your API key configured for future use.
- Node.js and the installed npm packages remain on your machine.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal and port. Your project files stay on disk so you can resume later.
- In the terminal where your dev server is running, press Ctrl+C to stop the server.
- Close VS Code if you want to free up memory.
- To resume later, open the project folder in VS Code and run npm run dev again.
Delete - I don't want to use this again
Remove all project files and start fresh. No cloud resources need cleanup.
- In the terminal where your dev server is running, press Ctrl+C to stop the server.
- Navigate to your Desktop and remove the project folder by running:
cd ~/Desktop
rm -rf deployment-readiness-checker
On Windows, run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force deployment-readiness-checker
- Your Anthropic API key remains active in your Anthropic account. If you want to revoke it, visit the API Keys page in your Anthropic console and delete the key you used for this project.
Nice Work!
Nice Work!
You just built a production-grade deployment readiness checker that combines deterministic static analysis with Claude's structured outputs. Paste any combination of deployment artifacts and get an instant, prioritized assessment of whether your app is ready to ship.
You've learned how to:
- Build a deterministic checker engine with 15+ rules across 7 production-readiness categories (security, reliability, observability, secrets, containerization, CI/CD, health) that produces verifiable pass/fail findings with evidence and line numbers.
- Design a complex Zod schema for Claude's structured readiness report and use zodOutputFormat() with client.messages.parse() to guarantee schema-conformant AI analysis every time.
- Implement a graceful degradation pattern where the deterministic report remains fully available even when the AI layer is unavailable, proving that AI augments validation rather than replacing it.
- Secret Mission: Added cross-artifact correlation checks that detect inconsistencies between deployment files, catching integration failures that single-file linters miss.
Ready to quiz yourself?