Unchained Labs

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
Real output. 16 rules, zero tokens, no network.

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