Skip to content

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

1

Analyze Source

Parse workflow file and extract IR tree, stats, and references.

2

Render Artifact

Generate Mermaid, JSON, markdown report, or interactive HTML.

3

Apply In Workflow

Use output in docs, PRs, CI gates, and architecture reviews.

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, or unless for branches that contain steps. Branches keep stable, labelled ids.
  • Use step.forEach for 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.

Terminal window
# Mermaid markdown (default)
npx awaitly-analyze ./src/workflows/checkout.ts
# JSON
npx awaitly-analyze ./src/workflows/checkout.ts --format=json
# Interactive HTML artifact
npx awaitly-analyze ./src/workflows/checkout.ts --html
# Workflow diff
npx awaitly-analyze --diff v1.ts v2.ts
# Strict diagnostics
npx awaitly-analyze ./src/workflows/checkout.ts --doctor
# CI gate: fail if a workflow has unstable graph identities
npx awaitly-analyze ./src/workflows/checkout.ts --assert-diagrammable
# Overlay a recorded run's executed path onto the static diagram
npx awaitly-analyze ./src/workflows/checkout.ts --trace=./run-events.json
# Live inspector: static graph + runtime trace overlay in the browser
npx awaitly-analyze ./src/workflows/checkout.ts --dev
npx awaitly-analyze ./src/workflows/checkout.ts --dev --port=5000
  • Step IDs from step('id', fn, opts) as canonical names.
  • Workflow and step docs from description and markdown.
  • JSDoc descriptions as jsdocDescription fallback.
  • Optional step metadata: intent, domain, owner, tags, stateChanges, emits, calls, errorMeta.

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.

Read the dependency facts from JSON:

Terminal window
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.

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.

- 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-types

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

Capture WorkflowEvent[] from onEvent, save the array as run-events.json, and overlay it:

Terminal window
npx awaitly-analyze ./checkout.ts \
--trace=./run-events.json --no-types \
-o --suffix=failed-run

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

Browse Analyzer Showcase for source and generated diagrams covering retries, resources, loops, workflow composition, and sagas.

Flag Default Description
--format=<fmt> mermaid mermaid, json, markdown
--direction=<dir> TB 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
Flag Default Description
--types / --no-types on Generate .types.ts
--test / --no-test off Generate test stubs
--test-runner=<runner> vitest vitest, jest, mocha
-o, --output-adjacent off Write output next to source
--suffix=<value> workflow Adjacent output file suffix
--no-stdout off Suppress stdout output
--dsl-output=<value> off Write DSL output
--write-dsl off Same as --dsl-output=.awaitly
--watch off Re-analyze on source changes
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)

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

Terminal window
npx awaitly-analyze ./src/workflows/checkout.ts --dev
# → http://localhost:4747
npx awaitly-analyze ./src/workflows/checkout.ts --dev --port=5000

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

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-analyze
await 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 →.

Terminal window
# Writes <basename>.html next to workflow source
npx awaitly-analyze ./src/workflows/checkout.ts --html
# Custom path
npx awaitly-analyze ./src/workflows/checkout.ts --html --html-output=./docs/checkout-diagram.html

The HTML output includes:

  • Mermaid rendered client-side.
  • Click-to-inspect node details.
  • Built-in theme picker with persistence.
  • Self-contained payload for easy sharing.
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',
});

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 gaps
report.score; // 0-100, share of nodes with a stable identity
report.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.

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).
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
Terminal window
# Local files
npx awaitly-analyze --diff before.ts after.ts
# HEAD vs working copy
npx awaitly-analyze --diff src/workflows/checkout.ts
# Git ref vs local
npx awaitly-analyze --diff main:src/workflows/checkout.ts src/workflows/checkout.ts
# GitHub PR
npx awaitly-analyze --diff gh:#123
import {
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' });
Option Default Description
detectRenames true Match same callee+position as rename
regressionMode false Mark removals as regressions

Use strict slug-keyed diagnostics aligned with runtime and ESLint rule naming.

Terminal window
awaitly-analyze ./src/workflows/checkout.ts --doctor
awaitly-analyze ./src/workflows/checkout.ts --doctor --format=json

Sample 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)
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:

  • StaticStepNode
  • StaticSequenceNode
  • StaticParallelNode
  • StaticRaceNode
  • StaticConditionalNode
  • StaticLoopNode
  • StaticWorkflowRefNode
  • StaticUnknownNode
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

Which Output Should You Use?

ChoiceBest forTradeoff
--format=mermaidReadable architecture diagrams in docs and PRs.Less detail than full JSON payload.
--format=jsonCI, automation, and custom tooling.Harder for humans to scan at a glance.
--htmlShareable deep review with click-to-inspect details.Heavier artifact than markdown output.
--diffPR-level workflow change review.Requires clean baseline/version selection.
--doctorStrict diagnostics and policy enforcement.Can surface warnings that require migration work.
--assert-diagrammableCI gate that blocks non-deterministic workflow diagrams.Requires declarative control flow (step.if, when, step.forEach).
--traceHighlight executed path on a static diagram from a recorded run.Requires capturing onEvent output and a diagrammable workflow.
--devLocal live inspector: static graph + runtime trace overlay in the browser.Dev-only; wire onEvent (e.g. devEvents) to stream runs.
  • 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 an unknown node with an UNANALYZED_AWAIT warning in --doctor output rather than being dropped. Wrap the async work in step() to close the gap.