awaitly-analyze
v0.32.1
Published
Static workflow analysis for awaitly. Analyze workflow source code to extract structure, types, and generate visualizations.
Maintainers
Readme
awaitly-analyze
Static workflow analysis for awaitly. Analyze workflow source code to extract structure, calculate complexity metrics, and generate visualizations.
Deterministic diagrams from real code
XState draws a perfect diagram because you hand-write the machine as data; the diagram is a serialization of that data, a second artifact you keep in sync with your implementation. awaitly diagrams the real executable code, so the diagram can't drift from behavior.
The cost: imperative control flow (if, for, computed ids) isn't always deterministically drawable. awaitly closes that gap the way XState does, by making the load-bearing control flow declarative so there's nothing left to infer:
- Write
step.if,when, orunlessinstead of a rawif, and the branch gets a stable, labelled id. - Write
step.forEachinstead of a raw loop, and each iteration gets a structured id. - Use literal step ids (put the dynamic part in
key), and every node has a stable identity.
A workflow built from those constructs is fully diagrammable: its diagram is deterministic, and a runtime trace overlays onto it exactly. Native if/for...of/while qualify too — the analyzer derives a stable branch id from the condition and a stable loop id from the iterable. --assert-diagrammable (below) enforces this in CI, flagging only expressions it cannot read statically.
CLI Usage
# Writes checkout.workflow.md next to the source and prints the diagram
awaitly-analyze ./src/workflows/checkout.ts
# Print only (no adjacent file)
awaitly-analyze ./src/workflows/checkout.ts --no-output-adjacent
# JSON format (also writes checkout.workflow.json unless you pass --no-output-adjacent)
awaitly-analyze ./src/workflows/checkout.ts --format=json
# Show step keys and change diagram direction
awaitly-analyze ./src/workflows/checkout.ts --keys --direction=LR
# Custom suffix (creates checkout.diagram.md)
awaitly-analyze ./src/workflows/checkout.ts --suffix=diagram
# JSON with a custom adjacent name (creates checkout.analysis.json)
awaitly-analyze ./src/workflows/checkout.ts --suffix=analysis --format=json
# Write to file only, suppress stdout
awaitly-analyze ./src/workflows/checkout.ts --no-stdout
# Generate .types.ts next to the source
awaitly-analyze ./src/workflows/checkout.ts --types
# Generate interactive HTML with click-to-inspect
awaitly-analyze ./src/workflows/checkout.ts --html
# Custom HTML output path
awaitly-analyze ./src/workflows/checkout.ts --html --html-output=./docs/checkout.html
# Doctor mode: strict diagnostics with concrete fix guidance
awaitly-analyze ./src/workflows/checkout.ts --doctor
# Doctor mode JSON for AI/tooling pipelines
awaitly-analyze ./src/workflows/checkout.ts --doctor --format=json
# CI gate: fail if any workflow's diagram is not fully deterministic
awaitly-analyze ./src/workflows/checkout.ts --assert-diagrammable
# Overlay a recorded run's executed path onto the static diagram
awaitly-analyze ./src/workflows/checkout.ts --trace=./run-events.json
# Review every workflow your branch changed: diff, regressions, new doctor findings, railway diagrams
awaitly-analyze review --base origin/main src/The same review runs on pull requests as a GitHub Action (uses: jagreehal/awaitly@analyze-v0) and posts one sticky comment. See GitHub Action.
CLI Options
| Option | Default | Description |
|--------|---------|-------------|
| --format=<format> | mermaid | Output format: mermaid or json |
| --html | - | Generate interactive HTML file (Mermaid CDN + click-to-inspect) |
| --html-output=<path> | <basename>.html | Output path for the HTML file |
| --keys | - | Show step cache keys in diagram |
| --direction=<dir> | TB | Diagram direction: TB, LR, BT, RL |
| --output-adjacent, -o | on | Write <basename>.workflow.md (or .json) next to the source |
| --no-output-adjacent | - | Print only; skip the adjacent diagram file |
| --suffix=<value> | workflow | Configurable suffix for output file |
| --no-stdout | - | Suppress stdout when a file is being written |
| --types / --no-types | off | Generate <workflowName>.types.ts |
| --dsl-output=<value> | off | Write DSL: off, .awaitly, or custom path (for visualization) |
| --write-dsl | - | Shorthand for --dsl-output=.awaitly |
| --doctor | - | Print strict diagnostics with fix suggestions (includes the diagrammability verdict) |
| --assert-diagrammable | - | Exit non-zero if any workflow's diagram is not fully deterministic (CI gate) |
| --trace=<events.json> | - | Overlay a recorded run's executed path onto the static diagram (JSON array of workflow events) |
| --help, -h | - | Show help message |
Output File Naming
Adjacent output (default):
- Mermaid format:
{basename}.{suffix}.md - JSON format:
{basename}.{suffix}.json
Example: checkout.ts with --suffix=workflow produces checkout.workflow.md
Features
- Static Analysis - Extract workflow structure from TypeScript source without execution
- Type Extraction - Extract Result-like types (AsyncResult, Result) from dependencies and steps
- Path Generation - Enumerate all possible execution paths through a workflow
- Complexity Metrics - Calculate cyclomatic complexity, cognitive complexity, and more
- Mermaid Diagrams - Generate flowchart visualizations
- Test Matrix - Generate test coverage matrices for workflow paths
- Cross-Workflow Composition - Analyze dependencies between workflows
- Data Flow Analysis - Track data dependencies with typed propagation
Installation
npm install awaitly-analyze ts-morph
# or
pnpm add awaitly-analyze ts-morphts-morph is a required peer dependency.
Quick Start
import {
analyze,
generatePaths,
calculateComplexity,
renderStaticMermaid,
} from 'awaitly-analyze';
// Analyze a workflow file
const ir = analyze('./src/workflows/checkout.ts').single();
// Generate all possible paths
const paths = generatePaths(ir);
console.log(`Found ${paths.length} unique execution paths`);
// Calculate complexity metrics
const metrics = calculateComplexity(ir);
console.log(`Cyclomatic complexity: ${metrics.cyclomaticComplexity}`);
// Generate Mermaid diagram
const mermaid = renderStaticMermaid(ir);
console.log(mermaid);API Reference
Analyzing Workflows
analyze(filePath, options?)
Returns a fluent interface for analyzing workflows in a file.
import { analyze } from 'awaitly-analyze';
// Single workflow file - get the workflow directly
const ir = analyze('./checkout.ts').single();
// Multiple workflows - get all as array
const workflows = analyze('./workflows.ts').all();
// Get specific workflow by name
const checkout = analyze('./workflows.ts').named('checkoutWorkflow');
// Safe access (returns null instead of throwing)
const ir = analyze('./checkout.ts').singleOrNull();
const first = analyze('./workflows.ts').firstOrNull();analyze.source(code, options?)
Analyze workflow source code from a string.
const ir = analyze.source(`
const checkout = createWorkflow({ fetchUser });
await checkout.run(async ({ steps }) => steps.fetchUser('1'));
`).single();
// ir.root.workflowName === 'checkout'Path Generation
generatePaths(ir, options?)
Generate all unique execution paths through a workflow.
import { generatePaths, calculatePathStatistics } from 'awaitly-analyze';
const paths = generatePaths(ir, { maxPaths: 100 });
// Get statistics
const stats = calculatePathStatistics(paths);
console.log(`Total paths: ${stats.totalPaths}`);
console.log(`Shortest path: ${stats.shortestPathLength} steps`);
console.log(`Longest path: ${stats.longestPathLength} steps`);generatePathsWithMetadata(ir, options?)
Generate paths with additional metadata about limit hits.
import { generatePathsWithMetadata } from 'awaitly-analyze';
const { paths, limitHit } = generatePathsWithMetadata(ir, { maxPaths: 50 });
if (limitHit) {
console.log('Warning: Path limit reached, not all paths generated');
}Complexity Metrics
calculateComplexity(ir)
Calculate complexity metrics for a workflow.
import { calculateComplexity } from 'awaitly-analyze';
const metrics = calculateComplexity(ir);
console.log(`Cyclomatic complexity: ${metrics.cyclomaticComplexity}`);
console.log(`Cognitive complexity: ${metrics.cognitiveComplexity}`);
console.log(`Max nesting depth: ${metrics.maxDepth}`);
console.log(`Max parallel breadth: ${metrics.maxParallelBreadth}`);
console.log(`Decision points: ${metrics.decisionPoints}`);
console.log(`Path count: ${metrics.pathCount}`);assessComplexity(metrics, thresholds?)
Get a complexity assessment with warnings.
import {
analyze,
calculateComplexity,
assessComplexity,
formatComplexitySummary
} from 'awaitly-analyze';
const ir = analyze('./checkout.ts').single();
const metrics = calculateComplexity(ir);
const assessment = assessComplexity(metrics);
console.log(formatComplexitySummary(metrics, assessment));Mermaid Diagrams
renderStaticMermaid(ir, options?)
Generate a Mermaid flowchart diagram.
import { renderStaticMermaid } from 'awaitly-analyze';
const mermaid = renderStaticMermaid(ir, {
direction: 'TB', // 'TB' | 'LR' | 'BT' | 'RL'
includeKeys: true,
includeDescriptions: true,
});
// Use in markdown:
// ```mermaid
// ${mermaid}
// ```renderPathsMermaid(ir, paths, options?)
Generate a diagram highlighting specific paths.
import { renderPathsMermaid, generatePaths } from 'awaitly-analyze';
const paths = generatePaths(ir);
const diagram = renderPathsMermaid(ir, paths.slice(0, 3));Diagrammability
computeDiagrammability(ir)
Return a single verdict on whether a workflow's diagram is fully deterministic, with each gap naming the first-class construct that closes it.
import { analyze, computeDiagrammability } from 'awaitly-analyze';
const ir = analyze('./src/workflows/checkout.ts').single();
const report = computeDiagrammability(ir);
report.deterministic; // boolean, 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.
Runtime Trace Overlay
Render the static skeleton and highlight the path a real run took, the view XState's inspector gives, on the real executable code (so the diagram can't drift from behavior).
traceFromEvents(events)
Reduce an awaitly workflow event stream (captured via the onEvent option) into a per-step trace.
import { traceFromEvents, renderStaticMermaidWithTrace, analyze } from 'awaitly-analyze';
const trace = traceFromEvents(recordedEvents); // WorkflowEvent[] → per-step statusrenderStaticMermaidWithTrace(ir, trace, options?)
Overlay the trace onto the static diagram: each executed step is restyled by its status (success / error / aborted / skipped / cache-hit / running); untouched steps stay in the base style.
const ir = analyze('./src/workflows/checkout.ts').single();
const { mermaid, matched, unmatched } = renderStaticMermaidWithTrace(ir, trace);
// `unmatched` lists trace steps with no static node; empty when the workflow
// is diagrammable (literal step ids), which is why the two features reinforce
// each other.Test Matrix
generateTestMatrix(paths)
Generate a test coverage matrix from paths.
import { generateTestMatrix, formatTestMatrixMarkdown } from 'awaitly-analyze';
const paths = generatePaths(ir);
const matrix = generateTestMatrix(paths);
// Format as markdown table
console.log(formatTestMatrixMarkdown(matrix));
// Or as code
import { formatTestMatrixAsCode } from 'awaitly-analyze';
console.log(formatTestMatrixAsCode(matrix));Cross-Workflow Composition
analyzeWorkflowGraph(files, options?)
Analyze dependencies between workflows across multiple files.
import {
analyzeWorkflowGraph,
getTopologicalOrder,
renderGraphMermaid,
} from 'awaitly-analyze';
const graph = analyzeWorkflowGraph([
'./src/workflows/checkout.ts',
'./src/workflows/payment.ts',
'./src/workflows/shipping.ts',
]);
// Get execution order (workflows with no dependencies first)
const order = getTopologicalOrder(graph);
console.log('Execution order:', order.map(n => n.name).join(' -> '));
// Visualize the dependency graph
const diagram = renderGraphMermaid(graph);Interactive HTML
Generate a self-contained HTML file with an interactive Mermaid diagram and a click-to-inspect panel. The output includes 6 color themes, system preference auto-detection, and localStorage persistence.
extractNodeMetadata(ir)
Walk the IR tree and produce a WorkflowMetadata object with per-node details (step IDs, callees, retry/timeout config, types, source locations) keyed by Mermaid node ID.
import { analyze, extractNodeMetadata } from 'awaitly-analyze';
import type { WorkflowMetadata } from 'awaitly-analyze';
const ir = analyze('./checkout.ts').single();
const metadata: WorkflowMetadata = extractNodeMetadata(ir);generateInteractiveHTML(mermaidText, metadata, options?)
Combine Mermaid text from renderStaticMermaid() with metadata from extractNodeMetadata() into a complete HTML string.
import {
analyze,
renderStaticMermaid,
extractNodeMetadata,
generateInteractiveHTML,
} from 'awaitly-analyze';
import type { InteractiveHTMLOptions } from 'awaitly-analyze';
const ir = analyze('./checkout.ts').single();
const mermaid = renderStaticMermaid(ir);
const metadata = extractNodeMetadata(ir);
const html = generateInteractiveHTML(mermaid, metadata, {
theme: 'midnight', // 'midnight' | 'ocean' | 'ember' | 'forest' | 'daylight' | 'paper'
title: 'Checkout Workflow', // defaults to workflow name
direction: 'TB', // diagram direction
});
import { writeFileSync } from 'node:fs';
writeFileSync('checkout.html', html);InteractiveHTMLOptions:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| title | string | workflow name | Page title |
| theme | string | auto-detect | Initial color theme |
| mermaidCdnUrl | string | latest v11 | Mermaid CDN URL |
| direction | "TB" \| "LR" \| "BT" \| "RL" | "TB" | Diagram direction |
Workflow Diagram DSL
renderWorkflowDSL(ir)
Produce a state-machine-like DSL for xstate-style visualization (states and transitions with event labels). Types are defined in awaitly; the analyzer emits DSL that conforms to them.
import { analyze, renderWorkflowDSL, writeDSLToAwaitlyDir } from 'awaitly-analyze';
import type { WorkflowDiagramDSL } from 'awaitly';
const ir = analyze('./checkout.ts').single();
const dsl: WorkflowDiagramDSL = renderWorkflowDSL(ir);
// Optional: write to .awaitly/dsl/ (default) or a custom folder
await writeDSLToAwaitlyDir(dsl, { rootDir: process.cwd() });
// Custom folder: await writeDSLToAwaitlyDir(dsl, { rootDir: process.cwd(), outputDir: 'dist/dsl' });Identity contract: DSL state ids normally use the semantic ids authored in the code (step()'s literal first argument, step.if()'s decision id). If a collision requires a suffixed diagram id, state.semanticId preserves the authored identity used by runtime graph validation, so the DSL remains directly usable as the graph option. Literal cache keys are carried on state.key. For current-node highlighting, WorkflowSnapshot.execution.currentStepId holds the step key — match it against state.key ?? state.semanticId ?? state.id. See awaitly diagram-dsl types for details.
JSON Output
renderStaticJSON(ir, options?)
Serialize workflow IR to JSON.
import { renderStaticJSON } from 'awaitly-analyze';
const json = renderStaticJSON(ir, { pretty: true });
fs.writeFileSync('workflow.json', json);Detected Patterns
The analyzer detects the following awaitly patterns:
createWorkflow()- Standard workflow creation (named or deps-first:createWorkflow({ deps })recovers the workflow name from the binding variable)run()- Inline workflow execution (deps-first with auto-boundsteps.*())createSagaWorkflow()- Saga workflow creationrunSaga()- Inline saga execution
Within workflows, it detects:
steps.fetchUser(id)/step('fetchUser', () => deps.fetchUser(id))- Deps-first bound steps and classic step callssteps.validateUser(await steps.fetchUser(id))- Steps awaited inline as call arguments, in evaluation orderstep.parallel()/allAsync()/allSettledAsync()- Parallel executionstep.race()/anyAsync()- Race executionstep.sleep(id, duration, opts?)- Sleep steps (ID required as first argument)step.retry(id, operation, opts)- Retry wrappers (ID required as first argument)step.withTimeout(id, operation, opts)- Timeout wrappers (ID required as first argument)step.try(id, operation, opts)- Try/catch steps (ID required as first argument)step.fromResult(id, operation, opts)- FromResult steps (ID required as first argument)step.getWritable()/step.getReadable()/step.streamForEach()- Streamingwhen()/unless()/whenOr()/unlessOr()- Conditional helpersif/else,switch- Control flowfor,while,forEach,map- Loops- Saga steps with compensation (
saga.step(),saga.tryStep())
Import Styles
The analyzer supports various import styles:
// Named imports
import { createWorkflow, run } from 'awaitly';
// Aliased imports
import { createWorkflow as cw } from 'awaitly';Namespace imports (import * as X from 'awaitly') and default imports from older awaitly versions are also recognized, so the analyzer keeps working on codebases that have not migrated to named imports yet.
Types
Key types exported from the package:
import type {
// IR nodes
StaticWorkflowIR,
StaticFlowNode,
StaticStepNode,
StaticParallelNode,
StaticConditionalNode,
// Paths
WorkflowPath,
PathStatistics,
// Complexity
ComplexityMetrics,
ComplexityAssessment,
// Test matrix
TestMatrix,
TestPath,
// Graph
WorkflowGraph,
WorkflowGraphNode,
// Interactive HTML
NodeMetadata,
WorkflowMetadata,
InteractiveHTMLOptions,
} from 'awaitly-analyze';API types reference
Main static-analysis node types and when fields are populated:
StaticWorkflowNode (root):
workflowName,source,dependencies,children,description,markdown,jsdocDescription?,errorTypes.descriptionandmarkdownare set only forcreateWorkflow/createSagaWorkflow(from options or deps). They are undefined forrun()/runSaga()(no options object).jsdocDescriptionis extracted from JSDoc above the workflow variable when present.StaticStepNode:
stepId,callee,name,key,description,markdown,jsdocDescription?,retry,timeout.stepIdis the required first argument for all step types:step('id', fn, opts),step.sleep('id', duration, opts?),step.retry('id', operation, opts),step.withTimeout('id', operation, opts),step.try('id', operation, opts),step.fromResult('id', operation, opts). Legacystep(fn, opts)is still parsed but yieldsstepId: "<missing>"and a warning.descriptionandmarkdowncome from step options;jsdocDescriptionis extracted from JSDoc above the step statement when present.StaticSagaStepNode:
callee,name,description,markdown,jsdocDescription?,hasCompensation,compensationCallee,isTryStep.descriptionandmarkdowncome from saga step options (e.g.saga.step(fn, { description, markdown })).jsdocDescriptionis extracted from JSDoc above the saga step statement when present.DependencyInfo:
name,typeSignature?,errorTypes,signature?.typeSignatureis the TypeScript type of the dependency (e.g. the function type), when the type checker is available; it may be undefined.errorTypesis not yet inferred from types and is typically empty.signatureprovides typed parameter and return type information when available.
JSDoc
The analyzer extracts JSDoc comments from workflow declarations (createWorkflow / createSagaWorkflow variable statements) and from step call sites (the statement containing await step(...), step.sleep(...), saga.step(...), etc.). Extracted text is exposed as jsdocDescription on the root (StaticWorkflowNode) and on step nodes (StaticStepNode, StaticSagaStepNode). Only the main description (text before the first @tag) is extracted; @param / @returns are not parsed into separate fields. Option-based description and markdown remain the canonical documentation fields and take precedence for display; JSDoc is additive so consumers can use description ?? jsdocDescription for fallback.
Type Extraction
The analyzer extracts type information from workflows when the TypeScript type checker is available. This includes:
- Step output types - Extracted from dependency return types
- Error types - Inferred from Result-like types
- Parameter types - For dependency functions
- Typed signatures - Full function signatures with Result-like type details
TypeInfo
Type information is represented by the TypeInfo interface:
interface TypeInfo {
/** Human-readable type string */
display: string;
/** Normalized, fully qualified type string */
canonical: string;
/** Kind of Result-like type detected */
kind: "asyncResult" | "result" | "promiseResult" | "plain" | "unknown";
/** Confidence level of the extraction */
confidence: "exact" | "inferred" | "fallback";
/** Where the type information came from */
source: "checker" | "annotation" | "fallback";
}Confidence Levels
exact: Type extracted directly from explicit type annotations (e.g.,: AsyncResult<User, Error>)inferred: Type inferred from usage patterns or string parsing of type signaturesfallback: Default when type checker unavailable or type cannot be resolved
Result-like Types
The analyzer recognizes and extracts generics from:
AsyncResult<T, E, C>- Async Result with ok, error, and cause typesResult<T, E, C>- Synchronous ResultPromise<Result<T, E>>- Wrapped Promise Result
Example: Extracting Typed Dependencies
import { analyze } from 'awaitly-analyze';
const ir = analyze('./user-workflow.ts').single();
// Access typed dependency signatures
for (const dep of ir.root.dependencies) {
console.log(`Dependency: ${dep.name}`);
if (dep.signature) {
console.log(` Parameters:`);
for (const param of dep.signature.params) {
console.log(` - ${param.name}: ${param.type.display}`);
}
console.log(` Returns: ${dep.signature.returnType.display}`);
if (dep.signature.resultLike) {
console.log(` Ok Type: ${dep.signature.resultLike.okType.display}`);
console.log(` Error Type: ${dep.signature.resultLike.errorType.display}`);
}
}
}Example: Step Type Information
import { analyze } from 'awaitly-analyze';
const ir = analyze('./user-workflow.ts').single();
// Walk steps to get output type info
function walkSteps(nodes) {
for (const node of nodes) {
if (node.type === 'step') {
console.log(`Step: ${node.stepId}`);
console.log(` Output Type: ${node.outputType}`);
console.log(` Output Type Info:`, node.outputTypeInfo);
console.log(` Error Type Info:`, node.errorTypeInfo);
console.log(` Cause Type Info:`, node.causeTypeInfo);
}
if (node.children) walkSteps(node.children);
}
}
walkSteps(ir.root.children);JSON output shape
The output of renderStaticJSON(ir) has this structure. Agents and doc generators can use it to parse or validate the JSON.
Top level:
{ root, metadata?, references? }root: StaticWorkflowNode (see below)metadata:{ analyzedAt, filePath, tsVersion?, warnings?, stats? }references: object mapping workflow name to{ root, metadata? }(when inlined)
StaticWorkflowNode (
root):type: "workflow",id,workflowName,source?,dependencies[],errorTypes[],children[],description?,markdown?,jsdocDescription?,name?,key?,location?,typeSummary?DependencyInfo (each entry in
dependencies):name,typeSignature?,errorTypes[],signature?signature(optional):{ params: [{ name, type: TypeInfo }], returnType: TypeInfo, resultLike?: { okType: TypeInfo, errorType: TypeInfo, causeType?: TypeInfo } }
Flow nodes (
childrenand nested): discriminated bytype:"step":id,stepId,name?,key?,callee?,description?,markdown?,jsdocDescription?,retry?,timeout?,location?,outputType?,inputType?,outputTypeInfo?,errorTypeInfo?,causeTypeInfo?"saga-step":id,name?,callee?,description?,markdown?,jsdocDescription?,hasCompensation,compensationCallee?,isTryStep?,location?,outputTypeInfo?,errorTypeInfo?,compensationParamTypeInfo?"sequence":id,children[]"parallel":id,children[],mode("all" | "allSettled"),callee?"race":id,children[],callee?"conditional":id,condition,consequent[],alternate?,helper?,defaultValue?"switch":id,expression,cases[](each:value?,isDefault,body[])"loop":id,loopType,body[],iterSource?,boundKnown,boundCount?"stream":id,streamType,namespace?,options?,callee?"workflow-ref":id,workflowName,resolved,resolvedPath?,inlinedIR?"unknown":id,reason,sourceCode?
TypeInfo (embedded in various nodes):
display: Human-readable type stringcanonical: Normalized type stringkind: "asyncResult" | "result" | "promiseResult" | "plain" | "unknown"confidence: "exact" | "inferred" | "fallback"source: "checker" | "annotation" | "fallback"
A JSON Schema for this structure is available at schema/static-workflow-ir.schema.json in this package.
Requirements
- Node.js >= 22
- TypeScript project with
ts-morph>= 28.0.0
License
MIT
