Skip to content

Static Analysis

Analyze workflow source code to extract structure, dependencies, and generate visualization data without executing the workflow.

Documentation fields: description and markdown on workflows and steps are the canonical way to feed static analysis and doc generation. See Documenting workflows for how to set them. JSDoc above workflow and step declarations is also extracted and available as jsdocDescription on the root and step nodes.

Step names: step() requires a string literal as the first argument. The analyzer uses that string as the step display name (for docs/diagrams). There is no inference; a missing or non-string first argument is reported as an error. See Steps for usage.

Step metadata: The analyzer also reads optional step metadata when present (intent, domain, owner, tags, stateChanges, emits, calls, errorMeta) and includes it in the IR and generated diagrams/tooling — e.g. step('charge', () => charge(), { domain: 'payments', intent: 'charge customer' }).

The awaitly-analyze CLI analyzes TypeScript workflow files and outputs Mermaid diagrams or JSON:

Terminal window
# Output Mermaid diagram (default)
npx awaitly-analyze ./src/workflows/checkout.ts
# Output JSON
npx awaitly-analyze ./src/workflows/checkout.ts --format=json
# Pipe to file
npx awaitly-analyze ./src/workflows/checkout.ts > workflow.md
# Show step cache keys
npx awaitly-analyze ./src/workflows/checkout.ts --keys
# Change diagram direction (TB, LR, BT, RL)
npx awaitly-analyze ./src/workflows/checkout.ts --direction=LR
# Compare two workflow files
npx awaitly-analyze --diff v1.ts v2.ts
# Compare HEAD vs working copy (single file shorthand)
npx awaitly-analyze --diff src/workflows/checkout.ts
# Compare a git ref against a local file
npx awaitly-analyze --diff main:src/workflows/checkout.ts src/workflows/checkout.ts
# Diff all workflow files in a GitHub PR
npx awaitly-analyze --diff gh:#123
# Diff as JSON or Mermaid diagram
npx awaitly-analyze --diff v1.ts v2.ts --format=json
npx awaitly-analyze --diff v1.ts v2.ts --format=mermaid
# Flag removed steps as regressions
npx awaitly-analyze --diff v1.ts v2.ts --regression

Generate a self-contained HTML file with an interactive Mermaid diagram, click-to-inspect node details, and a built-in theme picker:

Terminal window
# Generate interactive HTML (writes <basename>.html next to source)
npx awaitly-analyze ./src/workflows/checkout.ts --html
# Custom output path
npx awaitly-analyze ./src/workflows/checkout.ts --html --html-output=./docs/checkout-diagram.html

The generated HTML file is fully self-contained — it loads Mermaid from a CDN and embeds all workflow metadata inline. Open it in any browser; no dev server needed.

What’s included:

  • Mermaid diagram rendered client-side via CDN
  • Click-to-inspect — click any node to see its step ID, callee, retry/timeout config, types, data flow, and source location in a side panel
  • 6 color themes — 4 dark (Midnight, Ocean, Ember, Forest) and 2 light (Daylight, Paper)
  • System preference detection — auto-selects a dark or light theme based on prefers-color-scheme
  • localStorage persistence — your theme choice is remembered across sessions

Programmatic usage:

import {
analyze,
renderStaticMermaid,
extractNodeMetadata,
generateInteractiveHTML,
} from 'awaitly-analyze';
const ir = analyze('./checkout.ts').single();
const mermaid = renderStaticMermaid(ir);
const metadata = extractNodeMetadata(ir);
const html = generateInteractiveHTML(mermaid, metadata, {
theme: 'midnight', // optional: force a specific theme
title: 'Checkout', // optional: override page title
direction: 'TB', // optional: diagram direction
});
// Write to disk
import { writeFileSync } from 'node:fs';
writeFileSync('checkout.html', html);

See Renderers → Interactive HTML renderer for the full API.

When a file contains multiple workflows, each gets its own section with a markdown header and mermaid diagram:

