langgraph-lint 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langgraph_lint-0.1.0/.github/workflows/ci.yml +102 -0
- langgraph_lint-0.1.0/.github/workflows/publish.yml +71 -0
- langgraph_lint-0.1.0/.gitignore +19 -0
- langgraph_lint-0.1.0/CHANGELOG.md +25 -0
- langgraph_lint-0.1.0/LICENSE +21 -0
- langgraph_lint-0.1.0/PKG-INFO +147 -0
- langgraph_lint-0.1.0/README.md +113 -0
- langgraph_lint-0.1.0/pyproject.toml +72 -0
- langgraph_lint-0.1.0/src/langgraph_lint/__init__.py +13 -0
- langgraph_lint-0.1.0/src/langgraph_lint/cli.py +111 -0
- langgraph_lint-0.1.0/src/langgraph_lint/core.py +302 -0
- langgraph_lint-0.1.0/src/langgraph_lint/integrations.py +125 -0
- langgraph_lint-0.1.0/src/langgraph_lint/models.py +46 -0
- langgraph_lint-0.1.0/tests/fixtures/buggy_graph.py +47 -0
- langgraph_lint-0.1.0/tests/fixtures/clean_graph.py +39 -0
- langgraph_lint-0.1.0/tests/test_cli.py +77 -0
- langgraph_lint-0.1.0/tests/test_core.py +294 -0
- langgraph_lint-0.1.0/tests/test_integrations.py +116 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
# uv's managed interpreters are "externally managed" and reject --system, so
|
|
9
|
+
# every job installs into an explicit virtualenv that later steps reuse.
|
|
10
|
+
env:
|
|
11
|
+
UV_PROJECT_ENVIRONMENT: .venv
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
test:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
strategy:
|
|
17
|
+
fail-fast: false
|
|
18
|
+
matrix:
|
|
19
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
- uses: astral-sh/setup-uv@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python-version }}
|
|
25
|
+
|
|
26
|
+
- name: Activate the uv-managed virtualenv
|
|
27
|
+
run: |
|
|
28
|
+
uv venv .venv --allow-existing
|
|
29
|
+
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
|
|
30
|
+
echo "VIRTUAL_ENV=$PWD/.venv" >> "$GITHUB_ENV"
|
|
31
|
+
|
|
32
|
+
# The base install must stay dependency-free, so prove the package and
|
|
33
|
+
# its CLI work before any optional extra -- including langgraph itself --
|
|
34
|
+
# is present.
|
|
35
|
+
- name: Install base package only
|
|
36
|
+
run: uv pip install -e .
|
|
37
|
+
- name: Verify base install pulls in no LangChain or LangGraph
|
|
38
|
+
run: |
|
|
39
|
+
python -c "import langgraph_lint; print(langgraph_lint.__version__)"
|
|
40
|
+
langgraph-lint --version
|
|
41
|
+
python - <<'PY'
|
|
42
|
+
for name in ("langchain_core", "langgraph"):
|
|
43
|
+
try:
|
|
44
|
+
__import__(name)
|
|
45
|
+
except ImportError:
|
|
46
|
+
print(f"confirmed: base install pulls in no {name}")
|
|
47
|
+
else:
|
|
48
|
+
raise SystemExit(f"base install must not depend on {name}")
|
|
49
|
+
PY
|
|
50
|
+
|
|
51
|
+
# Smoke test the CLI end to end against a graph with a real, deliberate
|
|
52
|
+
# LG003 bug, using only the base install plus langgraph itself (which a
|
|
53
|
+
# real user would already have -- this simulates their actual setup).
|
|
54
|
+
- name: Install langgraph for the smoke test
|
|
55
|
+
run: uv pip install langgraph
|
|
56
|
+
- name: CLI finds the deliberate bug in the fixture graph
|
|
57
|
+
run: |
|
|
58
|
+
cd tests/fixtures
|
|
59
|
+
if langgraph-lint buggy_graph:app --min-severity high; then
|
|
60
|
+
echo "expected langgraph-lint to find the LG003 bug and exit 1" >&2
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
echo "confirmed: CLI found the bug and exited non-zero"
|
|
64
|
+
- name: CLI is silent on a clean graph
|
|
65
|
+
run: |
|
|
66
|
+
cd tests/fixtures
|
|
67
|
+
langgraph-lint clean_graph:app
|
|
68
|
+
|
|
69
|
+
- name: Install dev and agent extras
|
|
70
|
+
run: uv pip install -e ".[dev,agent]"
|
|
71
|
+
- name: Lint
|
|
72
|
+
run: ruff check .
|
|
73
|
+
- name: Type check
|
|
74
|
+
run: mypy src
|
|
75
|
+
- name: Test
|
|
76
|
+
run: pytest -q
|
|
77
|
+
|
|
78
|
+
build:
|
|
79
|
+
runs-on: ubuntu-latest
|
|
80
|
+
steps:
|
|
81
|
+
- uses: actions/checkout@v4
|
|
82
|
+
- uses: astral-sh/setup-uv@v5
|
|
83
|
+
with:
|
|
84
|
+
python-version: "3.12"
|
|
85
|
+
- name: Activate the uv-managed virtualenv
|
|
86
|
+
run: |
|
|
87
|
+
uv venv .venv --allow-existing
|
|
88
|
+
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
|
|
89
|
+
echo "VIRTUAL_ENV=$PWD/.venv" >> "$GITHUB_ENV"
|
|
90
|
+
- run: uv pip install build twine
|
|
91
|
+
- run: python -m build
|
|
92
|
+
- name: Validate distribution metadata
|
|
93
|
+
run: twine check --strict dist/*
|
|
94
|
+
- name: Install the built wheel in a clean environment
|
|
95
|
+
run: |
|
|
96
|
+
uv venv /tmp/fresh
|
|
97
|
+
uv pip install --python /tmp/fresh/bin/python dist/*.whl
|
|
98
|
+
/tmp/fresh/bin/langgraph-lint --version
|
|
99
|
+
- uses: actions/upload-artifact@v4
|
|
100
|
+
with:
|
|
101
|
+
name: dist
|
|
102
|
+
path: dist/
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
# Trusted Publishing (OIDC) -- no API token is ever stored in GitHub secrets.
|
|
4
|
+
# One-time setup at https://pypi.org/manage/account/publishing/ :
|
|
5
|
+
# PyPI project: langgraph-lint | owner: mathewOracle | repo: langgraph-lint
|
|
6
|
+
# workflow: publish.yml | environment: pypi
|
|
7
|
+
# Repeat at https://test.pypi.org/manage/account/publishing/ for TestPyPI.
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
release:
|
|
11
|
+
types: [published]
|
|
12
|
+
workflow_dispatch:
|
|
13
|
+
inputs:
|
|
14
|
+
target:
|
|
15
|
+
description: Where to publish
|
|
16
|
+
required: true
|
|
17
|
+
default: testpypi
|
|
18
|
+
type: choice
|
|
19
|
+
options: [testpypi, pypi]
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
build:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
- uses: astral-sh/setup-uv@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: "3.12"
|
|
29
|
+
# uv's managed interpreter rejects --system, so build inside a venv.
|
|
30
|
+
- name: Activate the uv-managed virtualenv
|
|
31
|
+
run: |
|
|
32
|
+
uv venv .venv --allow-existing
|
|
33
|
+
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
|
|
34
|
+
echo "VIRTUAL_ENV=$PWD/.venv" >> "$GITHUB_ENV"
|
|
35
|
+
- run: uv pip install build twine
|
|
36
|
+
- run: python -m build
|
|
37
|
+
- run: twine check --strict dist/*
|
|
38
|
+
- uses: actions/upload-artifact@v4
|
|
39
|
+
with:
|
|
40
|
+
name: dist
|
|
41
|
+
path: dist/
|
|
42
|
+
|
|
43
|
+
testpypi:
|
|
44
|
+
needs: build
|
|
45
|
+
if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
environment: testpypi
|
|
48
|
+
permissions:
|
|
49
|
+
id-token: write
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/download-artifact@v4
|
|
52
|
+
with:
|
|
53
|
+
name: dist
|
|
54
|
+
path: dist/
|
|
55
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
56
|
+
with:
|
|
57
|
+
repository-url: https://test.pypi.org/legacy/
|
|
58
|
+
|
|
59
|
+
pypi:
|
|
60
|
+
needs: build
|
|
61
|
+
if: github.event_name == 'release' || inputs.target == 'pypi'
|
|
62
|
+
runs-on: ubuntu-latest
|
|
63
|
+
environment: pypi
|
|
64
|
+
permissions:
|
|
65
|
+
id-token: write
|
|
66
|
+
steps:
|
|
67
|
+
- uses: actions/download-artifact@v4
|
|
68
|
+
with:
|
|
69
|
+
name: dist
|
|
70
|
+
path: dist/
|
|
71
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- `LG000` — graph fails to `compile()` at all.
|
|
8
|
+
- `LG001` — a node with no path from `START` (dead code). Downgraded to
|
|
9
|
+
informational when the node is referenced by a `Send(...)` call, to avoid
|
|
10
|
+
false-positiving on map-reduce fan-out.
|
|
11
|
+
- `LG003` — a conditional router's declared return type includes a value
|
|
12
|
+
missing from its `path_map`. This is the flagship rule: LangGraph compiles
|
|
13
|
+
this without error and it only crashes, with `KeyError`, on whichever input
|
|
14
|
+
first takes the untested branch. Verified against a real reproduction.
|
|
15
|
+
- `LG005` — the mirror image of `LG003`: a `path_map` branch the router's own
|
|
16
|
+
return type can never produce (dead routing code).
|
|
17
|
+
- CLI (`langgraph-lint module.path:attribute`), library API (`lint()`), and
|
|
18
|
+
an optional LangChain/LangGraph agent tool (`langgraph-lint[agent]`).
|
|
19
|
+
- Zero required dependencies, verified in a clean virtualenv.
|
|
20
|
+
|
|
21
|
+
Two rules considered for this release — "no path to `END`" and "duplicate
|
|
22
|
+
edge" — were cut before shipping. Both turned out to be structurally
|
|
23
|
+
undetectable: LangGraph auto-inserts an implicit edge to `END` on nearly
|
|
24
|
+
every node (confirmed even inside a genuine unbreakable cycle), and plain
|
|
25
|
+
edges are deduplicated into a `set` before `get_graph()` ever sees them.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mathewOracle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langgraph-lint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find LangGraph wiring bugs before they become a runtime KeyError.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mathewOracle/langgraph-lint
|
|
6
|
+
Project-URL: Repository, https://github.com/mathewOracle/langgraph-lint
|
|
7
|
+
Project-URL: Issues, https://github.com/mathewOracle/langgraph-lint/issues
|
|
8
|
+
Author: mathewOracle
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,ai,code-quality,graph,langchain,langgraph,linter,static-analysis
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Provides-Extra: agent
|
|
24
|
+
Requires-Dist: langchain-core>=0.3; extra == 'agent'
|
|
25
|
+
Requires-Dist: langchain>=1.0; extra == 'agent'
|
|
26
|
+
Requires-Dist: langgraph>=0.2; extra == 'agent'
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
29
|
+
Requires-Dist: langgraph>=0.2; extra == 'dev'
|
|
30
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# langgraph-lint
|
|
36
|
+
|
|
37
|
+
**Find LangGraph wiring bugs before they become a runtime `KeyError`.**
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
```console
|
|
42
|
+
$ langgraph-lint myapp.graph:app
|
|
43
|
+
|
|
44
|
+
[HIGH] LG003 (route_after_search): router can return 'maybe', which has no
|
|
45
|
+
matching entry in the path_map (['no', 'yes']) -- this raises KeyError at
|
|
46
|
+
runtime the first time this branch is taken
|
|
47
|
+
|
|
48
|
+
[HIGH] LG001 (validate_output): 'validate_output' has no incoming edge and
|
|
49
|
+
no path from START; it can never run
|
|
50
|
+
|
|
51
|
+
2 finding(s) in myapp.graph:app.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
That first finding is real, reproducible, and proven — not a guess:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
def router(state) -> Literal["yes", "no", "maybe"]:
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
graph.add_conditional_edges("a", router, {"yes": "b", "no": "c"}) # no "maybe"!
|
|
61
|
+
|
|
62
|
+
compiled = graph.compile() # succeeds. no warning. nothing.
|
|
63
|
+
compiled.invoke({"x": 2}) # fine
|
|
64
|
+
compiled.invoke({"x": 3}) # fine
|
|
65
|
+
compiled.invoke({"x": 101}) # KeyError: 'maybe' <- only now
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`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.
|
|
69
|
+
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
|
+
```console
|
|
73
|
+
pip install langgraph-lint # zero dependencies
|
|
74
|
+
pip install "langgraph-lint[agent]" # + a LangChain/LangGraph agent tool
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Use it as a CLI
|
|
78
|
+
|
|
79
|
+
```console
|
|
80
|
+
langgraph-lint myapp.graph:app # module.path:attribute, like uvicorn
|
|
81
|
+
langgraph-lint myapp.graph:app --min-severity high
|
|
82
|
+
langgraph-lint myapp.graph:app --format json
|
|
83
|
+
langgraph-lint myapp.graph:app --format github # Actions annotations
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`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.
|
|
87
|
+
|
|
88
|
+
## Use it as a library
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from langgraph_lint import lint
|
|
92
|
+
|
|
93
|
+
for finding in lint(app):
|
|
94
|
+
print(finding)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Use it in an agent
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from langgraph_lint.integrations import build_graph
|
|
101
|
+
|
|
102
|
+
auditor = build_graph("anthropic:claude-sonnet-4-5")
|
|
103
|
+
auditor.invoke({
|
|
104
|
+
"messages": [{"role": "user", "content": "Check myapp.graph:app for wiring bugs."}]
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Or `get_tools()` to bind `check_langgraph_graph` to your own agent.
|
|
109
|
+
|
|
110
|
+
## Rules
|
|
111
|
+
|
|
112
|
+
| Code | Catches | LangGraph catches it today? |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `LG000` | Graph fails to `compile()` at all | Raises, but as a stack trace, not a lint finding |
|
|
115
|
+
| `LG001` | A node with no path from `START` (dead code) | No |
|
|
116
|
+
| `LG003` | A router's `Literal[...]` return type has a value missing from its `path_map` | **No — silent until that branch runs** |
|
|
117
|
+
| `LG005` | A `path_map` branch the router's own return type can never produce (dead routing code, the mirror image of LG003) | No |
|
|
118
|
+
|
|
119
|
+
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.
|
|
120
|
+
|
|
121
|
+
## On false positives
|
|
122
|
+
|
|
123
|
+
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.
|
|
124
|
+
|
|
125
|
+
Similarly:
|
|
126
|
+
- `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.
|
|
127
|
+
- `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.
|
|
128
|
+
|
|
129
|
+
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.
|
|
130
|
+
|
|
131
|
+
## Compatibility
|
|
132
|
+
|
|
133
|
+
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.
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```console
|
|
138
|
+
uv venv && uv pip install -e ".[dev]"
|
|
139
|
+
pytest
|
|
140
|
+
ruff check . && mypy src
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
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.
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# langgraph-lint
|
|
2
|
+
|
|
3
|
+
**Find LangGraph wiring bugs before they become a runtime `KeyError`.**
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
```console
|
|
8
|
+
$ langgraph-lint myapp.graph:app
|
|
9
|
+
|
|
10
|
+
[HIGH] LG003 (route_after_search): router can return 'maybe', which has no
|
|
11
|
+
matching entry in the path_map (['no', 'yes']) -- this raises KeyError at
|
|
12
|
+
runtime the first time this branch is taken
|
|
13
|
+
|
|
14
|
+
[HIGH] LG001 (validate_output): 'validate_output' has no incoming edge and
|
|
15
|
+
no path from START; it can never run
|
|
16
|
+
|
|
17
|
+
2 finding(s) in myapp.graph:app.
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That first finding is real, reproducible, and proven — not a guess:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
def router(state) -> Literal["yes", "no", "maybe"]:
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
graph.add_conditional_edges("a", router, {"yes": "b", "no": "c"}) # no "maybe"!
|
|
27
|
+
|
|
28
|
+
compiled = graph.compile() # succeeds. no warning. nothing.
|
|
29
|
+
compiled.invoke({"x": 2}) # fine
|
|
30
|
+
compiled.invoke({"x": 3}) # fine
|
|
31
|
+
compiled.invoke({"x": 101}) # KeyError: 'maybe' <- only now
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`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.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```console
|
|
39
|
+
pip install langgraph-lint # zero dependencies
|
|
40
|
+
pip install "langgraph-lint[agent]" # + a LangChain/LangGraph agent tool
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Use it as a CLI
|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
langgraph-lint myapp.graph:app # module.path:attribute, like uvicorn
|
|
47
|
+
langgraph-lint myapp.graph:app --min-severity high
|
|
48
|
+
langgraph-lint myapp.graph:app --format json
|
|
49
|
+
langgraph-lint myapp.graph:app --format github # Actions annotations
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`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.
|
|
53
|
+
|
|
54
|
+
## Use it as a library
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from langgraph_lint import lint
|
|
58
|
+
|
|
59
|
+
for finding in lint(app):
|
|
60
|
+
print(finding)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Use it in an agent
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from langgraph_lint.integrations import build_graph
|
|
67
|
+
|
|
68
|
+
auditor = build_graph("anthropic:claude-sonnet-4-5")
|
|
69
|
+
auditor.invoke({
|
|
70
|
+
"messages": [{"role": "user", "content": "Check myapp.graph:app for wiring bugs."}]
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or `get_tools()` to bind `check_langgraph_graph` to your own agent.
|
|
75
|
+
|
|
76
|
+
## Rules
|
|
77
|
+
|
|
78
|
+
| Code | Catches | LangGraph catches it today? |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `LG000` | Graph fails to `compile()` at all | Raises, but as a stack trace, not a lint finding |
|
|
81
|
+
| `LG001` | A node with no path from `START` (dead code) | No |
|
|
82
|
+
| `LG003` | A router's `Literal[...]` return type has a value missing from its `path_map` | **No — silent until that branch runs** |
|
|
83
|
+
| `LG005` | A `path_map` branch the router's own return type can never produce (dead routing code, the mirror image of LG003) | No |
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
|
|
87
|
+
## On false positives
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
Similarly:
|
|
92
|
+
- `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.
|
|
93
|
+
- `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.
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
## Compatibility
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```console
|
|
104
|
+
uv venv && uv pip install -e ".[dev]"
|
|
105
|
+
pytest
|
|
106
|
+
ruff check . && mypy src
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langgraph-lint"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Find LangGraph wiring bugs before they become a runtime KeyError."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "mathewOracle" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"langgraph",
|
|
15
|
+
"langchain",
|
|
16
|
+
"static-analysis",
|
|
17
|
+
"linter",
|
|
18
|
+
"graph",
|
|
19
|
+
"agent",
|
|
20
|
+
"ai",
|
|
21
|
+
"code-quality",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
32
|
+
"Topic :: Software Development :: Testing",
|
|
33
|
+
"Typing :: Typed",
|
|
34
|
+
]
|
|
35
|
+
dependencies = []
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
agent = ["langchain>=1.0", "langchain-core>=0.3", "langgraph>=0.2"]
|
|
39
|
+
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "build>=1.2", "langgraph>=0.2"]
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
langgraph-lint = "langgraph_lint.cli:main"
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/mathewOracle/langgraph-lint"
|
|
46
|
+
Repository = "https://github.com/mathewOracle/langgraph-lint"
|
|
47
|
+
Issues = "https://github.com/mathewOracle/langgraph-lint/issues"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/langgraph_lint"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 100
|
|
54
|
+
target-version = "py311"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
|
|
62
|
+
[tool.mypy]
|
|
63
|
+
python_version = "3.11"
|
|
64
|
+
strict = true
|
|
65
|
+
|
|
66
|
+
[[tool.mypy.overrides]]
|
|
67
|
+
module = "langgraph_lint.integrations"
|
|
68
|
+
disallow_untyped_decorators = false
|
|
69
|
+
|
|
70
|
+
[[tool.mypy.overrides]]
|
|
71
|
+
module = ["langchain.*", "langchain_core.*", "langgraph.*"]
|
|
72
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Public API.
|
|
2
|
+
|
|
3
|
+
Find LangGraph wiring bugs LangGraph itself won't catch until runtime.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from langgraph_lint.core import lint
|
|
9
|
+
from langgraph_lint.models import Finding, Severity
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["Finding", "Severity", "__version__", "lint"]
|