Files
git.stella-ops.org/docs/modules/workflow/analyzer.md
master b7acf692b6 docs(workflow): analyzer rule reference + golden real-shape regression tests
docs/modules/workflow/analyzer.md — user-facing reference for
WF001-WF006 + WF010: one section per rule with a "bad" example and
the canonical fix. Covers activation, scope (Spec property is the
entry point; helpers walked transitively), trusted-assembly prefix
rule, cross-project WF010 indirection, and non-goals (no source
generator, no severity config, no escape hatch).

The DiagnosticDescriptors' HelpLinkUri already points at sections in
this doc (e.g., #wf005), so users who hit a build error can click
through to the exact rule explanation.

Golden tests (GoldenWorkflowShapeTests) exercise three patterns
lifted from the Bulstrad corpus:
  1. static readonly LegacyRabbitAddress fields + nested
     WhenExpression(Gt, Len, ...) + .Call + OnComplete with
     WhenExpression(Eq, ...) + ActivateTask/Complete
  2. SetBusinessReference(new WorkflowBusinessReferenceDeclaration
     { KeyExpression, Parts = new WorkflowNamedExpressionDefinition[] { ... } })
  3. WorkflowExpr.Func("bulstrad.normalizeCustomer", path)
     — custom runtime function dispatch

Each asserts zero WF* diagnostics. A regression that rejects these
patterns would break the entire Serdica corpus.

30/30 tests pass.
2026-04-15 09:29:08 +03:00

185 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# StellaOps.Workflow.Analyzer
A Roslyn `DiagnosticAnalyzer` that rejects workflow C# code which cannot be serialized to canonical workflow JSON. Failures are surfaced as compile-time errors at the offending line — no runtime round-trip needed to discover that a workflow is non-canonical.
## What the analyzer is for
`IDeclarativeWorkflow<T>` implementations build a `WorkflowSpec<T>` from a fluent builder. At runtime, `WorkflowCanonicalDefinitionCompiler` turns that spec into a canonical JSON document that the engine can execute, persist, diff, and replay.
For that to work, the spec must be **pure and declarative**: its shape can only depend on compile-time literals and the workflow builder surface, not on build-time state (time, environment, DB reads) or runtime C# semantics (imperative branching, exceptions, async).
The analyzer enforces this at compile time. Consumer projects pick it up via a `<ProjectReference>` to `StellaOps.Workflow.Analyzer.csproj` (with `OutputItemType="Analyzer"`) or via an `<Analyzer>` item pointing at the built DLL. Once referenced, every `IDeclarativeWorkflow<T>` class in the consumer compilation is checked on every build.
## Activation
The analyzer activates **only when the compilation references `StellaOps.Workflow.Abstractions` or `Ablera.Serdica.Workflow.Abstractions`**. Absent those, it is a no-op. No opt-in attribute is needed anywhere.
## Scope
The analyzer inspects the `Spec` property of every `IDeclarativeWorkflow<T>`-implementing class in the compilation. From each `Spec` initializer it walks transitively into every method call whose target is available as source:
- **Trusted leaves** — any method/type in an assembly whose name starts with `StellaOps.Workflow.` or `Ablera.Serdica.Workflow.`. Not walked; always allowed.
- **Same compilation / project references** — source walked via `DeclaringSyntaxReferences`. Violations in those methods are reported at their native location (same compilation) or surfaced as **WF010** at the workflow call site with an additional location inside the helper (cross-project).
- **Metadata-only (compiled NuGet / framework) references** — not walkable. Invocations, object creations, and non-const field/property reads on such assemblies are **rejected**. If you need a helper routine, put it in a project reference, not a binary dependency.
## Rules
All diagnostics are `Error` severity. They fail `dotnet build`.
### WF001 — Imperative control flow {#wf001}
**Bad**
```csharp
public WorkflowSpec<R> Spec { get; } = Build();
private static WorkflowSpec<R> Build()
{
if (DateTime.UtcNow.Hour > 12)
{
return WorkflowSpec.For<R>().InitializeState(...).Build();
}
return WorkflowSpec.For<R>().InitializeState(...).Build();
}
```
**Why it fails** — The shape of the canonical JSON would depend on when the workflow class was constructed. Two callers building the same workflow get different definitions.
**Fix** — Express branching inside the canonical model:
```csharp
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
.StartWith(flow => flow
.WhenExpression(
"After noon?",
WorkflowExpr.Gt(WorkflowExpr.Path("start.hour"), WorkflowExpr.Number(12)),
whenTrue => whenTrue.Call(...),
whenElse => whenElse.Call(...)))
.Build();
```
Forbidden constructs: `if`, `for`, `foreach`, `while`, `do`, `switch` (statement form), `try` / `catch` / `finally`, `throw`, `lock`, `using`, `goto`, `break`, `continue`, `yield`.
### WF002 — Async/await {#wf002}
**Bad**
```csharp
public WorkflowSpec<R> Spec { get; } = BuildAsync().GetAwaiter().GetResult();
private static async Task<WorkflowSpec<R>> BuildAsync()
{
await Task.Delay(1);
return WorkflowSpec.For<R>().Build();
}
```
**Why it fails** — The workflow Spec is constructed once, synchronously. `await` / `async` / `Task.Run` / `Thread` introduce runtime concurrency primitives that cannot be represented in the canonical definition.
**Fix** — Remove the async machinery. If the work you were doing was async only because it read from a DB or an API, move it out of the Spec: those reads should happen inside workflow steps at runtime (via `builder.Call(...)`), not at definition-construction time.
### WF003 — Call into non-trusted assembly {#wf003}
**Bad**
```csharp
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
.InitializeState(WorkflowExpr.Obj(
WorkflowExpr.Prop("machine", WorkflowExpr.String(Environment.MachineName))))
.Build();
```
**Why it fails**`Environment.MachineName` is a property read on `System.Environment` (in `System.Runtime`, a metadata-only reference). The canonical JSON would bake whatever machine built the workflow.
**Fix** — For runtime data, bind through a path reference: `WorkflowExpr.Path("start.machineName")`. For literals, use the typed builders: `WorkflowExpr.String("fixed-value")`. For helper routines, put them in a project-referenced class so the analyzer can walk them.
Lambda invocations via `func()` (where `func` is an `Action`/`Func<T>`/custom delegate) are **not** flagged as WF003 — the lambda body is walked inline. `nameof(...)` expressions are also skipped.
### WF004 — `new` on non-trusted type {#wf004}
**Bad**
```csharp
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
.InitializeState(WorkflowExpr.Obj(
WorkflowExpr.Prop("id", WorkflowExpr.String(new Guid().ToString()))))
.Build();
```
**Why it fails**`new Guid()` instantiates a BCL type at workflow-build time; every class construction produces a different Guid, and the canonical JSON is no longer deterministic.
**Fix** — Use a workflow expression. Workflow DTOs (`LegacyRabbitAddress`, `WorkflowBusinessReferenceDeclaration`, `WorkflowNamedExpressionDefinition`, etc.) that live in `StellaOps.Workflow.*` / `Ablera.Serdica.Workflow.*` are trusted and may be instantiated freely.
### WF005 — C# conditional operator in workflow code {#wf005}
**Bad**
```csharp
WorkflowExpr.Prop("route", WorkflowExpr.String(useFastLane ? "fast" : "slow"))
WorkflowExpr.Prop("name", WorkflowExpr.String(input.Name ?? "anonymous"))
WorkflowExpr.Prop("length", WorkflowExpr.Number(input?.Name?.Length ?? 0))
```
**Why it fails**`?:`, `??`, and `?.` resolve at workflow-**build** time. Whichever branch wins when the Spec is constructed is what ends up in the canonical JSON; the runtime condition never enters the workflow at all.
**Fix** — Use canonical equivalents:
```csharp
WorkflowExpr.Prop("route", WorkflowExpr.Func("if",
WorkflowExpr.Path("state.useFastLane"),
WorkflowExpr.String("fast"),
WorkflowExpr.String("slow")))
WorkflowExpr.Prop("name", WorkflowExpr.Func("coalesce",
WorkflowExpr.Path("start.name"),
WorkflowExpr.String("anonymous")))
```
### WF006 — Non-trusted field/property read {#wf006}
**Bad**
```csharp
WorkflowExpr.Prop("year", WorkflowExpr.Number(DateTime.UtcNow.Year))
WorkflowExpr.Prop("cfg", WorkflowExpr.String(MyStaticConfig.CurrentRegion))
```
**Why it fails** — Reading a non-const field or property on a type that isn't in a trusted or source-walkable assembly bakes the read value into the canonical JSON. `DateTime.UtcNow.Year` gives you the year-at-build, not a per-execution value. `MyStaticConfig.CurrentRegion` couples the workflow definition to whatever config was loaded when the plugin assembly initialized.
**What is allowed**
- **Compile-time constants** (`const` fields) on any type. `int.MaxValue` and user `public const string Route = "/x"` are always fine.
- **Static readonly fields** on types in the current compilation or a project reference — the analyzer can walk the initializer and verify it's canonical. This is how `static readonly LegacyRabbitAddress QueryAddress = new("pas_query");` stays legal.
- Any member on a `StellaOps.Workflow.*` / `Ablera.Serdica.Workflow.*` type.
**Fix** — Use `WorkflowExpr.Path(...)` to bind a runtime value, or a `const` on your own class for compile-time constants.
### WF010 — Reachable helper violation {#wf010}
Emitted when a helper method reachable from a workflow's `Spec` (in a project reference) itself contains a WF001WF006 violation. The primary location points at the call site inside the workflow; the additional location points at the offending line inside the helper.
If the helper is in the **same compilation** as the workflow, the underlying rule (e.g., WF001) fires directly at the helper's line instead. WF010 is specifically for cross-project indirection where the analyzer cannot emit diagnostics into the referenced project's source.
**Fix** — Fix the helper. One fix clears the diagnostic at every call site.
## Non-goals for v1
- **No source generator.** The analyzer only validates; the runtime `WorkflowCanonicalDefinitionCompiler` remains the single source of truth for canonical JSON emission. Adding a generator would create two sources of truth that will drift.
- **No severity configuration.** All rules are `Error`. If a workflow would fail canonicalization at runtime, building it should fail too.
- **No escape hatch attribute.** If a real case forces one (e.g., a vetted BCL helper we want to whitelist) it can be added later. Shipping it from day one invites misuse.
## When the analyzer will NOT catch something
- **Reflection** (`Type.GetMethod(...)`, `Activator.CreateInstance(...)`): flagged as WF003 (invocation of metadata-only `System.Reflection`).
- **Runtime-configured workflows built by a service/DI**: the `Spec` property's initializer is the entry point. If the workflow class has a constructor that fetches data before building `Spec`, the analyzer only walks the `Spec` getter — the constructor runs at runtime and is outside scope. Keep workflow definitions pure (no constructor state dependency).
## Quick reference
| ID | Short name | Severity |
|----|------------|----------|
| WF001 | Imperative control flow | Error |
| WF002 | Async/await | Error |
| WF003 | Call into non-trusted assembly | Error |
| WF004 | `new` on non-trusted type | Error |
| WF005 | C# `?:` / `??` / `?.` | Error |
| WF006 | Non-trusted field/property read | Error |
| WF010 | Reachable helper violation (cross-project) | Error |