## Workflow: checkoutWorkflow
(mermaid diagram for checkout workflow)
## Workflow: paymentWorkflow
(mermaid diagram for payment workflow)
import { analyze } from 'awaitly-analyze';
// Analyze a single-workflow file
const ir = analyze('./src/workflows/checkout.ts').single();
console.log(`Steps: ${ir.metadata.stats.totalSteps}`);
// Analyze a multi-workflow file
const workflows = analyze('./src/workflows/all.ts').all();
for (const ir of workflows) {
console.log(`${ir.root.workflowName}: ${ir.metadata.stats.totalSteps} steps`);
}
// Get a specific workflow by name
const checkout = analyze('./src/workflows/all.ts').named('checkoutWorkflow');
MethodReturnsThrowsUse when
.single()Single IRIf 0 or >1 workflowsFile has exactly one workflow
.singleOrNull()IR or nullNeverChecking if file has one workflow
.all()IR arrayNeverProcessing all workflows in file
.named(name)Single IRIf not foundTargeting specific workflow by name
.first()Single IRIf emptyGetting first workflow, don’t care about count
.firstOrNull()IR or nullNeverSafely getting first workflow

Choosing the right method:

// Most common: single workflow per file
const ir = analyze('./checkout.ts').single();
// Multi-workflow files: iterate all
for (const ir of analyze('./workflows.ts').all()) {
console.log(ir.root.workflowName);
}
// Target by name when you know what you want
const payment = analyze('./workflows.ts').named('paymentWorkflow');
// Safe access when file might be empty
const ir = analyze(filePath).firstOrNull();
if (ir) {
// process workflow
}

Analyze workflow code directly without reading from a file:

import { analyze } from 'awaitly-analyze';
const source = `
const checkout = createWorkflow('workflow', deps);
async function run() {
return await checkout.run(async ({ step, deps }) => {
const cart = await step('fetchCart', () => deps.fetchCart(), { key: 'cart' });
const total = await step('calculateTotal', () => deps.calculateTotal(cart), { key: 'total' });
return { cart, total };
});
}
`;
const ir = analyze.source(source).single();
console.log(ir.metadata.stats);
// { totalSteps: 2, conditionalCount: 0, parallelCount: 0, ... }

The analyzer extracts:

FeatureDetection
Stepsstep(), step.retry(), step.withTimeout()
Conditionalsif/else, when(), unless(), whenOr(), unlessOr()
Loopsfor, while, for-of, for-in
Parallelstep.parallel(), allAsync(), allSettledAsync()
Racestep.race(), anyAsync()
Retry/TimeoutOptions in step config, step.retry(), step.withTimeout()
Workflow refsCalls to other workflows

The analyzer handles various callback patterns:

// Simple identifier
workflow.run(async ({ step, deps }) => { ... });
// Destructuring
workflow.run(async ({ step }) => { ... });
// Destructuring with alias
workflow.run(async ({ step: runStep }) => { ... });
// Destructuring with default
workflow.run(async ({ step = defaultStep }) => { ... });
// Destructuring with alias and default
workflow.run(async ({ step: runStep = fallback }) => { ... });

The analyzer returns a StaticWorkflowIR with:

interface StaticWorkflowIR {
root: StaticWorkflowNode; // Workflow tree
metadata: StaticAnalysisMetadata;
references: Map<string, StaticWorkflowIR>;
}
interface StaticAnalysisMetadata {
analyzedAt: number;
filePath: string;
warnings: AnalysisWarning[];
stats: AnalysisStats;
}
interface AnalysisStats {
totalSteps: number;
conditionalCount: number;
parallelCount: number;
raceCount: number;
loopCount: number;
workflowRefCount: number;
unknownCount: number;
}

The workflow tree contains these node types:

type StaticFlowNode =
| StaticStepNode // step() call
| StaticSequenceNode // Sequential steps
| StaticParallelNode // Parallel execution
| StaticRaceNode // Race execution
| StaticConditionalNode // if/else or conditional helpers
| StaticLoopNode // for/while loops
| StaticWorkflowRefNode // Call to another workflow
| StaticUnknownNode; // Unanalyzable code

Main static-analysis node types and when fields are populated:

  • StaticWorkflowNode (root): workflowName, source, dependencies, children, description, markdown, jsdocDescription?, errorTypes. description and markdown are set only for createWorkflow / createSagaWorkflow (from options or deps). They are undefined for run() / runSaga(). jsdocDescription is extracted from JSDoc above the workflow variable when present.

  • StaticStepNode: stepId, callee, name, key, description, markdown, jsdocDescription?, retry, timeout, errors?, out?, reads?. stepId is required and comes from the first argument to step('id', fn, opts). description and markdown come from step options. jsdocDescription is extracted from JSDoc above the step statement.

  • StaticSagaStepNode: callee, name, description, markdown, jsdocDescription?, hasCompensation, compensationCallee, isTryStep. description and markdown come from saga step options.

  • DependencyInfo: name, typeSignature?, errorTypes. typeSignature is the TypeScript type when the type checker is available. errorTypes is not yet inferred from types.

