langgraph-lint 0.1.0__py3-none-any.whl
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/__init__.py +13 -0
- langgraph_lint/cli.py +111 -0
- langgraph_lint/core.py +302 -0
- langgraph_lint/integrations.py +125 -0
- langgraph_lint/models.py +46 -0
- langgraph_lint-0.1.0.dist-info/METADATA +147 -0
- langgraph_lint-0.1.0.dist-info/RECORD +10 -0
- langgraph_lint-0.1.0.dist-info/WHEEL +4 -0
- langgraph_lint-0.1.0.dist-info/entry_points.txt +2 -0
- langgraph_lint-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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"]
|
langgraph_lint/cli.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
Loads a graph object out of your own code, the same way ``uvicorn`` or
|
|
4
|
+
``gunicorn`` load an ASGI app: ``module.path:attribute``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import importlib
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from langgraph_lint import __version__, lint
|
|
16
|
+
from langgraph_lint.models import Finding, Severity
|
|
17
|
+
|
|
18
|
+
EXIT_OK = 0
|
|
19
|
+
EXIT_FINDINGS = 1
|
|
20
|
+
EXIT_ERROR = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_target(spec: str) -> object:
|
|
24
|
+
"""Resolve ``"pkg.module:attribute"`` into the live object it names."""
|
|
25
|
+
if ":" not in spec:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"target must be 'module.path:attribute', e.g. 'myapp.graph:app'; got {spec!r}"
|
|
28
|
+
)
|
|
29
|
+
module_name, _, attr_path = spec.partition(":")
|
|
30
|
+
sys.path.insert(0, str(Path.cwd()))
|
|
31
|
+
module = importlib.import_module(module_name)
|
|
32
|
+
obj: object = module
|
|
33
|
+
for part in attr_path.split("."):
|
|
34
|
+
obj = getattr(obj, part)
|
|
35
|
+
return obj
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
prog="langgraph-lint",
|
|
41
|
+
description="Find LangGraph wiring bugs before they become a runtime KeyError.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"target",
|
|
45
|
+
nargs="?",
|
|
46
|
+
help="graph to check, as 'module.path:attribute' (e.g. 'myapp.graph:app')",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--min-severity",
|
|
50
|
+
choices=[s.value for s in Severity],
|
|
51
|
+
default="medium",
|
|
52
|
+
help="minimum severity to report (default: medium)",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument("--format", choices=["text", "json", "github"], default="text")
|
|
55
|
+
parser.add_argument("--exit-zero", action="store_true", help="always exit 0")
|
|
56
|
+
parser.add_argument("--version", action="version", version=f"langgraph-lint {__version__}")
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _print_text(findings: list[Finding], target: str) -> None:
|
|
61
|
+
if not findings:
|
|
62
|
+
print(f"{target}: no findings. Every node earns its wiring.")
|
|
63
|
+
return
|
|
64
|
+
for finding in findings:
|
|
65
|
+
print(f" {finding}")
|
|
66
|
+
if finding.caveat:
|
|
67
|
+
print(f" ? {finding.caveat}")
|
|
68
|
+
print(f"\n{len(findings)} finding(s) in {target}.")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _print_github(findings: list[Finding]) -> None:
|
|
72
|
+
for f in findings:
|
|
73
|
+
level = "error" if f.severity == Severity.HIGH else "warning"
|
|
74
|
+
print(f"::{level} title={f.rule}::{f.node}: {f.message}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv: list[str] | None = None) -> int:
|
|
78
|
+
args = _build_parser().parse_args(argv)
|
|
79
|
+
if not args.target:
|
|
80
|
+
_build_parser().print_help()
|
|
81
|
+
return EXIT_ERROR
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
graph_like = _load_target(args.target)
|
|
85
|
+
except (ValueError, ImportError, AttributeError) as exc:
|
|
86
|
+
print(f"langgraph-lint: {exc}", file=sys.stderr)
|
|
87
|
+
return EXIT_ERROR
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
findings = lint(graph_like)
|
|
91
|
+
except TypeError as exc:
|
|
92
|
+
print(f"langgraph-lint: {exc}", file=sys.stderr)
|
|
93
|
+
return EXIT_ERROR
|
|
94
|
+
|
|
95
|
+
threshold = {"high": 0, "medium": 1, "low": 2}[args.min_severity]
|
|
96
|
+
findings = [f for f in findings if {"high": 0, "medium": 1, "low": 2}[f.severity] <= threshold]
|
|
97
|
+
|
|
98
|
+
if args.format == "json":
|
|
99
|
+
print(json.dumps([f.as_dict() for f in findings], indent=2))
|
|
100
|
+
elif args.format == "github":
|
|
101
|
+
_print_github(findings)
|
|
102
|
+
else:
|
|
103
|
+
_print_text(findings, args.target)
|
|
104
|
+
|
|
105
|
+
if args.exit_zero:
|
|
106
|
+
return EXIT_OK
|
|
107
|
+
return EXIT_FINDINGS if findings else EXIT_OK
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
raise SystemExit(main())
|
langgraph_lint/core.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Structural analysis of a LangGraph (or plain LangChain Runnable) graph.
|
|
2
|
+
|
|
3
|
+
Works by duck-typing against whatever ``.get_graph()`` returns -- a
|
|
4
|
+
``langchain_core.runnables.graph.Graph`` with ``.nodes`` and ``.edges``. This
|
|
5
|
+
module never imports ``langgraph`` or ``langchain_core``, so it has zero
|
|
6
|
+
required dependencies; it only needs an object shaped like the ones those
|
|
7
|
+
libraries produce.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import inspect
|
|
14
|
+
import textwrap
|
|
15
|
+
import typing
|
|
16
|
+
from collections import defaultdict, deque
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from langgraph_lint.models import Finding, Severity
|
|
20
|
+
|
|
21
|
+
#: LangGraph's reserved entry/exit node ids. Hardcoded rather than imported
|
|
22
|
+
#: from ``langgraph.graph`` to keep this module dependency-free; stable across
|
|
23
|
+
#: the versions this was built and tested against.
|
|
24
|
+
START = "__start__"
|
|
25
|
+
END = "__end__"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def lint(graph_like: Any) -> list[Finding]:
|
|
29
|
+
"""Analyze a graph for wiring defects LangGraph itself won't catch.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
graph_like: A compiled LangGraph graph, an uncompiled
|
|
33
|
+
``StateGraph``/``Graph`` builder, or any object exposing
|
|
34
|
+
``.get_graph()`` -> an object with ``.nodes`` and ``.edges``
|
|
35
|
+
(this is exactly what ``langchain_core.runnables.graph.Graph``
|
|
36
|
+
looks like, so plain LCEL runnables work too).
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Findings sorted by severity, then rule, then node name.
|
|
40
|
+
"""
|
|
41
|
+
nodes, edges, branches, compile_error = _resolve(graph_like)
|
|
42
|
+
findings: list[Finding] = []
|
|
43
|
+
|
|
44
|
+
if compile_error is not None:
|
|
45
|
+
findings.append(
|
|
46
|
+
Finding(
|
|
47
|
+
rule="LG000",
|
|
48
|
+
node="<graph>",
|
|
49
|
+
message=f"graph does not compile: {compile_error}",
|
|
50
|
+
severity=Severity.HIGH,
|
|
51
|
+
caveat="",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
return findings
|
|
55
|
+
|
|
56
|
+
forward: dict[str, set[str]] = defaultdict(set)
|
|
57
|
+
for edge in edges:
|
|
58
|
+
forward[edge.source].add(edge.target)
|
|
59
|
+
|
|
60
|
+
send_targets = _send_targets(nodes, branches)
|
|
61
|
+
reachable = _bfs(START, forward)
|
|
62
|
+
|
|
63
|
+
for name in nodes:
|
|
64
|
+
if name in (START, END):
|
|
65
|
+
continue
|
|
66
|
+
if name not in reachable:
|
|
67
|
+
findings.append(_unreachable_finding(name, send_targets))
|
|
68
|
+
|
|
69
|
+
if branches:
|
|
70
|
+
findings.extend(_check_conditional_branches(branches, nodes))
|
|
71
|
+
|
|
72
|
+
return sorted(
|
|
73
|
+
findings,
|
|
74
|
+
key=lambda f: ({"high": 0, "medium": 1, "low": 2}[f.severity], f.rule, f.node),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# -- resolving the input into a structural view ------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve(
|
|
82
|
+
graph_like: Any,
|
|
83
|
+
) -> tuple[dict[str, Any], list[Any], dict[str, dict[str, Any]] | None, str | None]:
|
|
84
|
+
branches: dict[str, dict[str, Any]] | None = None
|
|
85
|
+
if hasattr(graph_like, "branches"):
|
|
86
|
+
branches = graph_like.branches
|
|
87
|
+
elif hasattr(graph_like, "builder") and hasattr(graph_like.builder, "branches"):
|
|
88
|
+
branches = graph_like.builder.branches
|
|
89
|
+
|
|
90
|
+
structural = graph_like
|
|
91
|
+
if not hasattr(structural, "get_graph"):
|
|
92
|
+
if hasattr(structural, "compile"):
|
|
93
|
+
try:
|
|
94
|
+
structural = structural.compile()
|
|
95
|
+
except Exception as exc: # surfaced as a finding, not raised
|
|
96
|
+
return {}, [], branches, str(exc)
|
|
97
|
+
if not hasattr(structural, "get_graph"):
|
|
98
|
+
if hasattr(structural, "nodes") and hasattr(structural, "edges"):
|
|
99
|
+
return dict(structural.nodes), list(structural.edges), branches, None
|
|
100
|
+
raise TypeError(
|
|
101
|
+
f"{type(graph_like).__name__} does not look like a graph: expected "
|
|
102
|
+
f"`.get_graph()`, `.compile()`, or `.nodes`/`.edges`"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
graph = structural.get_graph()
|
|
106
|
+
return dict(graph.nodes), list(graph.edges), branches, None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# -- Send() detection, to avoid false-positives on map-reduce fan-out -------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _send_targets(nodes: dict[str, Any], branches: dict[str, dict[str, Any]] | None) -> set[str]:
|
|
113
|
+
"""Node names referenced as ``Send("name", ...)`` anywhere reachable.
|
|
114
|
+
|
|
115
|
+
``Send`` lets a node or router dynamically fan out to a target with no
|
|
116
|
+
static edge at all, which is indistinguishable from a typo'd dead node
|
|
117
|
+
by structure alone. Scanning source for the literal target name is a
|
|
118
|
+
heuristic -- it misses dynamically computed names -- but it silences the
|
|
119
|
+
overwhelmingly common case of map-reduce fan-out.
|
|
120
|
+
"""
|
|
121
|
+
targets: set[str] = set()
|
|
122
|
+
functions: list[Any] = []
|
|
123
|
+
for node in nodes.values():
|
|
124
|
+
func = getattr(node, "data", None)
|
|
125
|
+
if func is not None:
|
|
126
|
+
functions.append(func)
|
|
127
|
+
if branches:
|
|
128
|
+
for branch_map in branches.values():
|
|
129
|
+
for branch in branch_map.values():
|
|
130
|
+
path = getattr(branch, "path", None)
|
|
131
|
+
functions.append(getattr(path, "func", path))
|
|
132
|
+
|
|
133
|
+
for func in functions:
|
|
134
|
+
real = getattr(func, "func", func) # unwrap RunnableCallable-style wrappers
|
|
135
|
+
try:
|
|
136
|
+
source = textwrap.dedent(inspect.getsource(real))
|
|
137
|
+
tree = ast.parse(source)
|
|
138
|
+
except (OSError, TypeError, SyntaxError):
|
|
139
|
+
continue
|
|
140
|
+
for call in ast.walk(tree):
|
|
141
|
+
if not isinstance(call, ast.Call):
|
|
142
|
+
continue
|
|
143
|
+
name = call.func.id if isinstance(call.func, ast.Name) else None
|
|
144
|
+
if name != "Send" or not call.args:
|
|
145
|
+
continue
|
|
146
|
+
first = call.args[0]
|
|
147
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
148
|
+
targets.add(first.value)
|
|
149
|
+
return targets
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# -- reachability -------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _bfs(start: str, adjacency: dict[str, set[str]]) -> set[str]:
|
|
156
|
+
seen = {start}
|
|
157
|
+
queue = deque([start])
|
|
158
|
+
while queue:
|
|
159
|
+
current = queue.popleft()
|
|
160
|
+
for neighbor in adjacency.get(current, ()):
|
|
161
|
+
if neighbor not in seen:
|
|
162
|
+
seen.add(neighbor)
|
|
163
|
+
queue.append(neighbor)
|
|
164
|
+
return seen
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# -- individual rules ----------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _unreachable_finding(name: str, send_targets: set[str]) -> Finding:
|
|
171
|
+
if name in send_targets:
|
|
172
|
+
return Finding(
|
|
173
|
+
rule="LG001",
|
|
174
|
+
node=name,
|
|
175
|
+
message=(
|
|
176
|
+
f"{name!r} has no static edge, but is referenced by a `Send(...)` "
|
|
177
|
+
f"call -- likely a map-reduce fan-out target, not dead code"
|
|
178
|
+
),
|
|
179
|
+
severity=Severity.LOW,
|
|
180
|
+
caveat="Confirm the Send() target string is not a leftover from a rename.",
|
|
181
|
+
)
|
|
182
|
+
return Finding(
|
|
183
|
+
rule="LG001",
|
|
184
|
+
node=name,
|
|
185
|
+
message=f"{name!r} has no incoming edge and no path from START; it can never run",
|
|
186
|
+
severity=Severity.HIGH,
|
|
187
|
+
caveat=(
|
|
188
|
+
"If this node is only reached via `Send(...)` with a dynamically "
|
|
189
|
+
"computed name, this heuristic cannot see that -- verify manually."
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _check_conditional_branches(
|
|
195
|
+
branches: dict[str, dict[str, Any]], nodes: dict[str, Any]
|
|
196
|
+
) -> list[Finding]:
|
|
197
|
+
findings: list[Finding] = []
|
|
198
|
+
for source, branch_map in branches.items():
|
|
199
|
+
for branch in branch_map.values():
|
|
200
|
+
ends: dict[str, str] = dict(getattr(branch, "ends", {}) or {})
|
|
201
|
+
if not ends:
|
|
202
|
+
continue
|
|
203
|
+
path = getattr(branch, "path", None)
|
|
204
|
+
func = getattr(path, "func", path)
|
|
205
|
+
declared = _declared_return_literals(func)
|
|
206
|
+
if declared is None:
|
|
207
|
+
continue
|
|
208
|
+
confidence, literals = declared
|
|
209
|
+
missing = literals - set(ends)
|
|
210
|
+
for value in sorted(missing):
|
|
211
|
+
findings.append(
|
|
212
|
+
Finding(
|
|
213
|
+
rule="LG003",
|
|
214
|
+
node=source,
|
|
215
|
+
message=(
|
|
216
|
+
f"router can return {value!r}, which has no matching "
|
|
217
|
+
f"entry in the path_map ({sorted(ends)}) -- this raises "
|
|
218
|
+
f"KeyError at runtime the first time this branch is taken"
|
|
219
|
+
),
|
|
220
|
+
severity=Severity.HIGH if confidence == "type" else Severity.MEDIUM,
|
|
221
|
+
caveat=(
|
|
222
|
+
""
|
|
223
|
+
if confidence == "type"
|
|
224
|
+
else "Detected via best-effort source scan, not a type "
|
|
225
|
+
"annotation -- verify the router can truly return this."
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
# The reverse direction: a path_map entry the router's own
|
|
230
|
+
# declared type can never produce is dead routing code, not a
|
|
231
|
+
# crash risk. Only trusted when confidence is "type", since the
|
|
232
|
+
# AST heuristic can under-count literals and would false-positive.
|
|
233
|
+
if confidence == "type":
|
|
234
|
+
unreachable = set(ends) - literals
|
|
235
|
+
for key in sorted(unreachable):
|
|
236
|
+
findings.append(
|
|
237
|
+
Finding(
|
|
238
|
+
rule="LG005",
|
|
239
|
+
node=source,
|
|
240
|
+
message=(
|
|
241
|
+
f"path_map branch {key!r} -> {ends[key]!r} is declared "
|
|
242
|
+
f"but the router's return type never produces {key!r}"
|
|
243
|
+
),
|
|
244
|
+
severity=Severity.LOW,
|
|
245
|
+
caveat=(
|
|
246
|
+
"Dead routing code, not a crash risk -- "
|
|
247
|
+
"safe to leave if intentional."
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
return findings
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _declared_return_literals(func: Any) -> tuple[str, set[str]] | None:
|
|
255
|
+
"""Return (confidence, literal-values) the router's return type allows.
|
|
256
|
+
|
|
257
|
+
``"type"`` confidence comes from an explicit ``Literal[...]`` return
|
|
258
|
+
annotation and is close to certain. ``"ast"`` confidence comes from
|
|
259
|
+
scanning ``return "..."`` statements in the source and is a heuristic
|
|
260
|
+
lower bound -- it can both miss dynamic returns and, in principle, list
|
|
261
|
+
a value some other branch makes unreachable.
|
|
262
|
+
"""
|
|
263
|
+
try:
|
|
264
|
+
hints = typing.get_type_hints(func)
|
|
265
|
+
except Exception: # forward refs, missing imports, etc.
|
|
266
|
+
hints = {}
|
|
267
|
+
ret = hints.get("return")
|
|
268
|
+
args = getattr(ret, "__args__", None)
|
|
269
|
+
if args and all(isinstance(a, str) for a in args):
|
|
270
|
+
return "type", set(args)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
source = textwrap.dedent(inspect.getsource(func))
|
|
274
|
+
tree = ast.parse(source)
|
|
275
|
+
except (OSError, TypeError, SyntaxError):
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
literals: set[str] = set()
|
|
279
|
+
for node in ast.walk(tree):
|
|
280
|
+
if not isinstance(node, ast.Return) or node.value is None:
|
|
281
|
+
continue
|
|
282
|
+
literals.update(_string_constants(node.value))
|
|
283
|
+
return ("ast", literals) if literals else None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _string_constants(expr: ast.expr) -> set[str]:
|
|
287
|
+
"""Best-effort: collect string literals an expression could evaluate to.
|
|
288
|
+
|
|
289
|
+
Handles the common ``a if cond else b`` and ``a or b`` shapes directly;
|
|
290
|
+
anything else (f-strings, variables, function calls) is simply not
|
|
291
|
+
counted, which only makes this heuristic under-report, never over-report.
|
|
292
|
+
"""
|
|
293
|
+
if isinstance(expr, ast.Constant) and isinstance(expr.value, str):
|
|
294
|
+
return {expr.value}
|
|
295
|
+
if isinstance(expr, ast.IfExp):
|
|
296
|
+
return _string_constants(expr.body) | _string_constants(expr.orelse)
|
|
297
|
+
if isinstance(expr, ast.BoolOp):
|
|
298
|
+
found: set[str] = set()
|
|
299
|
+
for value in expr.values:
|
|
300
|
+
found |= _string_constants(value)
|
|
301
|
+
return found
|
|
302
|
+
return set()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Expose langgraph-lint as a tool for LangChain/LangGraph agents.
|
|
2
|
+
|
|
3
|
+
Requires the ``agent`` extra::
|
|
4
|
+
|
|
5
|
+
pip install "langgraph-lint[agent]"
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from langchain_core.tools import tool
|
|
15
|
+
except ImportError as exc:
|
|
16
|
+
raise ImportError(
|
|
17
|
+
"langgraph-lint's agent integration needs langchain-core. "
|
|
18
|
+
'Install it with: pip install "langgraph-lint[agent]"'
|
|
19
|
+
) from exc
|
|
20
|
+
|
|
21
|
+
from langgraph_lint.cli import _load_target
|
|
22
|
+
from langgraph_lint.core import lint
|
|
23
|
+
from langgraph_lint.models import Severity
|
|
24
|
+
|
|
25
|
+
MAX_RESULTS = 50
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@tool
|
|
29
|
+
def check_langgraph_graph(target: str, min_severity: str = "medium") -> str:
|
|
30
|
+
"""Check a LangGraph graph for wiring bugs it won't catch itself.
|
|
31
|
+
|
|
32
|
+
Finds nodes with no path from START (dead code) and conditional router
|
|
33
|
+
functions whose declared return type includes a value missing from their
|
|
34
|
+
path_map -- that one is a KeyError waiting to happen on whichever input
|
|
35
|
+
first takes that branch.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
target: The graph to check, as "module.path:attribute", e.g.
|
|
39
|
+
"myapp.graph:app". The module must be importable from the
|
|
40
|
+
current working directory.
|
|
41
|
+
min_severity: One of "high", "medium", "low". Defaults to "medium".
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
JSON with the finding count and details. Every finding carries a
|
|
45
|
+
"caveat" -- read it before reporting anything as a confirmed bug;
|
|
46
|
+
dynamic patterns like Send()-based fan-out can look identical to a
|
|
47
|
+
mistake from static structure alone.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
graph_like = _load_target(target)
|
|
51
|
+
except (ValueError, ImportError, AttributeError) as exc:
|
|
52
|
+
return json.dumps({"error": str(exc)})
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
severity = Severity(min_severity.lower())
|
|
56
|
+
except ValueError:
|
|
57
|
+
return json.dumps(
|
|
58
|
+
{"error": f"min_severity must be high, medium or low; got {min_severity!r}"}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
findings = lint(graph_like)
|
|
63
|
+
except TypeError as exc:
|
|
64
|
+
return json.dumps({"error": str(exc)})
|
|
65
|
+
|
|
66
|
+
order = {Severity.HIGH: 0, Severity.MEDIUM: 1, Severity.LOW: 2}
|
|
67
|
+
threshold = order[severity]
|
|
68
|
+
findings = [f for f in findings if order[f.severity] <= threshold]
|
|
69
|
+
capped = findings[:MAX_RESULTS]
|
|
70
|
+
|
|
71
|
+
return json.dumps(
|
|
72
|
+
{
|
|
73
|
+
"total_findings": len(findings),
|
|
74
|
+
"returned": len(capped),
|
|
75
|
+
"truncated": len(capped) < len(findings),
|
|
76
|
+
"findings": [f.as_dict() for f in capped],
|
|
77
|
+
},
|
|
78
|
+
indent=2,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_tools() -> list[Any]:
|
|
83
|
+
"""Return the langgraph-lint tool for binding to an agent."""
|
|
84
|
+
return [check_langgraph_graph]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
SYSTEM_PROMPT = """You audit LangGraph graphs for wiring defects.
|
|
88
|
+
|
|
89
|
+
Rules:
|
|
90
|
+
1. Every finding has a "caveat". Read it -- Send()-based fan-out and
|
|
91
|
+
human-in-the-loop interrupts are legitimate patterns that can resemble
|
|
92
|
+
a defect from structure alone.
|
|
93
|
+
2. A HIGH severity dangling-conditional-branch finding (LG003, "type"
|
|
94
|
+
confidence) is close to certain: it comes from the router's own type
|
|
95
|
+
annotation, not a guess.
|
|
96
|
+
3. Explain *why* each finding matters in terms of what would go wrong at
|
|
97
|
+
runtime, not just that a rule fired.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_graph(model: Any, system_prompt: str | None = None) -> Any:
|
|
102
|
+
"""Build a prebuilt agent wired to the langgraph-lint tool.
|
|
103
|
+
|
|
104
|
+
Prefers ``langchain.agents.create_agent`` and falls back to the older
|
|
105
|
+
``langgraph.prebuilt.create_react_agent`` if only ``langgraph`` (not
|
|
106
|
+
``langchain``) is installed.
|
|
107
|
+
"""
|
|
108
|
+
prompt = system_prompt or SYSTEM_PROMPT
|
|
109
|
+
try:
|
|
110
|
+
from langchain.agents import create_agent
|
|
111
|
+
except ImportError:
|
|
112
|
+
pass
|
|
113
|
+
else:
|
|
114
|
+
return create_agent(model, get_tools(), system_prompt=prompt)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
from langgraph.prebuilt import create_react_agent
|
|
118
|
+
except ImportError as exc: # pragma: no cover
|
|
119
|
+
raise ImportError(
|
|
120
|
+
'build_graph needs langgraph. Install: pip install "langgraph-lint[agent]"'
|
|
121
|
+
) from exc
|
|
122
|
+
return create_react_agent(model, get_tools(), prompt=prompt)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["SYSTEM_PROMPT", "build_graph", "check_langgraph_graph", "get_tools"]
|
langgraph_lint/models.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Core data types.
|
|
2
|
+
|
|
3
|
+
Deliberately dependency-free: nothing here imports langgraph or langchain_core,
|
|
4
|
+
so importing this module never requires either to be installed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import enum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Severity(enum.StrEnum):
|
|
15
|
+
"""Confidence that a finding is a genuine wiring defect.
|
|
16
|
+
|
|
17
|
+
``HIGH`` -- provable from a type annotation or exact structural match.
|
|
18
|
+
``MEDIUM`` -- true under the analyzer's stated assumptions, which a
|
|
19
|
+
dynamic pattern (like ``Send``) could legitimately violate.
|
|
20
|
+
``LOW`` -- informational; rarely worth a maintainer's time alone.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
HIGH = "high"
|
|
24
|
+
MEDIUM = "medium"
|
|
25
|
+
LOW = "low"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
29
|
+
class Finding:
|
|
30
|
+
"""A single suspected wiring defect in a graph."""
|
|
31
|
+
|
|
32
|
+
rule: str
|
|
33
|
+
node: str
|
|
34
|
+
message: str
|
|
35
|
+
severity: Severity = Severity.MEDIUM
|
|
36
|
+
#: Why this might be intentional. Every rule here has a legitimate
|
|
37
|
+
#: counter-pattern (Send-based fan-out, human-in-the-loop interrupts,
|
|
38
|
+
#: dynamically computed router outputs) and pretending otherwise makes
|
|
39
|
+
#: the tool an alert-fatigue machine, not a useful one.
|
|
40
|
+
caveat: str = ""
|
|
41
|
+
|
|
42
|
+
def as_dict(self) -> dict[str, Any]:
|
|
43
|
+
return dataclasses.asdict(self)
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str:
|
|
46
|
+
return f"[{self.severity.upper()}] {self.rule} ({self.node}): {self.message}"
|
|
@@ -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,10 @@
|
|
|
1
|
+
langgraph_lint/__init__.py,sha256=hSxQk-vNtWFk7oOz21cKXvSB1SdAHY8MdksQYgPKBKM,298
|
|
2
|
+
langgraph_lint/cli.py,sha256=OjPPVVeCWTEeXibRUUk5dCnhox26PMds7BVJFuRiwfE,3440
|
|
3
|
+
langgraph_lint/core.py,sha256=CT0T_rq1_-Gw-W0GIT70k1vaDZ4BlPofT3Mphes-R6o,11633
|
|
4
|
+
langgraph_lint/integrations.py,sha256=R9aXBEgm2DOwIUeMOGjj4RjAh4m0lhLnqVbc908nea4,4147
|
|
5
|
+
langgraph_lint/models.py,sha256=qd00S_ubYzRmInpaewzi-4uRHX0objjtzvYiEk-1-jQ,1426
|
|
6
|
+
langgraph_lint-0.1.0.dist-info/METADATA,sha256=3Hn73AwPiz81bI4GBfuzSh5cLxwc98Fquymal1FxBTE,7262
|
|
7
|
+
langgraph_lint-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
8
|
+
langgraph_lint-0.1.0.dist-info/entry_points.txt,sha256=w7emlh2zGmJ_d2dLrO2H_GoelFi2j_8Sps4rzsTj20s,59
|
|
9
|
+
langgraph_lint-0.1.0.dist-info/licenses/LICENSE,sha256=3TnGsRZT9l6u_Gy8bLyT7rkaHjxEPkLAcPQw-lJj214,1069
|
|
10
|
+
langgraph_lint-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|