Inspect & lint flows
@botmesh/core can describe the flows a bot has registered, as a Mermaid diagram or a JSON graph, and check them statically for common mistakes. Nothing is sent and the bot does not need to be started.
bot.describe(): Mermaid diagram or JSON graph
ts
import { writeFileSync } from 'node:fs'
import { Bot, flow, describeFlows } from '@botmesh/core'
bot.use(
flow('register')
.timeout('2m')
.ask('Name?', 'name')
.choose('Plan?', { pro: 'Pro', free: 'Free' })
.goto('support'),
)
bot.conversation('support', async (c, ctx) => { /* ... */ })
writeFileSync('flows.mmd', bot.describe()) // Mermaid (default): paste it in mermaid.live or a ```mermaid block
const graph = bot.describe({ format: 'json' }) // { version: 1, flows, groups, nodes, edges }, plain JSON data
bot.describe({ direction: 'LR', maxLabelLength: 40 })
describeFlows([flowA, flowB]) // without a Bot: a list of FlowBuilder/CompiledFlowWhat it draws:
- One
subgraphper DSL flow. The entry node (▶) lists every trigger that starts it (bot.use,bot.when(trigger, flow), abot.conversationtrigger). Withbot.when(trigger, flow)the flow's own trigger is not routed, and the diagram shows that. say,ask(→ state key, ⏱ when the step has its own timeout,✓schemawhen it validates with aschema),do(with the function name if it has one:do: saveUser()),media(with its kind:media (document): ...),choose(a diamond; options are arrows labelled1) Label, like the menu the user sees),form(its fields and state key),guard(guard: isAdmin(), with✗→ targetwhen it redirects),use(the sub-flow drawn inline, returning to the next step),delay(.delay()steps; a{ delay }option is appended to its step's label),goto(a dotted arrow to the target flow) andend.- An option's
then:is its own node when it is an action, or a nested subgraph that returns to the parent's next step when it is a sub-flow. An option'sgotois an arrow to its target flow. - The flow's
.timeout()and.persistent()go in the subgraph title. - A
gototo a flow that does not exist is drawn as amissing: namenode. - Steps after an unconditional
gotohave no incoming arrow (visible dead code). - Async conversations (
bot.conversation(name, fn)) are opaque: one node with their triggers. - User text is escaped with Mermaid entity codes, so it cannot break a label or inject
%%{init}%%directives. Node ids (f0_in,f0_s3,f0_end...) are stable and the same in Mermaid, JSON and lint results.
bot.lint(): static checks
ts
for (const d of bot.lint({ minSeverity: 'warning' })) {
bot.logger.warn({ code: d.code, flow: d.flow, path: d.path }, d.message)
}
if (bot.lint().some((d) => d.severity === 'error')) process.exitCode = 1 // CI gate
bot.lint({ rules: { 'missing-timeout': false } }) // turn a rule off
lintFlows([flowA, flowB]) // without a BotEach diagnostic is { code, severity, message, flow?, nodeId?, path?, trigger? }, sorted from most to least severe.
| Code | Severity | What it detects |
|---|---|---|
goto-target-missing | error | goto('x'), a .guard() redirect or a choose option goto to something not registered (also goto(builder) of a regex-triggered flow without .id(), whose id changes on every compile) |
invalid-timeout | error | a step timeout that cannot be parsed ('2 minutos') |
empty-choice | error | choose() with no options |
unreachable-step | warning | steps after an unconditional goto (or after a choose whose options all jump away) |
unreachable-flow | warning / info | no trigger can start it and no reachable flow jumps to it. Info when reachable code could still enter it with c.goto() |
empty-trigger | warning / info | flow([]) never matches; '' matches messages with no text (or everything with contains/startsWith/endsWith); an empty regex. Info if the flow is only a goto target |
duplicate-trigger | warning | a route never gets a pattern because an earlier one in the real dispatch order (specificity, then registration) takes it; respects scope and match strategy |
flow-id-collision | warning | two flows/conversations with the same id (e.g. flow('hola') and flow(['Hola', 'hi'])): only the last one runs |
persistent-memory-store | warning | .persistent() with a MemoryStore without file (checkpoints die on restart) |
duplicate-choice | warning | options with the same key or normalized label |
ambiguous-choice | warning | a numeric key/label out of position ({ 0: 'Zero', 1: 'One' }: typing 1 picks '0'), or a label equal to another option's key |
form-state-collision | warning | a .form() stores its answers under a c.state key that another ask/choose/form of the same flow also writes |
missing-timeout | info | ask/choose with no timeout and no conversation.timeout: the 5-minute fallback applies. Also notes that then: sub-flows do not inherit the parent's .timeout() |
wait-never-expires | info | timeout 0: the wait never expires |
The timeout checks (invalid-timeout, missing-timeout, wait-never-expires) also cover .form() steps. missing-timeout fires on most small bots by design; use minSeverity: 'warning' for a quieter report.
Lower-level helpers
describeFlows(source, options)andlintFlows(source, options)accept aBot, aFlowInventoryor an array of flows.bot.flowInventory()returns the snapshot they work on (registrations, routes, the default timeout and whether the store is durable) for your own tooling.MemoryStore#durableistruewhen it has afile.describeTrigger(trigger)returns{ label, patterns, match, scope, predicate }.