Use the built-in helpers to traverse nodes:

import {
analyze,
isStaticStepNode,
isStaticConditionalNode,
getStaticChildren
} from 'awaitly-analyze';
function walkWorkflow(node: StaticFlowNode) {
if (isStaticStepNode(node)) {
console.log(`Step: ${node.key || node.name}`);
}
if (isStaticConditionalNode(node)) {
console.log(`Condition: ${node.condition}`);
}
// Recursively walk children
for (const child of getStaticChildren(node)) {
walkWorkflow(child);
}
}
const ir = analyze('./checkout.ts').single();
for (const child of ir.root.children) {
walkWorkflow(child);
}

A single file can contain multiple workflows:

import { analyze } from 'awaitly-analyze';
const source = `
const workflowA = createWorkflow('workflowA', depsA);
const workflowB = createWorkflow('workflowB', depsB);
async function runA() {
return await workflowA.run(async ({ step }) => { ... });
}
async function runB() {
return await workflowB.run(async ({ step }) => { ... });
}
`;
const result = analyze.source(source);
// Get all workflows
const workflows = result.all();
console.log(workflows.length); // 2
// Get by name
const wfA = analyze.source(source).named('workflowA');
const wfB = analyze.source(source).named('workflowB');

Use description and markdown on workflows and steps (see Documenting workflows) so the analyzer can extract them. Then:

  1. Call analyze(filePath).all() to get all workflows in a file.
  2. For each IR: use root.workflowName, root.description, root.markdown for the workflow title and body.
  3. Walk root.children (and nested sequences) to list steps; use each step’s name, key, description, markdown for step tables or lists.
  4. Optionally use generatePaths(ir) and renderStaticMermaid(ir) for path coverage and diagrams.

Minimal recipe:

import { analyze, getStaticChildren, isStaticStepNode } from 'awaitly-analyze';
const workflows = analyze('./src/workflows/checkout.ts').all();
for (const ir of workflows) {
const { root } = ir;
console.log(`# ${root.workflowName}\n`);
if (root.description) console.log(root.description + '\n');
if (root.markdown) console.log(root.markdown + '\n');
console.log(`- ${ir.metadata.stats.totalSteps} steps\n`);
function listSteps(node) {
if (isStaticStepNode(node)) {
const name = node.stepId;
console.log(`- **${name}**${node.description ? `${node.description}` : ''}`);
}
for (const child of getStaticChildren(node)) listSteps(child);
}
for (const child of root.children) listSteps(child);
}

An optional script that does this is provided in the repo: run node packages/awaitly-analyze/scripts/generate-workflow-docs.mjs <path-to-workflow.ts> from the repo root (after building awaitly-analyze) to output one markdown section per workflow.

import { analyze, isStaticConditionalNode } from 'awaitly-analyze';
function countPaths(node: StaticFlowNode): number {
if (isStaticConditionalNode(node)) {
const thenPaths = node.consequent.reduce((n, c) => n * countPaths(c), 1);
const elsePaths = (node.alternate || []).reduce((n, c) => n * countPaths(c), 1);
return thenPaths + elsePaths;
}
// ... handle other node types
return 1;
}
const ir = analyze('./checkout.ts').single();
console.log(`Execution paths: ${countPaths(ir.root)}`);
import { analyze } from 'awaitly-analyze';
const ir = analyze('./checkout.ts').single();
const stats = ir.metadata.stats;
if (stats.totalSteps > 20) {
console.warn('Consider breaking this workflow into smaller pieces');
}
if (stats.conditionalCount > 5) {
console.warn('High cyclomatic complexity');
}

Generate output from the IR using built-in renderers:

