Static Analysis
TL;DR
Use awaitly-analyze to extract workflow structure from TypeScript, then render Mermaid/JSON/HTML artifacts for docs, review, and CI checks without running workflows.
When To Use
- You want architecture diagrams from source code, not runtime traces.
- You need deterministic workflow docs in CI.
- You want workflow diffs in PR review.
- You need rule-style diagnostics with stable slug codes.
Static Analysis Pipeline
Analyze Source
Parse workflow file and extract IR tree, stats, and references.
Render Artifact
Generate Mermaid, JSON, markdown report, or interactive HTML.
Apply In Workflow
Use output in docs, PRs, CI gates, and architecture reviews.
Deterministic diagrams from real code
Section titled “Deterministic diagrams from real code”awaitly-analyze reads executable TypeScript and derives the diagram from the code that runs.
Imperative control flow (if, for, computed step ids) produces diagrams with unstable structure. Use declarative control flow for steps that need stable diagram nodes:
- Use
step.if,when, orunlessfor branches that contain steps. Branches keep stable, labelled ids. - Use
step.forEachfor loops. Each iteration gets a structured id. - Use literal step ids and put dynamic parts in
key. Each node keeps a stable identity.
The analyzer reads native if/else, for...of, and while blocks. It derives a branch id from the condition and a loop id from the iterable, so plain TypeScript produces deterministic diagrams without a DSL. Run --assert-diagrammable in CI. It flags conditions and iterables that require runtime values, such as a call result or computed member. Hoist the value into a named variable to give the graph a stable identity.
See Control flow and diagrammability for the workflow authoring patterns.
Fast start
Section titled “Fast start”The npm package is awaitly-analyze (not awaitly-analyzer). Point it at any TypeScript file that contains run(), createWorkflow(), runSaga(), or durable.run() — a test file works if the run() call is in the source.
The default command writes <basename>.workflow.md next to the source and prints the diagram. Bound steps such as s.getUser() show the dep’s Result errors (NOT_FOUND, FETCH_ERROR) as -->|err| branches. --types is opt-in and writes <workflowName>.types.ts. --html writes <basename>.html and still prints the diagram.
When you omit --format and --railway, the CLI picks the diagram: railway (flowchart LR) for linear happy paths, Mermaid flowchart for branches, loops, parallel, and race. Pass --format=mermaid or --railway to lock the choice.
# Writes checkout.workflow.md and prints the diagramnpx awaitly-analyze ./src/workflows/checkout.ts
# Print onlynpx awaitly-analyze ./src/workflows/checkout.ts --no-output-adjacent
# Force a Mermaid flowchart (direction TB unless you set --direction)npx awaitly-analyze ./src/workflows/checkout.ts --format=mermaid
# JSON (also writes checkout.workflow.json)npx awaitly-analyze ./src/workflows/checkout.ts --format=json
# Interactive HTML next to the sourcenpx awaitly-analyze ./src/workflows/checkout.ts --html
# Generate .types.tsnpx awaitly-analyze ./src/workflows/checkout.ts --types
# Diff two existing files (not placeholders — both paths must exist)npx awaitly-analyze --diff before.ts after.ts
# Strict diagnosticsnpx awaitly-analyze ./src/workflows/checkout.ts --doctor
# CI gate: fail if a workflow has unstable graph identitiesnpx awaitly-analyze ./src/workflows/checkout.ts --assert-diagrammable
# Overlay a recorded run (JSON array of WorkflowEvent from onEvent)npx awaitly-analyze ./src/workflows/checkout.ts --trace=./run-events.json
# Live inspector: static graph + runtime trace overlay in the browsernpx awaitly-analyze ./src/workflows/checkout.ts --devnpx awaitly-analyze ./src/workflows/checkout.ts --dev --port=5000What the analyzer reads
Section titled “What the analyzer reads”- Workflow calls:
createWorkflow(...),run(deps, fn),run(fn),runSaga(...), anddurable.run(deps, fn, options)— including through an import alias, and whether the workflow is invoked with.run()or.runWithState(). - Both deps-first callback shapes: bound steps (
run(deps, async (s) => s.loadBatch())) and the workflow form (run(deps, async ({ step, deps }) => step('loadBatch', ...))). - Step IDs from
step('id', fn, opts)as canonical names. - Workflow and step docs from
descriptionandmarkdown. - JSDoc descriptions as
jsdocDescriptionfallback. - Optional step metadata:
intent,domain,owner,tags,stateChanges,emits,calls,errorMeta.
Follow one checkout through the analyzer
Section titled “Follow one checkout through the analyzer”This checkout has a free-order branch and a paid branch. The payment dependency carries a timeout and retry policy. The receipt dependency has a fallback, so RECEIPT_FAILED does not reach the final error union.
import { fallback, ok, retry, run, timeout } from 'awaitly';import { charge, loadCart, sendReceipt } from './checkout-deps';
export const checkout = (cartId: string) => run( { loadCart, charge: retry(timeout(charge, 2_000), { attempts: 3, delay: 100, backoff: 'exponential', }), sendReceipt: fallback(sendReceipt, () => ok({ queued: true })), }, async (steps) => { const cart = await steps.loadCart(cartId);
if (cart.total === 0) { return { kind: 'free' as const, cart }; }
const payment = await steps.charge(cart.total); const receipt = await steps.sendReceipt(cart.email); return { kind: 'paid' as const, cart, payment, receipt }; }, );Use this file to answer four review questions without charging a card.
Check the resilience policy
Section titled “Check the resilience policy”Read the dependency facts from JSON:
npx awaitly-analyze ./checkout.ts --format=json --no-types \ | jq '.workflows[0].root.dependencies[] | { name, errorTypes, policies }'The charge record contains the timeout followed by the retry wrapper. The sendReceipt record shows a fallback. Reviewers can inspect the exact policy chain without tracing through wrapper types or running the workflow.
Generate tests from both branches
Section titled “Generate tests from both branches”import { analyze, formatTestMatrixMarkdown, generatePaths, generateTestMatrix,} from 'awaitly-analyze';
const workflow = analyze('./checkout.ts').single();const paths = generatePaths(workflow);const matrix = generateTestMatrix(paths, {}, workflow);
console.log(formatTestMatrixMarkdown(matrix));The matrix includes the cart.total === 0 path and the paid path through charge and sendReceipt. Pass the workflow IR as the third argument to rank paths using errorMeta when your steps classify business and infrastructure failures.
Block diagram drift in CI
Section titled “Block diagram drift in CI”- name: Check workflow diagrams run: | pnpm exec awaitly-analyze src/workflows/checkout.ts \ --doctor --format=json --no-types pnpm exec awaitly-analyze src/workflows/checkout.ts \ --assert-diagrammable --no-typesThe first command reports source-level diagnostics, including missing ids and unmodeled awaits. Each diagnostic includes a location. The second command returns a non-zero exit code when the analyzer cannot assign stable identities to the graph.
Review the executed path after a failure
Section titled “Review the executed path after a failure”Capture WorkflowEvent[] from onEvent, save the array as run-events.json, and overlay it:
npx awaitly-analyze ./checkout.ts \ --trace=./run-events.json --suffix=failed-runThe output keeps both checkout branches visible and marks the steps from that run. unmatched trace ids expose runtime steps that have no node in the static graph. Each step.forEach iteration is its own node on the overlay, so a resume run shows cache-hit iterations next to the one that ran.
Browse Analyzer Showcase for source and generated diagrams covering retries, resources, loops, workflow composition, and sagas.
CLI reference
Section titled “CLI reference”Core flags
Section titled “Core flags”| Flag | Default | Description |
|---|---|---|
--format=<fmt> |
auto, else mermaid |
mermaid or json for a single file. markdown only with --diff. Omit --format to auto-pick railway vs Mermaid. |
--direction=<dir> |
TB (LR when auto-railway) |
TB, TD, LR, BT, RL |
--railway |
off | Linear happy-path + ok/err branching |
--keys |
off | Show step cache keys |
--errors / --no-errors |
on | Show/hide error nodes |
--html |
off | Generate interactive HTML artifact |
--html-output=<path> |
auto | Write HTML to custom path |
Output control
Section titled “Output control”| Flag | Default | Description |
|---|---|---|
--types / --no-types |
off | Generate .types.ts (input, output, and the error union) |
--test / --no-test |
off | Generate test stubs |
--test-runner=<runner> |
vitest |
vitest, jest, mocha |
-o, --output-adjacent |
on | Write <basename>.workflow.md (or .json) next to the source |
--no-output-adjacent |
off | Print only; skip the adjacent diagram file |
--suffix=<value> |
workflow |
Adjacent output file suffix |
--no-stdout |
off | Suppress stdout when a file is being written |
--dsl-output=<value> |
off |
Write DSL output |
--write-dsl |
off | Same as --dsl-output=.awaitly |
--watch |
off | Re-analyze on source changes |
Diagrammability and trace
Section titled “Diagrammability and trace”| Flag | Default | Description |
|---|---|---|
--assert-diagrammable |
off | Exit non-zero if a workflow has unstable graph identities |
--trace=<path> |
- | Overlay a recorded run’s executed path onto the static Mermaid diagram |
--dev |
off | Serve a live workflow inspector at http://localhost:4747 (SSE + runtime trace overlay) |
--port=<n> |
4747 |
Port for --dev (use with --dev) |
Live inspector
Section titled “Live inspector”--dev starts a local HTTP server that serves the static workflow diagram for a file, watches the source for changes, and accepts runtime event streams from running workflows. Each run’s trace is overlaid on the static graph: the full shape stays visible while live runs paint their path.
npx awaitly-analyze ./src/workflows/checkout.ts --dev# → http://localhost:4747
npx awaitly-analyze ./src/workflows/checkout.ts --dev --port=5000Wire onEvent to stream runs into the inspector. Use devEvents from awaitly-visualizer (see Visualization →) or POST events yourself to /events. Updates use Server-Sent Events; no WebSocket server required.
Lock the diagram to reality
Section titled “Lock the diagram to reality”Pass a declared graph at workflow creation or per run so each step and decision id must appear in the graph. Awaitly rejects a runtime id that does not appear in the graph, which keeps execution aligned with the static diagram.
import { createWorkflow } from 'awaitly';
const workflow = createWorkflow('checkout', deps, { graph: ['fetchUser', 'chargeCard', 'premium-check'],});
// Plain id list or WorkflowDiagramDSL from awaitly-analyzeawait run(async ({ step }) => { /* ... */ }, { graph: ['fetchUser', 'item-{i}'], // {placeholder} matches item-0, item-1, ...});Pair with --assert-diagrammable in CI: the analyzer checks that source is diagrammable; graph enforces that runtime matches the declared shape. See Workflow options →.
Interactive HTML artifact
Section titled “Interactive HTML artifact”# Writes <basename>.html next to the source; still prints the diagram to stdoutnpx awaitly-analyze ./src/workflows/checkout.ts --html
# Custom pathnpx awaitly-analyze ./src/workflows/checkout.ts --html --html-output=./docs/checkout-diagram.htmlThe HTML output includes:
- Mermaid rendered client-side.
- Click-to-inspect node details.
- Built-in theme picker with persistence.
- Self-contained payload for easy sharing.
Programmatic API
Section titled “Programmatic API”import { analyze, renderStaticMermaid, renderStaticJSON, extractNodeMetadata, generateInteractiveHTML,} from 'awaitly-analyze';
const ir = analyze('./src/workflows/checkout.ts').single();
const mermaid = renderStaticMermaid(ir, { direction: 'TB', showKeys: false });const json = renderStaticJSON(ir, { pretty: true });
const metadata = extractNodeMetadata(ir);const html = generateInteractiveHTML(mermaid, metadata, { title: 'Checkout Workflow', direction: 'TB',});Diagrammability
Section titled “Diagrammability”Check whether each graph node has a stable identity. Each issue names the construct that closes the gap:
import { analyze, computeDiagrammability } from 'awaitly-analyze';
const ir = analyze('./src/workflows/checkout.ts').single();const report = computeDiagrammability(ir);
report.deterministic; // true when there are no gapsreport.score; // 0-100, share of nodes with a stable identityreport.issues; // [{ kind, message, suggestion, location, nodeId }]// kinds: 'dynamic-step-id' | 'dynamic-decision-id' | 'raw-conditional'// | 'raw-loop' | 'unbounded-loop' | 'unknown-node'Use --assert-diagrammable on the CLI to turn this into a CI gate.
Runtime trace overlay
Section titled “Runtime trace overlay”Render the static skeleton and highlight the path a real run took. Record events via the workflow onEvent option, then overlay them onto the static diagram:
import { analyze, traceFromEvents, renderStaticMermaidWithTrace,} from 'awaitly-analyze';
const ir = analyze('./src/workflows/checkout.ts').single();const trace = traceFromEvents(recordedEvents); // WorkflowEvent[] → per-step status
const { mermaid, matched, unmatched } = renderStaticMermaidWithTrace(ir, trace);// `unmatched` lists trace steps with no static node; empty when the workflow// has stable identities (literal step ids).Fluent selection helpers
Section titled “Fluent selection helpers”| Method | Returns | Throws | Best for |
|---|---|---|---|
.single() |
Single IR | If 0 or >1 workflows | Single-workflow file |
.singleOrNull() |
IR or null | Never | Optional single workflow |
.all() |
IR array | Never | Multi-workflow iteration |
.named(name) |
Single IR | If not found | Named workflow selection |
.first() |
Single IR | If empty | First workflow only |
.firstOrNull() |
IR or null | Never | Safe first workflow |
Diff workflows
Section titled “Diff workflows”--format=markdown is the default for --diff and is only valid in this mode.
# Local filesnpx awaitly-analyze --diff before.ts after.ts
# HEAD vs working copynpx awaitly-analyze --diff src/workflows/checkout.ts
# Git ref vs localnpx awaitly-analyze --diff main:src/workflows/checkout.ts src/workflows/checkout.ts
# GitHub PRnpx awaitly-analyze --diff gh:#123import { analyze, diffWorkflows, renderDiffMarkdown, renderDiffJSON, renderDiffMermaid,} from 'awaitly-analyze';
const before = analyze('./v1.ts').single();const after = analyze('./v2.ts').single();
const diff = diffWorkflows(before, after, { detectRenames: true, regressionMode: false,});
const md = renderDiffMarkdown(diff, { showUnchanged: true });const json = renderDiffJSON(diff);const mermaid = renderDiffMermaid(after, diff, { showRemovedSteps: true, direction: 'TB' });Diff options
Section titled “Diff options”| Option | Default | Description |
|---|---|---|
detectRenames |
true |
Match same callee+position as rename |
regressionMode |
false |
Mark removals as regressions |
--doctor diagnostics
Section titled “--doctor diagnostics”Use strict slug-keyed diagnostics aligned with runtime and ESLint rule naming.
awaitly-analyze ./src/workflows/checkout.ts --doctorawaitly-analyze ./src/workflows/checkout.ts --doctor --format=jsonSample output:
✗ [step-require-id]:12:4 Step "<missing>" uses legacy signature without explicit ID Docs: https://jagreehal.github.io/awaitly/rules/#step-require-id Fix: Use step('id', fn, opts) instead of step(fn, opts)Analyzer output model
Section titled “Analyzer output model”interface StaticWorkflowIR { root: StaticWorkflowNode; metadata: StaticAnalysisMetadata; references: Map<string, StaticWorkflowIR>;}
interface AnalysisStats { totalSteps: number; conditionalCount: number; parallelCount: number; raceCount: number; loopCount: number; workflowRefCount: number; unknownCount: number;}Node categories:
StaticStepNodeStaticSequenceNodeStaticParallelNodeStaticRaceNodeStaticConditionalNodeStaticLoopNodeStaticWorkflowRefNodeStaticUnknownNode
Feature detection coverage
Section titled “Feature detection coverage”| Feature | Detection |
|---|---|
| Steps | step(), step.retry(), step.withTimeout() |
| Conditionals | if/else, when*, unless* |
| Loops | for, while, for-of, for-in |
| Parallel | step.all(), allAsync(), allSettledAsync() |
| Race | step.race(), anyAsync() |
| Workflow refs | Child workflow run calls |
| Entry points | createWorkflow(), run(), runSaga(), durable.run(), .runWithState() |
A step.forEach node carries its loop id, plus stepIdPattern and max N when
they are set, and with error nodes on (the default) a maxIterations bound draws
its IterationLimitError exit. A run body that is step.retry or
step.withTimeout is read as that helper, so the loop body shows the retry
attempts and backoff and its errors come from the dep signature.
Which Output Should You Use?
| Choice | Best for | Tradeoff |
|---|---|---|
--format=mermaid | Readable architecture diagrams in docs and PRs. | Less detail than full JSON payload. |
--format=json | CI, automation, and custom tooling. | Harder for humans to scan at a glance. |
--html | Shareable deep review with click-to-inspect details. | Heavier artifact than markdown output. |
--diff | PR-level workflow change review. | Requires clean baseline/version selection. |
--doctor | Strict diagnostics and policy enforcement. | Can surface warnings that require migration work. |
--assert-diagrammable | CI gate that blocks non-deterministic workflow diagrams. | Fails on dynamic step ids or conditions the analyzer cannot name. Bound steps and native if/for are fine when identities are stable. |
--trace | Highlight executed path on a static diagram from a recorded run. | Requires capturing onEvent output and a diagrammable workflow. |
--dev | Local live inspector: static graph + runtime trace overlay in the browser. | Dev-only; wire onEvent (e.g. devEvents) to stream runs. |
Workflow names
Section titled “Workflow names”A workflow’s name is what the diagram title, the doctor report, and the generated .types.ts filename all show. For a run() or durable.run() call it comes from, in order:
- The enclosing function —
export function runBatch() { return durable.run(...) }andconst settleInvoices = async () => durable.run(...)are the common wrapper shapes. - A string-literal durable
id, for a call at module level. A runtime-built id such as`batch-${x}`is skipped. run@file:line, for a genuinely anonymous call — includingrun()insideit(...)or another callback. That becomes a filename likerun_awaitly_test_ts_26.types.ts.
Generated .types.ts filenames follow the workflow name, so a committed one may be written under a new name after a rename. Pass --types only when you want that file.
Generated error union
Section titled “Generated error union”The .types.ts error union is built from the same analysis that draws the diagram, so the two agree. It collects:
- Errors declared on the workflow (
errors: [...]). - Per-step
errors: [...]metadata, anywhere in the flow. - The error types of dependency
Resultsignatures — unions are split into their members and string literals are normalized.
Limitations
Section titled “Limitations”- Dynamic step IDs can appear as
<dynamic>. - The analyzer resolves external workflow references when their source is available in the project.
- An awaited call inside a workflow callback that static analysis cannot model (helper function, bare
await deps.fn()) surfaces as anunknownnode with anUNANALYZED_AWAITwarning in--doctoroutput rather than being dropped. Wrap the async work instep()to close the gap.