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.
@@ -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"]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ langgraph-lint = langgraph_lint.cli:main
@@ -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.