import { analyze, renderStaticMermaid } from 'awaitly-analyze';
const ir = analyze('./checkout.ts').single();
const mermaid = renderStaticMermaid(ir, {
direction: 'TB', // TB, LR, BT, RL
showKeys: false, // Show step cache keys
showConditions: true, // Show condition text
});
console.log(mermaid);
// flowchart TB
// start(("▶"))
// step_1["Fetch cart"]
// ...
import { analyze, renderStaticJSON, renderMultipleStaticJSON } from 'awaitly-analyze';
const ir = analyze('./checkout.ts').single();
const json = renderStaticJSON(ir, { pretty: true });
// For multiple workflows
const workflows = analyze('./checkout.ts').all();
const multiJson = renderMultipleStaticJSON(workflows, './checkout.ts', {
pretty: true,
});

The output of renderStaticJSON(ir) has this structure:

  • Top level: { root, metadata?, references? }
  • StaticWorkflowNode: type: "workflow", id, workflowName, source?, dependencies[], errorTypes[], children[], description?, markdown?, jsdocDescription?
  • Flow nodes (discriminated by type): "step" (includes required stepId from the first argument), "saga-step", "sequence", "parallel", "race", "conditional", "switch", "loop", "stream", "workflow-ref", "unknown"

A JSON Schema for validation is available at schema/static-workflow-ir.schema.json in the awaitly-analyze package.

Generate a self-contained HTML file with an interactive Mermaid diagram and click-to-inspect panel. This is the same output produced by the --html CLI flag.

The pipeline has two steps: extract metadata from the IR, then combine it with the Mermaid text into HTML:

import {
analyze,
renderStaticMermaid,
extractNodeMetadata,
generateInteractiveHTML,
} from 'awaitly-analyze';
const ir = analyze('./checkout.ts').single();
const mermaid = renderStaticMermaid(ir);
const metadata = extractNodeMetadata(ir);
const html = generateInteractiveHTML(mermaid, metadata, { theme: 'midnight' });

extractNodeMetadata(ir) walks the IR tree and returns a WorkflowMetadata object containing per-node metadata (step IDs, callees, retry/timeout config, types, source locations) keyed by the Mermaid node ID so the HTML click handler can look up details.

generateInteractiveHTML(mermaidText, metadata, options?) returns a complete HTML string. Options:

OptionTypeDefaultDescription
titlestringworkflow namePage title
themestringauto-detectInitial theme (midnight, ocean, ember, forest, daylight, paper)
mermaidCdnUrlstringlatest v11Mermaid CDN URL
directionstringTBDiagram direction

Parallel and race nodes support custom names that appear in diagrams:

// Object form with name option
await step.parallel('Fetch user data', {
user: () => deps.fetchUser(),
posts: () => deps.fetchPosts(),
});
// Array form with name
await step.parallel('Fetch all', () => allAsync([
deps.fetchUser(),
deps.fetchPosts(),
]));

Both forms render with the custom name in Mermaid diagrams instead of generic “Parallel (all)”.

Compare two versions of a workflow to detect added, removed, renamed, and moved steps, plus structural changes like new parallel blocks.

Pass --diff with one or two sources. Sources can be local files, git refs (ref:path), or a GitHub PR (gh:#N). The default output format is markdown; use --format to switch to JSON or Mermaid.

Terminal window
# Two local files
npx awaitly-analyze --diff before.ts after.ts
# Single file — compares HEAD against working copy
npx awaitly-analyze --diff src/workflows/checkout.ts
# Git ref vs local file
npx awaitly-analyze --diff main:src/workflows/checkout.ts src/workflows/checkout.ts
# GitHub PR — auto-discovers changed workflow files
npx awaitly-analyze --diff gh:#123
# GitHub PR — specific file only
npx awaitly-analyze --diff gh:#123 src/workflows/checkout.ts
# Output formats
npx awaitly-analyze --diff before.ts after.ts --format=json
npx awaitly-analyze --diff before.ts after.ts --format=mermaid
# Flag removed steps as regressions
npx awaitly-analyze --diff before.ts after.ts --regression

The git ref syntax uses ref:path where ref is any valid git ref (branch, tag, or SHA). GitHub PR mode requires the GitHub CLI (gh) to be installed and authenticated.

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, // match renamed steps by callee + position (default: true)
regressionMode: false, // flag removals as regressions (default: false)
});
// Render as markdown
const md = renderDiffMarkdown(diff, { showUnchanged: true });
// Render as JSON
const json = renderDiffJSON(diff);
// Render as Mermaid (needs the "after" IR for the base diagram)
const mermaid = renderDiffMermaid(after, diff, {
showRemovedSteps: true, // add ghost nodes for removed steps (default: true)
direction: 'TB', // diagram direction (default: TB)
});
ChangeDescription
AddedStep exists in the after workflow but not in the before
RemovedStep exists in the before workflow but not in the after
RenamedSame callee and position, different step ID
MovedSame step ID, different container (e.g. sequential to parallel)
StructuralContainer-level changes — added/removed parallel, race, conditional, or loop blocks

