Lab 16 · Production readiness review
Build a static reviewer that scans an agent project for production gaps, such as in-memory state, hard-coded keys and unguarded refund tools, and writes a scored gap report. Then score the human checklist that no regex can answer.
What you will build
A command-line readiness reviewer. You point it at an agent project and it reads every text file, runs nine checks mapped to a production-readiness checklist, and prints a Markdown gap report with a score, a verdict and the file and line behind every finding.
You run it against a small sample project with deliberate gaps, fix one of them, and then run it against your own code. A second small script scores the 60-item human checklist for everything a static scan cannot see: the threat model, the SLOs, the runbook.
What you will learn
- Which structural gaps block an agent release: in-memory checkpointers, literal secrets, destructive tools without a human gate
- How to write detectors that return a status plus evidence someone can act on
- Why
warnandfailare different, and why a reviewer that cries wolf gets disabled - How to score a release: critical failures block it whatever the total says
- What a static review cannot see, and how the human checklist covers it
Check your prerequisites
You need three things on your machine and three values from your instructor.
- Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
- A code editor. VS Code with the Python extension works well.
- A terminal. PowerShell on Windows, Terminal on macOS or Linux.
python --versionpython3 --versionThis lab runs fully offline and uses only the Python standard library. It needs no API key, no model and no packages to install.
Create the project folder and virtual environment
Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.
mkdir C:\labs\lab-16-readiness-review
cd C:\labs\lab-16-readiness-review
python -m venv .venv
.venv\Scripts\Activate.ps1mkdir -p ~/labs/lab-16-readiness-review
cd ~/labs/lab-16-readiness-review
python3 -m venv .venv
source .venv/bin/activateKeep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.
When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.
PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.
This is what your folder will look like at the end of the lab:
Files marked as generated are created when you run the code. You do not create them yourself.
Install the libraries
This lab uses only the Python standard library, so there is nothing to install. (The requirements.txt in the finished download is empty on purpose.) Keep the virtual environment activated anyway; it is a good habit.
Download the lab files
Download the files below.
- Save
sample_project.zipin your lab folder and unzip it there, so you get asample_projectfolder next to where your scripts will go. - Create a
checklistsfolder in your lab folder and save the three.mdfiles in it.
To unzip from the terminal, run Expand-Archive sample_project.zip -DestinationPath . in PowerShell or unzip sample_project.zip on macOS and Linux. Windows Explorer's Extract All suggests a folder named after the zip, which gives you sample_project\sample_project; change the target to your lab folder, or move the inner folder up.
Have a quick look at sample_project. It is a normal-looking billing agent with a tool that issues refunds, a unit test, a langgraph.json and a .env.example. Do not fix anything yet: the reviewer should find the gaps for you.
Write the readiness reviewer
The reviewer does not run the project. It reads the files as text and looks for structure: which checkpointer is configured, whether a key is written into the code, whether a refund tool has an approval gate. That is enough to catch the boring, structural gaps before a review meeting, so the meeting can spend its hour on the things a script cannot judge.
Create an empty file named readiness_review.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports
Only the standard library: re for the patterns, pathlib to walk the project, argparse for the command line and NamedTuple for a small result type.
"""Lab 16: a static production-readiness review of an agent project.
A static reviewer only sees structure: it proves a checkpointer is *configured*, never that it
is *correct*. Score the rest of the checklist by hand.
Run: python readiness_review.py [project_dir] [--out gap-report.md]
"""
import argparse
import re
import sys
from pathlib import Path
from typing import Callable, NamedTupleBlock 2Which files to read
SKIP_DIRS keeps virtual environments, caches and .git out of the scan; scanning .venv would drown you in findings from installed libraries. READABLE lists the text file types to read, and EXTRA_FILES adds files that have no extension, such as Dockerfile and .env.
SKIP_DIRS = {".git", ".venv", "venv", "__pycache__", "node_modules", ".pytest_cache", ".ruff_cache"}
READABLE = {".py", ".json", ".toml", ".yml", ".yaml", ".txt", ".md", ".cfg", ".example"}
EXTRA_FILES = {"Dockerfile", ".env", ".gitignore"}Block 3What to look for
Each tuple is the vocabulary of one check:
LEGACY_APIS: names from LangChain 0.x that should be gone after the 1.x migration.DURABLE_SAVERS: checkpointers that survive a restart.DESTRUCTIVE_VERBS: words in a tool name that suggest an irreversible action.HITL_MARKERS,EVAL_MARKERS,TRACING_MARKERS: evidence of an approval gate, an evaluation suite and tracing.SECRET_PATTERNS: shapes of real credentials, plus anyapi_key = "..."style literal.
Every marker is a regular expression, so characters like ( are escaped. TOOL_DEF finds the name of every function decorated with @tool. POINTS turns a status into a score.
# Every marker below is searched as a regular expression, so brackets are escaped.
LEGACY_APIS = ("AgentExecutor", "initialize_agent", "MemorySaver", "LLMChain", "RetrievalQA",
"ConversationBufferMemory", "MultiServerMCPClient")
DURABLE_SAVERS = ("SqliteSaver", "PostgresSaver", "AsyncPostgresSaver", "AsyncSqliteSaver")
DESTRUCTIVE_VERBS = ("refund", "delete", "cancel", "charge", "transfer", "revoke", "purge",
"terminate", "issue", "pay")
HITL_MARKERS = ("HumanInTheLoopMiddleware", "interrupt_on", r"interrupt\(")
EVAL_MARKERS = (r"client\.evaluate\(", r"aevaluate\(", "create_llm_as_judge", "create_dataset")
TRACING_MARKERS = ("LANGSMITH_TRACING", "LANGSMITH_API_KEY", "enable_tracing")
SECRET_PATTERNS = (
(r"sk-[A-Za-z0-9]{20,}", "OpenAI-style key"),
(r"lsv2_(?:pt|sk)_[A-Za-z0-9]{16,}", "LangSmith key"),
(r"AKIA[0-9A-Z]{16}", "AWS access key id"),
(r"(?i)(api_key|password|secret|token)\s*=\s*[\"'][^\"'\s$]{12,}[\"']", "hard-coded literal"),
)
TOOL_DEF = re.compile(r"@tool[^\n]*\n(?:\s*@[^\n]*\n)*\s*(?:async\s+)?def\s+(\w+)")
POINTS = {"pass": 1.0, "warn": 0.5, "fail": 0.0}Block 4Load the project
load_files walks the folder with rglob("*") and returns {relative_path: text}. errors="ignore" means an odd byte in one file cannot crash the review. Every check works on this one dictionary, so the disk is read only once.
def load_files(root: Path) -> dict[str, str]:
"""Every text file in the project, keyed by its path relative to the root."""
files: dict[str, str] = {}
for path in sorted(root.rglob("*")):
relative = path.relative_to(root)
if not path.is_file() or SKIP_DIRS & set(relative.parts):
continue
if path.suffix in READABLE or path.name in EXTRA_FILES:
files[relative.as_posix()] = path.read_text(encoding="utf-8", errors="ignore")
return filesBlock 5Search helpers
findreturns"path:line"for the first match of a pattern, so every finding can point at a line. Withapp_only=Trueit skips tests and non-Python files, so a fake model or anInMemorySaverin a test never counts as production configuration.existschecks for a file by glob pattern, such as**/langgraph.json.tool_nameslists every@toolfunction name.
def line_of(text: str, match: re.Match[str]) -> int:
"""1-based line number of a regex match."""
return text[: match.start()].count("\n") + 1
def find(files: dict[str, str], pattern: str, *, app_only: bool = False) -> str:
"""'path:line' of the first regex match, or ''. app_only skips tests and non-Python files."""
for path, text in files.items():
if app_only and (not path.endswith(".py") or "test" in path.lower()):
continue
if match := re.search(pattern, text):
return f"{path}:{line_of(text, match)}"
return ""
def exists(files: dict[str, str], *patterns: str) -> str:
"""The first file whose path matches one of the glob patterns, or ''."""
return next((p for pattern in patterns for p in files if Path(p).match(pattern)), "")
def tool_names(files: dict[str, str]) -> set[str]:
"""Every @tool-decorated function name in the project, lowercased."""
return {m.group(1).lower() for text in files.values() for m in TOOL_DEF.finditer(text)}Block 6Architecture checks
check_legacy_apis guards each name with (?<![A-Za-z0-9_]), a negative lookbehind. Without it, the legacy name MemorySaver would also match the modern InMemorySaver, because substrings lie.
check_checkpointer has three outcomes, and the difference matters:
- a durable saver anywhere in app code: pass;
InMemorySaverin a project that has alanggraph.jsonorDockerfile: fail, because it will be deployed and lose every conversation on restart;InMemorySaverin a project that is not deployable: only warn, because a local demo is allowed to forget.
def check_legacy_apis(files: dict[str, str]) -> tuple[str, str]:
"""ARCH-01: names from LangChain 0.x that must not survive the 1.x migration."""
hits = [f"{api} ({where})" for api in LEGACY_APIS
if (where := find(files, rf"(?<![A-Za-z0-9_]){api}"))]
return ("fail", "; ".join(hits)) if hits else ("pass", "no legacy 0.x APIs found")
def check_checkpointer(files: dict[str, str]) -> tuple[str, str]:
"""ARCH-04: the most common gap is in-memory state in something that gets deployed."""
if durable := next((s for s in DURABLE_SAVERS if find(files, s, app_only=True)), ""):
return "pass", f"{durable} at {find(files, durable, app_only=True)}"
in_memory = find(files, "InMemorySaver", app_only=True)
if in_memory and exists(files, "langgraph.json", "**/langgraph.json", "Dockerfile"):
return "fail", f"InMemorySaver in deployable code ({in_memory}); state is lost on restart"
if in_memory:
return "warn", f"only InMemorySaver ({in_memory}); fine locally, not in a deployment"
return "fail", "no checkpointer at all: no threads, no resume, no HITL after a restart"Block 7Safety checks
check_secrets reports every credential-shaped string as label at path:line, and a committed .env that .gitignore does not exclude. It skips .md and .example files, where placeholder keys are expected.
check_hitl returns n/a when no tool sounds destructive, which excludes it from the score. Otherwise it passes only if an approval gate exists, and even then its evidence reminds you to check the gate covers those tools.
def check_secrets(files: dict[str, str]) -> tuple[str, str]:
"""SAFE-01: literal credentials in the repository, or a committed .env."""
hits = []
for path, text in files.items():
if path.endswith((".example", ".md")):
continue
for pattern, label in SECRET_PATTERNS:
if match := re.search(pattern, text):
hits.append(f"{label} at {path}:{line_of(text, match)}")
if ".env" in files and ".env" not in files.get(".gitignore", ""):
hits.append("a committed .env that .gitignore does not exclude")
return ("fail", "; ".join(hits)) if hits else ("pass", "no literal secrets found")
def check_hitl(files: dict[str, str]) -> tuple[str, str]:
"""SAFE-03: a destructive-sounding tool that can fire without a human."""
risky = sorted(t for t in tool_names(files) if any(v in t for v in DESTRUCTIVE_VERBS))
if not risky:
return "n/a", "no destructive-sounding tools found"
if where := next((w for m in HITL_MARKERS if (w := find(files, m))), ""):
return "pass", f"approval gate at {where}, but check it covers {', '.join(risky)}"
return "fail", f"destructive tools with no approval gate: {', '.join(risky)}"Block 8Quality, observability and deployment checks
Four checks with the same shape: find evidence, return pass with its location, or fail with a sentence that says why it matters. Unit tests catch API breaks; only evaluations catch behaviour changes, so the two are separate checks. A Dockerfile without langgraph.json is a warn, not a fail: it is deployable, just not on the Agent Server.
def check_tests(files: dict[str, str]) -> tuple[str, str]:
"""QUAL-01: without tests, nothing stops a regression."""
tests = [path for path in files if Path(path).name.startswith("test_")]
if tests:
return "pass", f"{len(tests)} test file(s), e.g. {tests[0]}"
return "fail", "no test_*.py anywhere"
def check_evals(files: dict[str, str]) -> tuple[str, str]:
"""QUAL-03: unit tests catch API breaks; only evals catch behaviour changes."""
where = next((w for m in EVAL_MARKERS if (w := find(files, m))), "")
if where:
return "pass", f"evaluation code at {where}"
return "fail", "no evaluation suite: quality changes will be invisible"
def check_tracing(files: dict[str, str]) -> tuple[str, str]:
"""OBS-01: an incident with no trace is an incident with no evidence."""
where = next((w for m in TRACING_MARKERS if (w := find(files, m))), "")
return ("pass", f"tracing configured at {where}") if where else ("fail", "no tracing configuration")
def check_deployable(files: dict[str, str]) -> tuple[str, str]:
"""DEP-01: something a deployment target can actually build."""
if path := exists(files, "langgraph.json", "**/langgraph.json"):
return "pass", path
if path := exists(files, "Dockerfile", "**/Dockerfile"):
return "warn", f"{path} but no langgraph.json"
return "fail", "no langgraph.json and no Dockerfile: nothing to deploy"Block 9A check of your own: pinned dependencies
This is the kind of check you add for your project. It maps to checklist item DEP-02: every line in a requirements*.txt should pin an exact version with ==, so a rebuild in six weeks installs the same code.
It returns warn rather than fail, because a human could reasonably disagree (a library, for example, wants ranges). Keep fail for things that are definitely wrong. Lines starting with - (such as -r other.txt) and comments are ignored.
def check_pinned_deps(files: dict[str, str]) -> tuple[str, str]:
"""DEP-02 (your own check): an unpinned dependency makes every build a different build."""
req_files = [p for p in files if Path(p).name.startswith("requirements") and p.endswith(".txt")]
if not req_files:
return "n/a", "no requirements*.txt (pins in pyproject.toml are not checked)"
loose = []
for path in req_files:
for number, line in enumerate(files[path].splitlines(), 1):
line = line.split("#")[0].strip()
if line and not line.startswith("-") and "==" not in line:
loose.append(f"{line} ({path}:{number})")
if loose:
return "warn", f"not pinned to an exact version: {', '.join(loose)}"
return "pass", f"every dependency pinned in {', '.join(req_files)}"Block 10Findings and the check registry
Finding is one row of the report. CHECKS maps each detector to a checklist id, a title and a critical flag. To add a check, write a function that returns (status, evidence) and add one line here.
class Finding(NamedTuple):
"""One check's verdict, with evidence the person who has to fix it can act on."""
id: str
title: str
critical: bool
status: str # pass | warn | fail | n/a
evidence: str
# id, title, critical (one failed critical check blocks the release), detector
CHECKS: list[tuple[str, str, bool, Callable[[dict[str, str]], tuple[str, str]]]] = [
("ARCH-01", "No legacy 0.x LangChain APIs", False, check_legacy_apis),
("ARCH-04", "Durable checkpointer in deployable code", True, check_checkpointer),
("SAFE-01", "No secrets in the repository", True, check_secrets),
("SAFE-03", "Destructive tools are gated by a human", True, check_hitl),
("QUAL-01", "Offline test suite exists", False, check_tests),
("QUAL-03", "Evaluation suite exists", False, check_evals),
("OBS-01", "Tracing is configured", True, check_tracing),
("DEP-01", "The project is deployable", False, check_deployable),
("DEP-02", "Dependencies are pinned", False, check_pinned_deps),
]Block 11Review and score
review runs every check and wraps each result in a Finding.
score averages the points of every check that is not n/a (and returns 0.0 if none apply, instead of dividing by zero). Then the rules:
- any failed critical check means NOT READY, whatever the total says;
- a total under 0.75 means NOT READY;
- 0.90 or more is READY, anything in between is READY WITH CAVEATS.
def review(project_dir: Path) -> list[Finding]:
"""Run every static check against a project directory."""
files = load_files(Path(project_dir).resolve())
return [Finding(i, title, critical, *detect(files)) for i, title, critical, detect in CHECKS]
def score(findings: list[Finding]) -> tuple[float, list[Finding], str]:
"""Score, blocking findings and verdict. One failed critical check blocks the release."""
scored = [f for f in findings if f.status != "n/a"]
total = round(sum(POINTS[f.status] for f in scored) / len(scored), 4) if scored else 0.0
blocking = [f for f in findings if f.critical and f.status == "fail"]
if blocking or total < 0.75:
return total, blocking, "NOT READY"
return total, blocking, "READY" if total >= 0.90 else "READY WITH CAVEATS"Block 12Render the gap report
The deliverable is Markdown someone can paste into a review ticket: the score and verdict, one table row per check, and a numbered list of blocking gaps. The footer is a reminder that the most important items need a human.
def render_markdown(project: str, findings: list[Finding]) -> str:
"""The deliverable: a gap report someone can paste into a review ticket."""
total, blocking, verdict = score(findings)
lines = [f"# Production readiness gap report: `{project}`", "",
f"Score **{total:.2f}** - **{verdict}**", "",
"| ID | Check | Status | Evidence |", "|---|---|---|---|"]
for f in findings:
critical = " **(critical)**" if f.critical else ""
lines.append(f"| {f.id} | {f.title}{critical} | {f.status.upper()} | {f.evidence} |")
if blocking:
lines += ["", "## Blocking gaps (fix before release)", ""]
lines += [f"1. **{f.id}** {f.title} - {f.evidence}" for f in blocking]
lines += ["", "Static checks cannot see least privilege, the threat model, the SLOs or the",
"runbook. Score those by hand with `checklists/production-readiness.md`."]
return "\n".join(lines) + "\n"Block 13Command line
The project folder is the first argument and defaults to sample_project. --out also writes the report to a file, with UTF-8 encoding on every platform (a plain > redirect in Windows PowerShell 5.1 would write UTF-16).
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Static production-readiness review.")
parser.add_argument("project", nargs="?", default="sample_project", help="folder to review")
parser.add_argument("--out", help="also write the report to this Markdown file")
args = parser.parse_args()
project = Path(args.project).resolve()
if not project.is_dir():
sys.exit(f"Not a folder: {project}. Unzip sample_project.zip here or pass a path.")
report = render_markdown(project.name, review(project))
print(report)
if args.out:
Path(args.out).write_text(report, encoding="utf-8")
print(f"Report written to {args.out}")Write the checklist scorer
Some items have no honest static answer: least privilege, the threat model, the SLOs, the escalation path. Those live in checklists/production-readiness.md, where you tick each box by hand. This short script scores the filled-in file with the same rules as the reviewer.
Create an empty file named checklist_score.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports and the item format
ITEM_RE matches checklist lines like - [x] **ARCH-04** · statement and captures the mark, the id and the statement. The marks score 1 (x), 0.5 (~, partly done) or 0 (empty). A - or / mark means not applicable and is left out of POINTS, so the item drops out of the average.
"""Lab 16: score a filled-in production-readiness checklist.
Run: python checklist_score.py [checklist.md]
"""
import re
import sys
from pathlib import Path
DEFAULT_CHECKLIST = Path(__file__).resolve().parent / "checklists" / "production-readiness.md"
# "- [x] **ARCH-01** · statement". A "-" or "/" mark means "not applicable here".
ITEM_RE = re.compile(r"^- \[([ xX~/\-])\] \*\*([A-Z]{3,4}-\d{1,2})\*\* ·? ?(.+)$", re.MULTILINE)
POINTS = {"x": 1.0, "X": 1.0, "~": 0.5, " ": 0.0}Block 2Parse the checklist
findall returns every item line; prose, tables and the legend are ignored because they do not match. An item is critical when its statement contains (critical).
def parse_checklist(text: str) -> list[dict[str, object]]:
"""One dict per applicable checklist line; prose, legends and tables are ignored."""
return [
{"id": item_id, "points": POINTS[mark], "critical": "(critical)" in statement}
for mark, item_id, statement in ITEM_RE.findall(text)
if mark in POINTS
]Block 3Score and verdict
The average of all applicable items, plus the ids of critical items that are not fully done. One unchecked critical item blocks the release, whatever the total says.
def score_checklist(items: list[dict[str, object]]) -> tuple[float, list[str]]:
"""The overall score, plus the ids of critical items that are not fully done."""
if not items:
return 0.0, []
total = sum(float(item["points"]) for item in items) / len(items)
return round(total, 4), [str(i["id"]) for i in items if i["critical"] and float(i["points"]) < 1.0]
def verdict(score: float, blocking: list[str]) -> str:
"""One unchecked critical item blocks the release, whatever the total says."""
if blocking or score < 0.50:
return "NOT READY"
if score >= 0.90:
return "READY"
return "READY WITH CAVEATS" if score >= 0.75 else "NOT READY"Block 4Command line
Pass the path of your filled-in copy. Without an argument it scores the blank checklist from the checklists folder.
if __name__ == "__main__":
checklist = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_CHECKLIST
if not checklist.is_file():
sys.exit(f"Checklist not found: {checklist}")
items = parse_checklist(checklist.read_text(encoding="utf-8"))
score, blocking = score_checklist(items)
print(f"{checklist.name}: {len(items)} applicable items, score {score:.2f} - {verdict(score, blocking)}")
print(f"blocking critical items ({len(blocking)}): {', '.join(blocking) or 'none'}")Review the sample project
With sample_project unzipped in your lab folder, run the reviewer. It reviews sample_project by default.
python readiness_review.pypython3 readiness_review.pyYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
# Production readiness gap report: `sample_project`
Score **0.50** - **NOT READY**
| ID | Check | Status | Evidence |
|---|---|---|---|
| ARCH-01 | No legacy 0.x LangChain APIs | PASS | no legacy 0.x APIs found |
| ARCH-04 | Durable checkpointer in deployable code **(critical)** | FAIL | InMemorySaver in deployable code (app/agent.py:5); state is lost on restart |
| SAFE-01 | No secrets in the repository **(critical)** | FAIL | OpenAI-style key at app/config.py:4; hard-coded literal at app/config.py:4 |
| SAFE-03 | Destructive tools are gated by a human **(critical)** | FAIL | destructive tools with no approval gate: issue_refund |
| QUAL-01 | Offline test suite exists | PASS | 1 test file(s), e.g. tests/test_tools.py |
| QUAL-03 | Evaluation suite exists | FAIL | no evaluation suite: quality changes will be invisible |
| OBS-01 | Tracing is configured **(critical)** | PASS | tracing configured at .env.example:3 |
| DEP-01 | The project is deployable | PASS | langgraph.json |
| DEP-02 | Dependencies are pinned | WARN | not pinned to an exact version: langchain>=1.0 (requirements.txt:1), langgraph (requirements.txt:3) |
## Blocking gaps (fix before release)
1. **ARCH-04** Durable checkpointer in deployable code - InMemorySaver in deployable code (app/agent.py:5); state is lost on restart
1. **SAFE-01** No secrets in the repository - OpenAI-style key at app/config.py:4; hard-coded literal at app/config.py:4
1. **SAFE-03** Destructive tools are gated by a human - destructive tools with no approval gate: issue_refund
Static checks cannot see least privilege, the threat model, the SLOs or the
runbook. Score those by hand with `checklists/production-readiness.md`.The sample project scores 0.50 and is NOT READY, blocked by three critical gaps:
- ARCH-04:
InMemorySaverinapp/agent.py, in a project with alanggraph.json. Every conversation, and every pending approval, is lost on restart. - SAFE-01: a key written into
app/config.py. Two patterns hit the same line. - SAFE-03:
issue_refundcan move money with no human approval.
It also reports a missing evaluation suite and two unpinned dependencies. And it gives credit where due: tests exist, tracing is configured in .env.example, and there is a langgraph.json. Every finding names a file and a line, so the person fixing it does not have to search.
Fix one gap and review again
Open sample_project/app/config.py. Add import os at the top, and replace the hard-coded key with OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", ""). Save the file and run the reviewer again:
python readiness_review.pypython3 readiness_review.pySAFE-01 now passes and the score rises from 0.50 to 0.61, but the verdict is still NOT READY: two critical gaps remain. That is the rule working as intended. A high average must never hide a blocker.
Optional: fix ARCH-04 as well by switching to SqliteSaver from langgraph.checkpoint.sqlite, and watch the blocking list shrink again.
Review your own project
Now point the reviewer at real code: an earlier lab folder, the course capstone or any agent project you have. Replace the path with yours. --out saves the report as gap-report.md.
python readiness_review.py C:\labs\lab-15-ship-it --out gap-report.mdpython3 readiness_review.py ~/labs/lab-15-ship-it --out gap-report.mdRead every finding before you "fix" anything. A scanner that flags TEST_API_KEY = "test-key-do-not-ship" in a test file is not wrong, but a human may triage it to accepted. That is the normal outcome for many findings: the tool's job is to put things in front of a person, not to decide whether they matter.
For every blocking gap, add a line at the bottom of gap-report.md: owner · date · what done looks like. A gap with no owner is not a gap, it is a wish.
Score the human checklist
First score the blank checklist, to see the scale of what a static scan cannot see:
python checklist_score.pypython3 checklist_score.pyYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
production-readiness.md: 60 applicable items, score 0.00 - NOT READY
blocking critical items (14): ARCH-03, ARCH-04, ARCH-09, QUAL-07, SAFE-01, SAFE-03, SAFE-04, SAFE-07, SAFE-09, OBS-06, DEP-05, DEP-07, OPS-01, OPS-05Sixty items, fourteen of them critical. Now make it real:
- Copy
checklists/production-readiness.mdtomy-project-readiness.md. - Mark every item honestly for your project:
[x]only when you can name the evidence,[~]when it is partly done,[-]when it does not apply (say why). - Run
python checklist_score.py my-project-readiness.md.
[~] is the honest answer more often than you would like. The runbook and postmortem templates in checklists are what the checklist's operations items ask for.
Check your work
You are done when
- The sample project scores 0.50, NOT READY, with ARCH-04, SAFE-01 and SAFE-03 as blocking gaps.
- After moving the key into the environment, SAFE-01 passes and the score rises to 0.61, still NOT READY.
gap-report.mdfor your own project names a file and line for at least three findings.- Every blocking gap in your report has an owner and a date.
- You have scored a filled-in copy of the checklist and can name your top three risks without reading the report.
Troubleshooting
Not a folder: ...sample_project
Unzip sample_project.zip into the lab folder so that sample_project sits next to readiness_review.py, or pass the folder's path as the first argument.
The report looks different from the expected output
Check the unzip did not create sample_project\sample_project. The reviewer would then see the project one level down, and paths in the evidence gain a prefix.
Checklist not found
Save the three checklist files in a checklists folder in your lab folder, or pass the path of your copy.
The review of my own project is slow or full of library code
Add the folder name of your virtual environment or build output to SKIP_DIRS.
'python' is not recognized, or the wrong Python runs
Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.
Go further
- Add another check mapped to a checklist id that would have caught a real bug in your project, such as call limits (
ARCH-07:ModelCallLimitMiddleware,recursion_limit) or run metadata (OBS-02). - Run it in CI. Call the reviewer on every pull request and fail the build when
score()returns a blocking finding. Post the Markdown report as the job summary or a PR comment. - Fill in the runbook template for the agent you deployed in Lab 15: identity card, alerts, first five minutes of an incident, rollback.
- Operational checks. Add a mode that takes a LangSmith project name and checks what files cannot show: does an alert exist, is an online evaluator running, has the dataset grown this month?
Stuck?
Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.