Static analysis · Convex · Built for agents

Your agent ships faster with Convex. Is it wasting time on dumb mistakes?

The deterministic check on agent-written Convex: validator drift, table-scanning queries, dead functions. One pass before deploy — every finding with a fix recipe your agent can apply.

Run it yourself
$ bunx @fagnersales/convex-doctor
convex-doctor · one pass
$ bunx @fagnersales/convex-doctor

  convex-doctor

  3 functions will throw ReturnsValidationError.

  ✖ 3 errors   ⚠ 15 warnings   ℹ 6 info · 48 fns · 883ms

  ✖ MISSING_FIELD · validator omits a schema field
     users.ts:31 · getUser

     30 │   returns: v.object({
     31 │     name: v.string(),
          ^^^^ schema `users` also requires `email`
     32 │   }),

     why  the first real row returned fails validation
          at runtime — typecheck never sees it.
     fix  add email: v.string() to the validator.

     docs https://docs.convex.dev/functions/validation
2+1
Analysis engines
drift · lints · dead code
30+
Doc-grounded rules
every one deep-links its source
0
Config files
no ESLint install, no setup
1
Command · one pass
text for you · JSON for agents
01 · Drift engine

Typecheck passes.
Runtime throws.

Your agent adds the field, ships the feature — and never touches the returns validator. TypeScript can't see it; Convex throws at runtime. convex-doctor diffs schema → validator → every return path — through joins, spreads, .paginate() envelopes, and cross-file imports.

Missing_field Stale_field Null_branch_missing Optionality_mismatch Cardinality_mismatch Type_mismatch Extra_literal_field Missing_literal_field
convex/schema.tsthe truth
users: defineTable({
  name: v.string(),
  email: v.string(),   // added last sprint
})
convex/users.tsthe promise
export const getUser = query({
  args: { id: v.id("users") },
  returns: v.object({
    name: v.string(),
    // email? never updated —
    // throws on the first real row
  }),
  handler: (ctx, { id }) => ctx.db.get(id),
})
02 · Best-practice lints

The whole guide,
enforced.

The best-practices guide, the official eslint-plugin, and the rules they don't ship — one pass, no install, no config. Every diagnostic: why, fix, doc link.

Filter_in_querywarn
.filter() on a db query scans every row and discards in memory. Use .withIndex — or filter in plain TypeScript.
Await_in_loopwarn
await ctx.db.* inside a for-loop is sequential round-trips. Parallelize with Promise.all. Pagination cursors are exempt.
Unbounded_collectwarn
.collect() with no index narrowing can load the whole table. Bound it with .withIndex, .take(n), or .paginate().
Fetch_in_queryerror
The V8 isolate has no fetch — a query that calls it throws, every time. That code belongs in an action.
Db_in_actionerror
ActionCtx has no ctx.db. Reads and writes go through ctx.runQuery / ctx.runMutation.
Nondeterministic_querywarn
Date.now() inside a query freezes in the reactive cache — the value never updates with wall-clock time.
Schedule_public_fnwarn
Schedulers and crons should call internal.* — a public api.* function is reachable by any client.
Floating_ctx_promisewarn
An un-awaited ctx.* write or schedule may never run — and its errors are swallowed silently.
Missing_arg_validatorwarn
A public function with no args validator lets client input reach the handler unchecked.
Query_in_node_fileerror
A query in a "use node" file can't run in the V8 isolate — Convex rejects the deploy outright.
Duplicate_cron_iderror
Two cron jobs registered under one identifier — another deploy-time rejection caught at your desk instead.
Redundant_indexwarn
by_a is dead weight when by_a_b exists — a prefix index duplicates what the longer one already covers.
+ Wrong_runtime_import Node_builtin_without_use_node Misplaced_use_node Sequential_ctx_run Cron_public_fn Old_function_syntax Schema_validation_disabled
03 · Dead-function detection

Dead means unreachable,
not just unreferenced.

--dead builds a project-wide call graph and lists every function no caller reaches. Dead callers grant no life — an orphaned entry point drags its whole helper cluster with it.

  • api.* / internal.* chains — resolved through barrel re-exports.
  • String function names"path/file:fn" literals count as callers.
  • Self-calls don't count — a migration re-scheduling itself is not "alive".
  • Keep comments — document why an entry point stays, next to the code.
convex/migrations.tskeep what ops needs
// convex-doctor: keep — run manually by ops
// during incident cleanup
export const requeueStuckJobs =
  internalMutation({ ... })
output--dead-only
✖ dead       posts.ts:88 · legacyFeed
✖ dead       posts.ts:41 · hydrateFeed
             (referenced only by dead code)
✓ kept       migrations.ts:12 · requeueStuckJobs
             keep — run manually by ops…
04 · Agentic usage

Built to be driven
by an agent.

Don't dump 455 issues into a context window — work one rule code at a time: lock a group, fix every site, re-scan, commit. The loop converges to zero.

agent-prompt.txt · paste into your agent of choice
Run: bunx @fagnersales/convex-doctor groups --json
Fix one rule code at a time:
1. lock a group: bunx @fagnersales/convex-doctor --only <CODE> --json
2. fix every site with the group's shared recipe
3. re-scan until the group reads zero, then commit
Repeat until the scan is clean.
Step 1 · The menu

List fixable groups

One line per rule code, errors first.

$ convex-doctor groups --json
Step 2 · The batch

Lock one group

The shared recipe once, then file · line · function per site.

$ convex-doctor --only AWAIT_IN_LOOP --json
Step 3 · The cursor

Re-scan to verify

Fixed sites vanish from the next run. Loop to zero, commit.

$ convex-doctor agent-guide
Every group tells the agent how hard to think
Mechanical

The diagnostic fully determines the edit. Safe to apply blind.

Guided

A deterministic recipe — read the local context first.

Manual

Cross-file or data-migration judgment. Maybe ask a human.

claude> /convex-fix

Install the /convex-fix skill — your agent runs the loop itself: audit, fix, verify, commit.

Quick start · CI-ready

Wire it into
typecheck.

No install, no config. Run it in any Convex project — make it part of every push with --json and --strict.

exit 0no errors — deploy away
exit 1errors found (or warnings, under --strict)
exit 2bad arguments
package.json
# inside any Convex project
$ bunx @fagnersales/convex-doctor

# non-default convex dir
$ bunx @fagnersales/convex-doctor \
    --convex-dir backend/convex

# make it part of every push
"scripts": {
  "doctor": "bunx @fagnersales/convex-doctor",
  "typecheck": "tsgo --noEmit && bun doctor"
}

# the full physical
$ bunx @fagnersales/convex-doctor \
    --dead --strict --json
✓ all green, no throws