langgraph-lint
Find LangGraph wiring bugs before they become a runtime KeyError.
LangGraph validates some things at compile() time — an edge to a node that doesn't exist, for instance, fails immediately and loudly. But a lot of real wiring bugs compile perfectly fine and only surface the first time a specific input takes a specific untested branch, weeks later, in production.
$ langgraph-lint myapp.graph:app
[HIGH] LG003 (route_after_search): router can return 'maybe', which has no
matching entry in the path_map (['no', 'yes']) -- this raises KeyError at
runtime the first time this branch is taken
[HIGH] LG001 (validate_output): 'validate_output' has no incoming edge and
no path from START; it can never run
2 finding(s) in myapp.graph:app.
That first finding is real, reproducible, and proven — not a guess:
def router(state) -> Literal["yes", "no", "maybe"]:
...
graph.add_conditional_edges("a", router, {"yes": "b", "no": "c"}) # no "maybe"!
compiled = graph.compile() # succeeds. no warning. nothing.
compiled.invoke({"x": 2}) # fine
compiled.invoke({"x": 3}) # fine
compiled.invoke({"x": 101}) # KeyError: 'maybe' <- only now
langgraph-lint catches this the moment you write it, by checking the router's own Literal[...] return type against the path_map you gave add_conditional_edges. No invocation needed.
Install
pip install langgraph-lint # zero dependencies
pip install "langgraph-lint[agent]" # + a LangChain/LangGraph agent tool
Use it as a CLI
langgraph-lint myapp.graph:app # module.path:attribute, like uvicorn
langgraph-lint myapp.graph:app --min-severity high
langgraph-lint myapp.graph:app --format json
langgraph-lint myapp.graph:app --format github # Actions annotations
target accepts a compiled graph, an uncompiled StateGraph builder, or any object with .get_graph() — so it also works on plain LangChain LCEL runnables, just with a smaller rule set (the conditional-branch check is LangGraph-specific). Exit code 1 on findings, 0 clean, 2 on error.
Use it as a library
from langgraph_lint import lint
for finding in lint(app):
print(finding)
Use it in an agent
from langgraph_lint.integrations import build_graph
auditor = build_graph("anthropic:claude-sonnet-4-5")
auditor.invoke({
"messages": [{"role": "user", "content": "Check myapp.graph:app for wiring bugs."}]
})
Or get_tools() to bind check_langgraph_graph to your own agent.
Rules
| Code | Catches | LangGraph catches it today? |
|---|---|---|
LG000 |
Graph fails to compile() at all |
Raises, but as a stack trace, not a lint finding |
LG001 |
A node with no path from START (dead code) |
No |
LG003 |
A router's Literal[...] return type has a value missing from its path_map |
No — silent until that branch runs |
LG005 |
A path_map branch the router's own return type can never produce (dead routing code, the mirror image of LG003) |
No |
Two rules that seemed obviously useful up front — "node with no path to END" and "duplicate edge" — got cut before release. Both turned out to be structurally undetectable: LangGraph auto-inserts an implicit escape-hatch edge to END on essentially every node (even inside a genuine, unbreakable cycle — verified), and plain edges are deduplicated into a set before get_graph() ever sees them. A rule that can never fire is worse than no rule; better to ship four honest ones than six decorative ones.
On false positives
Every rule here has a real counter-pattern, and this tool knows about the big one: Send()-based map-reduce fan-out creates zero static edges to its target node. A node reached only via Send("worker", ...) has no incoming edge in the graph's structure at all — indistinguishable, by structure alone, from a typo'd dead node. langgraph-lint scans node and router source for Send("literal_name", ...) calls and downgrades those findings to informational rather than screaming at correct, idiomatic code.
Similarly:
LG005's check on unreachablepath_mapbranches only trusts aLiteral[...]type annotation, never the AST heuristic, since a false claim of "this can never happen" is worse than staying silent.LG003's AST-based fallback (used when a router has noLiteral[...]annotation) is a heuristic, not a guarantee — it scansreturn "..."statements and can miss dynamically computed values. Those findings areMEDIUM, notHIGH, and say so explicitly.
Every Finding carries a caveat field for exactly this reason. LG003 findings backed by an actual type annotation carry an empty caveat, because there's nothing to hedge — that's about as close to certain as static analysis gets.
Compatibility
Built and tested against langgraph==1.2.11. The LG003/LG005 branch checks read StateGraph.branches and BranchSpec.path/.ends -- internal, non-public attributes, not a documented API. They've been stable across recent LangGraph releases, but a future LangGraph refactor could rename them; if that happens, langgraph-lint degrades to LG001-only (dead-node detection, which only needs the public get_graph()), not a crash -- worth knowing if you pin an unusually old or new LangGraph version.
Development
uv venv && uv pip install -e ".[dev]"
pytest
ruff check . && mypy src
The test suite builds real langgraph.StateGraph objects rather than mocks. The flagship test doesn't just assert a finding fires — it then actually invokes the compiled graph and confirms the predicted KeyError really happens, so the rule's claim is proven, not just plausible.
License
MIT
Metadata
Release files for langgraph-lint 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| langgraph_lint-0.1.0.tar.gz | 17.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| langgraph_lint-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 31.0 kB
Release files / langgraph_lint-0.1.0.tar.gz
| Download URL | langgraph_lint-0.1.0.tar.gz |
|---|---|
| Size | 17.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
230d0a6ccae9660e3a9fa821d6458471e2fc41e2c7c87bb9092e07b72940365c
|
|
BLAKE2b-256 checksum How to use checksums |
943f699bdf4823bfc27c84c97529813e2389670e76caa55a8385ca65c305258a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency logRelease files / langgraph_lint-0.1.0-py3-none-any.whl
| Download URL | langgraph_lint-0.1.0-py3-none-any.whl |
|---|---|
| Size | 13.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
394e1630600a1fe77335d6e58216987c7fb582c747c4e2f0a79419af807b9c60
|
|
BLAKE2b-256 checksum How to use checksums |
0ff17327981a30b785defd67b03b5cf9b78bdd73e93b2b5afaeae6afdbcf294c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency log