graphlint
Your agent graph has bugs you can find without running it.
A barrier where a stream would do. Three verifiers that fail identically. A cycle that never converges. All of it is visible in the spec — the cheapest possible place to catch it.
- alpha
- 16 rules
- 57 tests passing
- MIT
Install
Zero config, zero tokens
$ npm i -D graphlint
$ npx graphlint check .claude/workflows/
.claude/workflows/review.js (script)
✗ This `parallel()` barrier's result is only reshaped, never compared across items. unjustified-barrier
24 │ const found = await parallel(DIMENSIONS.map((d) => () => agent(d.prompt)))
│ ~
fix Use `pipeline(items, stage1, stage2)` and put the reshape inside a stage.
✗ 3 verifiers spawned from one factory with no varying lens, model or index. correlated-verifiers
31 │ agent(`Verify this finding is real. Answer yes or no.`),
│ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
fix Vary the lens — three different questions beat three identical ones, and it is free.
2 errors · 3 warnings in 1 file
It is a static analyzer. It never spawns an agent, never calls a model, and costs nothing to run. Node 20+.
Inputs
Both shapes a graph comes in
Workflow scripts
agent(), parallel(), pipeline(), phase(), export const meta. Parsed with a real JS parser rather than regexes, because almost every interesting property is contextual — whether a call sits inside a cycle, which pipeline stage it belongs to, whether its result is consumed without a schema.
Declarative graph specs
.graph.json, .spec.json — nodes and edges as data. Structural checks apply here too: unreachable nodes, cycles with no declared cap, barrier: true with no barrierReason.
Both front-ends compile to the same intermediate representation, and every rule reads only that. It is why a rule written for one input shape works on the other.
A bare directory is scanned for .graph.json, .spec.json, anything under a workflows/ directory, and scripts that actually call agent(). It does not lint every .js file in your repo.
Rules
16 rules, each with a reason
Every finding carries both the reasoning and the fix. A linter that says don't without saying because gets disabled by the second week.
Errors — costs money or does not terminate
| Rule | Catches |
|---|---|
agent-as-reduce |
An agent spawned to combine, merge or dedupe results. That is an edge, not a node — flatMap and a Set do it for zero tokens. |
correlated-verifiers |
N identical verifiers. Three skeptics sharing a model and a prompt scaffold are one check at 3× the price. |
unbounded-cycle |
A cycle spawning agents with no dry-round counter or no hard cap. |
resurfacing-cycle |
A cycle deduping against confirmed results instead of everything seen, so rejected findings resurface forever. |
null-unsafe-fanin |
A fan-in that assumes a full result set. One failed node then kills a run that had 8 of 9 results. |
nondeterminism |
Date.now(), Math.random(), argless new Date() — each invalidates the resume cache and turns a crash into a full re-spend. |
unisolated-writer |
Parallel agents editing files without isolation: "worktree". Fails silently: the tree compiles and is wrong. |
spec-structure |
Unreachable nodes and uncapped cycles in a declarative spec. |
Warnings — will cost you at scale
| Rule | Catches |
|---|---|
unjustified-barrier |
A parallel() whose result is only reshaped, or barrier: true with no barrierReason. |
missing-schema |
Free text consumed downstream instead of a validated contract. |
untiered-graph |
No model or effort set anywhere, so every node inherits the session model. |
silent-cap |
A truncated work list with no log — the report reads as full coverage. |
missing-meta |
No export const meta, so the run is unnamed and its phases ungrouped. |
Info — worth a look
| Rule | Catches |
|---|---|
conjunction-prompt |
A prompt asked to scan and write. Two verbs, two nodes. |
phase-mismatch |
phase() titles and meta.phases disagree. |
uniform-verification |
Every finding verified equally hard, with no severity threshold. |
$ graphlint explain correlated-verifiers
Why
These graphs fail in a small number of ways
- A barrier where a stream would do.
parallel()waits for the slowest item;pipeline()does not. Same agents, same work, strictly more waiting. - Verifiers that share a model, a temperature and a prompt scaffold. They fail identically, so "3 of 3 agree" can be one error counted three times — at triple the cost.
- A cycle deduping against the wrong set. Rejected findings resurface every round, so the dry counter never trips and the loop never converges.
- Free text crossing an edge that a downstream node has to parse.
None of these need a run to find. They are visible in the spec — before a token is spent, in the same pass as your other linters.
It will produce false positives on unusual graphs. Barriers especially: a barrier that is load-bearing for a reason graphlint cannot see reads as unjustified. Turn the rule off per-repo in .graphlintrc.json rather than working around it.
It also does not estimate cost (that is preflight) and does not measure verifier independence from real runs (that is decorrelate).
CI
Findings in the Security tab
- run: npx graphlint check .claude/workflows --format sarif > graphlint.sarif
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: graphlint.sarif }
Exit code is 1 if any error fired or warnings exceed --max-warnings, 0 otherwise. --format json for your own tooling.
The family