Metadata-Version: 2.5
Name: langgraph-lint
Version: 0.1.0
Summary: Find LangGraph wiring bugs before they become a runtime KeyError.
Project-URL: Homepage, https://github.com/mathewOracle/langgraph-lint
Project-URL: Repository, https://github.com/mathewOracle/langgraph-lint
Project-URL: Issues, https://github.com/mathewOracle/langgraph-lint/issues
Author: mathewOracle
License-Expression: MIT
License-File: LICENSE
Keywords: agent,ai,code-quality,graph,langchain,langgraph,linter,static-analysis
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Software Development :: Testing
Classifier: Typing :: Typed
Requires-Python: >=3.11
Provides-Extra: agent
Requires-Dist: langchain-core>=0.3; extra == 'agent'
Requires-Dist: langchain>=1.0; extra == 'agent'
Requires-Dist: langgraph>=0.2; extra == 'agent'
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: langgraph>=0.2; extra == 'dev'
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

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

```console
$ 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:

```python
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

```console
pip install langgraph-lint                # zero dependencies
pip install "langgraph-lint[agent]"       # + a LangChain/LangGraph agent tool
```

## Use it as a CLI

```console
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

```python
from langgraph_lint import lint

for finding in lint(app):
    print(finding)
```

## Use it in an agent

```python
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 unreachable `path_map` branches only trusts a `Literal[...]` 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 no `Literal[...]` annotation) is a heuristic, not a guarantee — it scans `return "..."` statements and can miss dynamically computed values. Those findings are `MEDIUM`, not `HIGH`, 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

```console
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
