eslint-plugin-botmesh catches in source code the mistakes no generic linter
knows about: v1 API that no longer exists, a blocking runtime.enter(), an
async plugin install(), empty triggers, waits without a timeout and per-user
state in module variables. It complements the runtime
bot.lint() / lintFlows(), which inspect the
registered flows (broken gotos, unreachable steps, duplicate triggers).
It needs ESLint 9 or later (flat config) and Node >= 22. The src/bot.js
of every create-botmesh
template passes the strict preset with no findings (the plugin’s tests
check it).
Install
Usage
typescript-eslint (its parser) and list the .ts
files, since ESLint only lints .js/.mjs/.cjs by default:
Presets
Rules
botmesh/no-v1-api
Flags the v1 / builderbot API that does not exist in v2, and names the
replacement in the message:
botmesh/no-await-runtime-enter
ConversationRuntime.enter() resolves when the conversation ends.
Awaiting it in a handler blocks the chat’s FIFO queue, so the message the
conversation is waiting for is never dispatched. The rule reports
await runtime.enter(...), return runtime.enter(...) (the dispatcher awaits
the returned promise), an arrow function whose body is runtime.enter(...),
and the same three forms with .catch(...) / .then(...) / .finally(...)
chained or with optional calls (await runtime?.enter(...)): the chained
promise still settles only when the conversation ends.
{ receiverPattern: string }, a regex source the receiver name must
match (default runtime$, case-insensitive).
botmesh/no-async-plugin-install
Plugin.install() must be synchronous: bot.plug() does not await it, so
the bot runs before an async install has finished, and its rejection only
reaches the 'error' event (older core versions crashed the process with an
unhandled rejection). TypeScript now rejects an async install at compile time
(PluginSpec.install returns void | SyncInstallError); the rule covers
JavaScript and as any escapes. It checks the object passed to definePlugin(...), any object
literal with both name and install, and classes with an install method
that declare a name field or implements Plugin. For async setup use the
lazy pattern (resolve on first use, report configuration errors from a
non-fatal warmup), like LazyProvider in @botmesh/ai.
No options.
botmesh/no-empty-trigger
An empty trigger ('', ' ', an empty template literal, [], [''],
{ pattern: '' }) never matches a real message, or, with contains, matches
every message. Checked in flow(...) / addKeyword(...), bot.when(...) and
the trigger option of bot.conversation(name, fn, { trigger }). The core
also throws a TypeError for an empty text pattern when it is registered; the
rule catches it (and []) before the code runs. flow([]) /
addKeyword([]) is allowed: it is the idiom for a sub-flow with no trigger of
its own (run from a choose then, .use() or a goto); bot.lint() reports
the ones nothing reaches.
No options.
botmesh/require-timeout-on-ask
Every wait for user input should set its timeout on purpose. Without one, the
wait falls back to the bot’s conversation.timeout, else the built-in 5
minutes, which rarely suits both a long form and a quick yes/no. A timeout of
0 (or '0s') disables it: a user who stops answering keeps the conversation
open, capturing their messages, until a command such as /reset. The rule
reports both.
- Flow DSL:
.ask(text, key, { timeout }),.choose(prompt, options, key, { timeout }),.form(definition, { timeout }), or a.timeout(...)anywhere on the sameflow(...)chain (unlessallowFlowTimeout: false). - Conversations:
c.ask(text, { timeout })and, when listed inmethods,c.waitText({ timeout }),c.waitMedia(types, { timeout }),c.waitLocation({ timeout }),c.choose(prompt, options, { timeout })andc.waitAny({ timeoutMs }).waitAnytakes milliseconds intimeoutMsand ignores atimeoutkey, so the rule reportsc.waitAny({ timeout })too. - When the options argument is not an object literal (a variable, a call) or contains a spread, the rule cannot see inside it and stays quiet.
conversation.timeout, turn the rule
off instead of repeating it on each step.
Options: { methods?: ('ask' | 'waitText' | 'waitMedia' | 'waitLocation' | 'choose' | 'waitAny' | 'form')[], allowFlowTimeout?: boolean }
(defaults ['ask'] and true).
botmesh/prefer-ctx-user-over-global-state
Flags the v1 globalState and module-level Maps, Sets or objects keyed by
ctx.from, ctx.sender or ctx.senderId. Writes (users.set/delete(ctx.from),
seen.add(ctx.from), scores[ctx.sender] = …, scores[ctx.sender].n++) are always
reported; reads (users.get/has(ctx.from), scores[ctx.sender]) only when the
same binding is also written or reassigned inside a function. A constant
allow or deny list (const ADMINS = new Set([...]), if (blocked.has(ctx.from))),
even one filled at module level, holds no per-user state and is not reported.
Per-user state is lost on restart, grows forever and is not shared between
replicas. Use ctx.user
(await ctx.user.ns('feature').set(key, value)) for per-user data and
c.state for per-conversation data.
Short-lived per-process data that should not survive a restart, such as a
rate-limit window, is a deliberate exception: disable the rule on that line
(// eslint-disable-next-line botmesh/prefer-ctx-user-over-global-state).
Option: { contextNames: string[] }, the names of the handler context
parameter (default ['ctx']).
API
rules (rules['no-v1-api']); they are
not named exports. Each rule’s meta.docs.url points at its section of this
page, so editors link a finding to its explanation.