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.
This commit is contained in:
master
2026-04-15 09:29:08 +03:00
parent b250bb7aff
commit b7acf692b6
2 changed files with 360 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
# 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 |