Markdown — human-readable report with sections for each change kind:

# Workflow Diff: checkout → checkout
## Summary
- 1 added, 1 removed, 1 renamed, 0 moved, 2 unchanged
## Added Steps
- `apply-discount` (deps.applyDiscount)
## Removed Steps
- `send-receipt` (deps.sendReceipt)
## Renamed Steps
- `validate-cart` → `verify-cart` (deps.validateCart)
## Unchanged Steps
- `fetch-cart` (deps.fetchCart)
- `charge-payment` (deps.chargePayment)

The regressionMode option prepends a warning icon to the Removed Steps heading.

JSON — structured WorkflowDiff object for CI checks, dashboards, or piping into other tools:

{
"steps": [
{ "kind": "unchanged", "stepId": "fetch-cart", "callee": "deps.fetchCart" },
{ "kind": "unchanged", "stepId": "charge-payment", "callee": "deps.chargePayment" },
{ "kind": "renamed", "stepId": "verify-cart", "previousStepId": "validate-cart", "callee": "deps.validateCart" },
{ "kind": "removed", "stepId": "send-receipt", "callee": "deps.sendReceipt" },
{ "kind": "added", "stepId": "apply-discount", "callee": "deps.applyDiscount" }
],
"summary": {
"stepsAdded": 1,
"stepsRemoved": 1,
"stepsRenamed": 1,
"stepsMoved": 0,
"stepsUnchanged": 2,
"hasRegressions": false
}
}

Mermaid — renders the “after” workflow diagram with diff styling overlaid. Added steps get a green fill, renamed steps orange, moved steps blue. Removed steps appear as dashed ghost nodes:

flowchart TB
  start((Start))
  step_1["fetch-cart"]
  step_2["verify-cart"]
  step_3["apply-discount"]
  step_4["charge-payment"]
  end_node((End))

  step_1 --> step_2
  step_2 --> step_3
  step_3 --> step_4
  start --> step_1
  step_4 --> end_node

  classDef diffAddedStyle fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
  classDef diffRemovedStyle fill:#ffcdd2,stroke:#c62828,stroke-width:2px,stroke-dasharray:5
  classDef diffRenamedStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px

  classDef stepStyle fill:#e1f5fe,stroke:#01579b
  classDef startStyle fill:#c8e6c9,stroke:#2e7d32
  classDef endStyle fill:#ffcdd2,stroke:#c62828
  class start startStyle
  class end_node endStyle
  class step_1 stepStyle
  class step_4 stepStyle

  removed_1["❌ send-receipt"]

  class step_2 diffRenamedStyle
  class step_3 diffAddedStyle
  class removed_1 diffRemovedStyle

Style classes diffAddedStyle, diffRemovedStyle, diffRenamedStyle, and diffMovedStyle are injected automatically.

diffWorkflows(before, after, options?)

OptionTypeDefaultDescription
detectRenamesbooleantrueMatch steps with the same callee and position index as renames instead of remove + add
regressionModebooleanfalseFlag removed steps as regressions in the summary

renderDiffMarkdown(diff, options?)

OptionTypeDefaultDescription
showUnchangedbooleantrueInclude the Unchanged Steps section
titlestringautoOverride the report title

renderDiffMermaid(afterIR, diff, options?)

OptionTypeDefaultDescription
showRemovedStepsbooleantrueAdd ghost nodes for removed steps
directionstringTBMermaid flowchart direction (TB, LR, BT, RL)

All diff types are exported from awaitly-analyze:

import type {
WorkflowDiff,
StepDiffEntry,
StepChangeKind,
StructuralChange,
DiffSummary,
DiffOptions,
DiffMarkdownOptions,
DiffMermaidOptions,
} from 'awaitly-analyze';
  • Dynamic code: Template literals in step keys show as <dynamic>
  • External imports: Referenced workflows must be in the same file to resolve

Learn about Visualization →