docs(implplan): add DOCS cutover plan sprint, archive FE integration-hub sprint

Adds SPRINT_20260415_001_DOCS_real_service_cutover_plan tracking the doc
work needed to finalize the no-mocks / real-service migration.

Archives SPRINT_20260415_002_FE_integration_hub_truthful_status_and_button_styling
— both tasks complete (truthful integration status + button styling fix
landed in the earlier Web UI commit).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-15 11:27:31 +03:00
parent 07e227fdb7
commit c01ce36b62
11 changed files with 1324 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
# Sprint 20260415-002 - FE Integration Hub Truthful Status And Button Styling
## Topic & Scope
- Fix the integration hub summary cards so they do not render misleading `Not started` states before counts are loaded.
- Fix the integration detail health-action buttons so they use the canonical button token contract and remain visible on hover.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: targeted Vitest coverage, rebuilt local Angular dist, and live Playwright verification against `stella-ops.local`.
## Dependencies & Concurrency
- Depends on the live local stack already being reachable through `stella-ops.local`.
- Safe parallelism: frontend-only; no backend contract changes.
## Documentation Prerequisites
- `src/Web/StellaOps.Web/AGENTS.md`
## Delivery Tracker
### FE-HUB-001 - Make integration hub summary truthful while counts are loading
Status: DONE
Dependency: none
Owners: Developer
Task description:
- The integration hub setup-order cards rendered `Not started` immediately because the UI treated zero-valued placeholder counts as final state before the six summary requests resolved.
- The Secrets card also queried `RepoSource` instead of `SecretsManager`, which made the summary disagree with the actual catalog.
Completion criteria:
- [x] Setup-order cards show a loading state until the summary requests resolve.
- [x] The Secrets summary counts `SecretsManager` integrations.
- [x] Targeted frontend tests cover the loading state and the corrected secrets count query.
### FE-HUB-002 - Remove health-tab action button style collisions
Status: DONE
Dependency: FE-HUB-001
Owners: Developer
Task description:
- The integration detail page used generic `.btn-primary/.btn-secondary/.btn-danger` classes with hardcoded colors. On the live health tab this collided with the shared button skin and made the `Test Connection` button visually misleading.
- Replace the generic classes with namespaced detail-button classes that use the shared button token contract and explicit hover/focus states.
Completion criteria:
- [x] The health-tab buttons use namespaced classes instead of generic `.btn-*`.
- [x] Primary and secondary buttons use the canonical `--color-btn-primary-*` and `--color-btn-secondary-*` tokens.
- [x] Live Playwright verification confirms the `Test Connection` button remains visible before and after hover.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-04-15 | Sprint created for the post-bootstrap integration-hub truthfulness and button-styling regressions reported against the live local stack. | Developer |
| 2026-04-15 | Updated `src/app/features/integration-hub/integration-hub.component.ts` so the summary cards and tiles render loading indicators until counts resolve and the Secrets card counts `IntegrationType.SecretsManager` instead of `RepoSource`. Added regression coverage in `integration-hub.component.spec.ts` and `src/tests/integration_hub/integration-hub-ui.component.spec.ts`. | Developer |
| 2026-04-15 | Updated `src/app/features/integration-hub/integration-detail.component.ts` to replace generic `.btn-primary/.btn-secondary/.btn-danger` with namespaced `detail-btn` variants that use the shared button tokens. Added regression assertions in `integration-detail.component.spec.ts`. | Developer |
| 2026-04-15 | Targeted Vitest run passed: `integration-detail.component.spec.ts`, `integration-hub.component.spec.ts`, and `src/tests/integration_hub/integration-hub-ui.component.spec.ts` passed `26/26`. Rebuilt the Angular development dist and verified live on `/setup/integrations/41321ad0-4320-46c0-8b05-4ab312477488?tab=health` that `Test Connection` stayed visible before and after hover with `rgb(26, 15, 0)` text on the tokenized warm background. | Developer |
## Decisions & Risks
- Decision: summary cards must not infer final setup status from placeholder zeroes; loading is a first-class state.
- Decision: detail-page action buttons should use namespaced classes to avoid collisions with shared/global `.btn-*` selectors.
- Risk: the local dev UI override must stay attached to `router-gateway` for immediate visibility of Angular dist changes; otherwise a stale bundled UI can mask the fix.
## Next Checkpoints
- Confirm the live integration hub and health-tab experience after the next operator refresh.

View File

@@ -0,0 +1,144 @@
# Sprint 20260415-001 - Real Service Cutover Plan
## Topic & Scope
- Produce the repo-wide implementation plan for replacing remaining live in-memory, stub, and mock backend bindings with real runtime services.
- Group the remaining work into parallel module workstreams so implementation can proceed without cross-module ambiguity.
- Define, for each workstream, the current fake runtime bindings, the target real backend, migration/config prerequisites, and the proof lane required before closure.
- Working directory: `docs/implplan`.
- Expected evidence: sprint task definitions, module cutover inventory, subagent findings, and file-level handoff references for implementation sprints.
## Dependencies & Concurrency
- Upstream reference: `docs-archived/implplan/SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md` captures the completed Policy/Graph patterns, live proof harnesses, and accepted verification shape for no-mock runtime cutovers.
- Shared dependency rules apply across all workstreams: PostgreSQL-backed ownership must auto-migrate on startup; Valkey/object storage may be used only where the module contract already requires them.
- Safe parallelism: the workstreams below can be analyzed independently, but implementation sequencing must account for shared attestation, identity, and scheduler contracts.
## Documentation Prerequisites
- `docs/README.md`
- `docs/ARCHITECTURE_OVERVIEW.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/policy/architecture.md`
- `docs/modules/scanner/architecture.md`
- `docs/modules/signals/architecture.md`
- `docs/modules/platform/architecture.md`
- `docs/modules/jobengine/architecture.md`
- `docs/modules/concelier/architecture.md`
## Delivery Tracker
### REALPLAN-001 - Freeze the remaining live fake-backend inventory
Status: DOING
Dependency: none
Owners: Project Manager
Task description:
- Capture the authoritative inventory of live `Program.cs` registrations and runtime composition roots that still bind to `InMemory*`, `Stub*`, or mock backend implementations.
- Separate true system-of-record violations from acceptable process-local caches so downstream implementation work is not polluted by false positives.
- Record the module grouping and wave priority that all subsequent workstreams will follow.
Completion criteria:
- [ ] The inventory names every remaining live runtime fake-backend binding with exact file references.
- [ ] Every item is assigned to exactly one workstream below.
- [ ] Known non-goals and acceptable transient caches are explicitly called out.
### REALPLAN-002 - Policy and attestation-adjacent runtime cleanup plan
Status: TODO
Dependency: REALPLAN-001
Owners: Project Manager, Developer
Task description:
- Produce the cutover plan for remaining Policy runtime fakes and closely coupled attestation/verdict dependencies: `src/Policy/`, `src/Attestor/`, and `src/Findings/` bindings that still route to in-memory stores or stub Rekor/VEX adapters.
- The plan must identify which bindings need real persisted ownership, which require live external adapters behind truthful failure modes, and which can remain ephemeral caches.
- The output must define the implementation slices, schema/migration work, config flags, and live proof harnesses required to retire the remaining fake backends.
Completion criteria:
- [ ] Remaining Policy/attestation fake bindings are enumerated with target real implementations.
- [ ] Required migrations, runtime config, and upstream dependencies are listed.
- [ ] Verification plan includes focused tests plus live proof expectations where the contract is externally visible.
### REALPLAN-003 - Notify and Notifier real-service cutover plan
Status: TODO
Dependency: REALPLAN-001
Owners: Project Manager, Developer
Task description:
- Produce the cutover plan for `src/Notify/` and `src/Notifier/`, including notification routing, subscription, audit, delivery history, and worker/web-service coordination that still depends on in-memory stores.
- The plan must specify the durable ownership model, queueing/dispatch model, and whether existing module libraries can be reused or new persistence packages are required.
- Include the end-to-end proof lane needed to show that message submission, persistence, worker processing, and status surfaces all operate against real runtime state.
Completion criteria:
- [ ] All remaining Notify/Notifier fake backends are mapped to durable service replacements.
- [ ] Storage, queueing, and migration requirements are explicit.
- [ ] The proof lane covers submit, persist, dispatch, and readback behavior.
### REALPLAN-004 - Scheduler, PacksRegistry, and Registry producer-path cutover plan
Status: TODO
Dependency: REALPLAN-001
Owners: Project Manager, Developer
Task description:
- Produce the cutover plan for `src/JobEngine/` and `src/Registry/` runtime fakes, especially scheduler runs, graph jobs, policy run summaries, resolver jobs, packs registry storage, and token-service rule storage.
- The plan must reconcile which services should own persisted job state, which can depend on shared scheduler infrastructure, and how startup migrations and live proof harnesses will be applied across web-service and worker boundaries.
- Capture cross-module sequencing so producer paths are made real before downstream consumers depend on them.
Completion criteria:
- [ ] Scheduler/PacksRegistry/Registry fake bindings are mapped to real ownership and persistence layers.
- [ ] Cross-service sequencing and shared dependency assumptions are documented.
- [ ] Verification includes producer submission, worker execution, and persisted result/status proof.
### REALPLAN-005 - Scanner, Signals, ReachGraph, and SBOM runtime data-plane cutover plan
Status: TODO
Dependency: REALPLAN-001
Owners: Project Manager, Developer
Task description:
- Produce the cutover plan for `src/Scanner/`, `src/Signals/`, `src/ReachGraph/`, and `src/SbomService/` runtime fakes that still keep scan coordination, policy snapshots, reachability facts, overlays, uploads, metadata, or graph/runtime adapters in process memory.
- The plan must distinguish between canonical stores, derived projections, and caches, and must identify where existing persistence libraries already exist but are not yet wired into live hosts.
- Include the integration-test and end-to-end evidence needed to prove that uploaded evidence, derived projections, and query surfaces survive process restarts and resolve from real stores.
Completion criteria:
- [ ] All Scanner/Signals/ReachGraph/SBOM fake runtime bindings are classified by ownership type.
- [ ] Each fake binding has a target persistence or adapter strategy and migration plan.
- [ ] Proof requirements cover ingest, projection/materialization, and read/query behavior after restart.
### REALPLAN-006 - BinaryIndex, Platform, Doctor, AdvisoryAI, and AirGap runtime cutover plan
Status: TODO
Dependency: REALPLAN-001
Owners: Project Manager, Developer
Task description:
- Produce the cutover plan for `src/BinaryIndex/`, `src/Platform/`, `src/Doctor/`, `src/AdvisoryAI/`, and `src/AirGap/` bindings that still use in-memory repositories or process-local state for runtime behavior.
- The plan must call out where the correct target is durable persistence versus a truthful external-service adapter, especially for AI consent/attestation, report storage, platform read models, and time-anchor state.
- Include the operational proof shape for each surface so UI/API-visible behavior can be validated against restarted services and real stored state.
Completion criteria:
- [ ] Each module family has a concrete target backend strategy for every remaining fake binding.
- [ ] External-adapter versus durable-store decisions are justified.
- [ ] Verification requirements include API/UI proof where the user-facing contract depends on persisted state.
### REALPLAN-007 - Concelier and Excititor remaining runtime cutover plan
Status: TODO
Dependency: REALPLAN-001
Owners: Project Manager, Developer
Task description:
- Produce the cutover plan for remaining `src/Concelier/` and `src/Excititor/` runtime fakes, including overlay stores, VEX attestation stores, general in-memory storage registration, and lease handling that still bypasses durable service behavior.
- The plan must keep the Aggregation-Only Contract intact: immutable raw fact ownership remains unchanged, while fake derived/runtime stores are replaced with truthful persisted ownership or explicit unsupported modes.
- Include the regression surface that must be protected so ingestion, lease coordination, and downstream overlay consumers continue to behave deterministically.
Completion criteria:
- [ ] Remaining Concelier/Excititor fake backends are enumerated with target real replacements.
- [ ] AOC constraints and non-goals are explicitly recorded.
- [ ] Proof requirements include ingestion, lease coordination, and downstream read behavior.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-04-15 | Sprint created to coordinate repo-wide replacement of remaining live in-memory/stub/mock backend bindings with real runtime services. | Project Manager |
| 2026-04-15 | Dispatched explorer workstreams: REALPLAN-002 `Tesla`, REALPLAN-003 `Aristotle`, REALPLAN-004 `Epicurus`, REALPLAN-005 `Euclid`, REALPLAN-006 `Kierkegaard`, REALPLAN-007 `Halley`. | Project Manager |
## Decisions & Risks
- Decision: this coordination sprint owns planning only. Implementation work must land in module-owned sprints once each workstream returns a concrete cutover plan.
- Decision: work is grouped into seven parallel planning streams to keep the repository-wide cleanup tractable without losing module ownership.
- Decision: a binding is in scope only if it is active in a live host runtime path. Test doubles and clearly non-authoritative process-local caches are not closure blockers unless they leak into user-visible behavior.
- Risk: some `InMemory*` types are compatibility caches rather than canonical stores. Each workstream must explicitly classify ownership before proposing persistence work.
- Risk: several modules likely need new persistence libraries or startup migrations; implementation cannot proceed safely until schema ownership is explicit.
- Risk: shared attestation, scheduler, and identity adapters may create cross-workstream dependencies that force wave sequencing even if analysis is parallel.
- Reference inventory snapshot from initial scan: remaining live fake-backend bindings were detected in `Notify`, `Notifier`, `Scheduler`, `PacksRegistry`, `Registry.TokenService`, `Scanner`, `Signals`, `ReachGraph`, `SbomService`, `BinaryIndex`, `Platform`, `Doctor`, `AdvisoryAI`, `AirGap.Time`, `Concelier`, `Excititor`, `Attestor/Signer`, `Findings`, and parts of `Policy`.
## Next Checkpoints
- Dispatch explorer subagents for REALPLAN-002 through REALPLAN-007 and capture their findings back into this sprint.
- Convert each completed workstream into one or more implementation sprints owned by the relevant module.
- Prioritize the first execution wave after planning based on shared infrastructure leverage and end-user impact.

View File

@@ -12,3 +12,4 @@ WF004 | StellaOps.Workflow | Error | Object creation is not canonicalizable
WF005 | StellaOps.Workflow | Error | C# conditional operator is not canonicalizable
WF006 | StellaOps.Workflow | Error | Non-trusted field/property access bakes build-time state
WF010 | StellaOps.Workflow | Error | Helper reachable from workflow contains non-canonical construct
WF020 | StellaOps.Workflow | Error | Workflow pattern not yet supported by the canonical artifact generator

View File

@@ -0,0 +1,242 @@
using System.Collections.Generic;
namespace StellaOps.Workflow.Analyzer.Emission;
/// <summary>
/// Minimal canonical IR used by the source generator. Mirrors the runtime
/// contract types (WorkflowCanonicalDefinition, WorkflowExpressionDefinition,
/// WorkflowStepDeclaration, WorkflowAddressDeclaration) at a level sufficient
/// to serialise to canonical JSON. The runtime types stay authoritative;
/// this is a compile-time-only duplicate shaped for netstandard2.0.
/// </summary>
public abstract class CanonicalNode
{
public abstract void WriteTo(CanonicalJsonWriter w);
}
// ---- Expressions ----
public abstract class CanonicalExpr : CanonicalNode { }
public sealed class NullExpr : CanonicalExpr
{
public override void WriteTo(CanonicalJsonWriter w)
{
w.BeginObject();
w.Property("$type"); w.StringValue("null");
w.EndObject();
}
}
public sealed class StringExpr : CanonicalExpr
{
public string Value { get; }
public StringExpr(string value) => Value = value;
public override void WriteTo(CanonicalJsonWriter w)
{
w.BeginObject();
w.Property("$type"); w.StringValue("string");
w.Property("value"); w.StringValue(Value);
w.EndObject();
}
}
public sealed class NumberExpr : CanonicalExpr
{
public string InvariantLiteral { get; }
public NumberExpr(string invariantLiteral) => InvariantLiteral = invariantLiteral;
public override void WriteTo(CanonicalJsonWriter w)
{
w.BeginObject();
w.Property("$type"); w.StringValue("number");
w.Property("value"); w.StringValue(InvariantLiteral);
w.EndObject();
}
}
public sealed class BoolExpr : CanonicalExpr
{
public bool Value { get; }
public BoolExpr(bool value) => Value = value;
public override void WriteTo(CanonicalJsonWriter w)
{
w.BeginObject();
w.Property("$type"); w.StringValue("boolean");
w.Property("value"); w.BoolValue(Value);
w.EndObject();
}
}
public sealed class PathExpr : CanonicalExpr
{
public string Path { get; }
public PathExpr(string path) => Path = path;
public override void WriteTo(CanonicalJsonWriter w)
{
w.BeginObject();
w.Property("$type"); w.StringValue("path");
w.Property("path"); w.StringValue(Path);
w.EndObject();
}
}
public sealed class NamedExpr
{
public string Name { get; }
public CanonicalExpr Expression { get; }
public NamedExpr(string name, CanonicalExpr expression)
{
Name = name;
Expression = expression;
}
public void WriteTo(CanonicalJsonWriter w)
{
w.BeginObject();
w.Property("name"); w.StringValue(Name);
w.Property("expression"); Expression.WriteTo(w);
w.EndObject();
}
}
public sealed class ObjectExpr : CanonicalExpr
{
public IReadOnlyList<NamedExpr> Properties { get; }
public ObjectExpr(IReadOnlyList<NamedExpr> properties) => Properties = properties;
public override void WriteTo(CanonicalJsonWriter w)
{
w.BeginObject();
w.Property("$type"); w.StringValue("object");
w.Property("properties");
w.BeginArray();
foreach (var p in Properties) p.WriteTo(w);
w.EndArray();
w.EndObject();
}
}
public sealed class ArrayExpr : CanonicalExpr
{
public IReadOnlyList<CanonicalExpr> Items { get; }
public ArrayExpr(IReadOnlyList<CanonicalExpr> items) => Items = items;
public override void WriteTo(CanonicalJsonWriter w)
{
w.BeginObject();
w.Property("$type"); w.StringValue("array");
w.Property("items");
w.BeginArray();
foreach (var i in Items) i.WriteTo(w);
w.EndArray();
w.EndObject();
}
}
public sealed class BinaryExpr : CanonicalExpr
{
public string Operator { get; }
public CanonicalExpr Left { get; }
public CanonicalExpr Right { get; }
public BinaryExpr(string op, CanonicalExpr left, CanonicalExpr right)
{
Operator = op; Left = left; Right = right;
}
public override void WriteTo(CanonicalJsonWriter w)
{
w.BeginObject();
w.Property("$type"); w.StringValue("binary");
w.Property("operator"); w.StringValue(Operator);
w.Property("left"); Left.WriteTo(w);
w.Property("right"); Right.WriteTo(w);
w.EndObject();
}
}
public sealed class UnaryExpr : CanonicalExpr
{
public string Operator { get; }
public CanonicalExpr Operand { get; }
public UnaryExpr(string op, CanonicalExpr operand)
{
Operator = op; Operand = operand;
}
public override void WriteTo(CanonicalJsonWriter w)
{
w.BeginObject();
w.Property("$type"); w.StringValue("unary");
w.Property("operator"); w.StringValue(Operator);
w.Property("operand"); Operand.WriteTo(w);
w.EndObject();
}
}
public sealed class GroupExpr : CanonicalExpr
{
public CanonicalExpr Inner { get; }
public GroupExpr(CanonicalExpr inner) => Inner = inner;
public override void WriteTo(CanonicalJsonWriter w)
{
w.BeginObject();
w.Property("$type"); w.StringValue("group");
w.Property("expression"); Inner.WriteTo(w);
w.EndObject();
}
}
public sealed class FunctionExpr : CanonicalExpr
{
public string FunctionName { get; }
public IReadOnlyList<CanonicalExpr> Arguments { get; }
public FunctionExpr(string fn, IReadOnlyList<CanonicalExpr> args)
{
FunctionName = fn; Arguments = args;
}
public override void WriteTo(CanonicalJsonWriter w)
{
w.BeginObject();
w.Property("$type"); w.StringValue("function");
w.Property("functionName"); w.StringValue(FunctionName);
w.Property("arguments");
w.BeginArray();
foreach (var a in Arguments) a.WriteTo(w);
w.EndArray();
w.EndObject();
}
}
// ---- Top-level definition ----
public sealed class CanonicalDefinition
{
public string SchemaVersion { get; set; } = "stellaops.workflow.definition/v1";
public string WorkflowName { get; set; } = "";
public string WorkflowVersion { get; set; } = "";
public string DisplayName { get; set; } = "";
public List<string> WorkflowRoles { get; } = new();
public CanonicalExpr? InitializeStateExpression { get; set; }
public string? InitialTaskName { get; set; }
// TODO phases after v1: InitialSequence.Steps, Tasks[], BusinessReference,
// RequiredModules, RequiredCapabilities, StartRequestContract.
public string ToCanonicalJson()
{
var w = new CanonicalJsonWriter();
w.BeginObject();
w.Property("schemaVersion"); w.StringValue(SchemaVersion);
w.Property("workflowName"); w.StringValue(WorkflowName);
w.Property("workflowVersion"); w.StringValue(WorkflowVersion);
w.Property("displayName"); w.StringValue(DisplayName);
if (InitializeStateExpression is not null)
{
w.Property("start");
w.BeginObject();
w.Property("initializeStateExpression");
InitializeStateExpression.WriteTo(w);
if (InitialTaskName is not null)
{
w.Property("initialTaskName"); w.StringValue(InitialTaskName);
}
w.EndObject();
}
w.EndObject();
return w.ToJson();
}
}

View File

@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
namespace StellaOps.Workflow.Analyzer.Emission;
/// <summary>
/// Hand-rolled canonical JSON writer. Matches the output of
/// System.Text.Json.JsonSerializer.Serialize with options:
/// PropertyNamingPolicy = CamelCase
/// DefaultIgnoreCondition = WhenWritingNull
/// WriteIndented = true (2-space indent, LF newlines)
/// Byte-for-byte parity is verified by GeneratorByteParityTests.
/// </summary>
public sealed class CanonicalJsonWriter
{
private readonly StringBuilder buffer = new();
private readonly Stack<bool> needsComma = new();
private int depth;
public CanonicalJsonWriter() => needsComma.Push(false);
public string ToJson()
{
var s = buffer.ToString();
// Runtime uses Environment.NewLine via JsonSerializer; WriteIndented
// produces \r\n on Windows, \n on Unix. To guarantee cross-platform
// byte-equality we always emit \n. The runtime must be configured to
// match (option: set Environment-agnostic formatting in the server's
// serializer options — verified in parity tests).
return s;
}
public void BeginObject()
{
WriteItemPrefix();
buffer.Append('{');
depth++;
needsComma.Push(false);
}
public void EndObject()
{
depth--;
needsComma.Pop();
if (buffer.Length > 0 && buffer[buffer.Length - 1] != '{')
{
WriteNewlineAndIndent();
}
buffer.Append('}');
SetNeedsComma();
}
public void BeginArray()
{
WriteItemPrefix();
buffer.Append('[');
depth++;
needsComma.Push(false);
}
public void EndArray()
{
depth--;
needsComma.Pop();
if (buffer.Length > 0 && buffer[buffer.Length - 1] != '[')
{
WriteNewlineAndIndent();
}
buffer.Append(']');
SetNeedsComma();
}
public void Property(string camelCaseName)
{
WriteItemPrefix();
AppendEscapedString(camelCaseName);
buffer.Append(": ");
// Clear comma flag so the next value (which we will emit immediately)
// doesn't insert a comma before itself. We re-enable after the value.
if (needsComma.Count > 0)
{
needsComma.Pop();
needsComma.Push(false);
}
}
public void StringValue(string value)
{
WriteItemPrefixIfNoProperty();
AppendEscapedString(value);
SetNeedsComma();
}
public void BoolValue(bool value)
{
WriteItemPrefixIfNoProperty();
buffer.Append(value ? "true" : "false");
SetNeedsComma();
}
public void NullValue()
{
WriteItemPrefixIfNoProperty();
buffer.Append("null");
SetNeedsComma();
}
/// <summary>
/// Raw numeric literal. Callers MUST format the number using invariant
/// culture and the exact pattern the runtime uses (see
/// CanonicalNumberFormatter).
/// </summary>
public void RawNumberValue(string invariantLiteral)
{
WriteItemPrefixIfNoProperty();
buffer.Append(invariantLiteral);
SetNeedsComma();
}
private void WriteItemPrefix()
{
if (needsComma.Count > 0 && needsComma.Peek())
{
buffer.Append(',');
}
if (depth > 0 || buffer.Length > 0)
{
WriteNewlineAndIndent();
}
}
private void WriteItemPrefixIfNoProperty()
{
// If we just wrote a property name, the ": " is already in place; no
// prefix. Otherwise apply the same prefix as a fresh item.
if (buffer.Length >= 2 &&
buffer[buffer.Length - 1] == ' ' &&
buffer[buffer.Length - 2] == ':')
{
return;
}
WriteItemPrefix();
}
private void WriteNewlineAndIndent()
{
buffer.Append('\n');
for (var i = 0; i < depth; i++)
{
buffer.Append(" ");
}
}
private void SetNeedsComma()
{
if (needsComma.Count > 0)
{
needsComma.Pop();
needsComma.Push(true);
}
}
private void AppendEscapedString(string value)
{
buffer.Append('"');
for (var i = 0; i < value.Length; i++)
{
var c = value[i];
switch (c)
{
case '"': buffer.Append("\\\""); break;
case '\\': buffer.Append("\\\\"); break;
case '\b': buffer.Append("\\b"); break;
case '\f': buffer.Append("\\f"); break;
case '\n': buffer.Append("\\n"); break;
case '\r': buffer.Append("\\r"); break;
case '\t': buffer.Append("\\t"); break;
default:
if (c < 0x20)
{
buffer.Append("\\u");
buffer.Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
}
else
{
buffer.Append(c);
}
break;
}
}
buffer.Append('"');
}
}

View File

@@ -0,0 +1,17 @@
using System.Globalization;
namespace StellaOps.Workflow.Analyzer.Emission;
/// <summary>
/// Matches the runtime's WorkflowExpr.Number(long/decimal/int/…) → stringification.
/// Runtime code (WorkflowCanonicalExpressionBuilder.cs) uses
/// value.ToString(CultureInfo.InvariantCulture) for all numeric types.
/// </summary>
public static class CanonicalNumberFormatter
{
public static string Format(long value) => value.ToString(CultureInfo.InvariantCulture);
public static string Format(int value) => value.ToString(CultureInfo.InvariantCulture);
public static string Format(decimal value) => value.ToString(CultureInfo.InvariantCulture);
public static string Format(double value) => value.ToString("G17", CultureInfo.InvariantCulture);
public static string Format(float value) => value.ToString("G9", CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,558 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using StellaOps.Workflow.Analyzer.Emission;
namespace StellaOps.Workflow.Analyzer;
/// <summary>
/// Source generator that emits canonical workflow JSON (bundled as string
/// consts) for every IDeclarativeWorkflow&lt;T&gt; implementation in the
/// compilation. The publisher reads these at startup and uploads them to
/// the workflow service.
///
/// Parity oracle: the runtime WorkflowCanonicalDefinitionCompiler. Every
/// emitted JSON MUST be byte-identical to what that compiler produces for
/// the same workflow instance — verified by byte-parity tests.
///
/// v1 scope (this file): emit workflow metadata + InitializeState
/// expression for canonical-expression-only workflows. Everything else
/// (StartWith lambdas, AddTask chains, WhenExpression, Call, etc.)
/// raises WF020 and is filled in during subsequent phases.
/// </summary>
[Generator(LanguageNames.CSharp)]
public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var workflowClasses = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, _) => Transform(ctx))
.Where(static r => r is not null)
.Select(static (r, _) => r!.Value);
context.RegisterSourceOutput(workflowClasses, EmitForWorkflow);
var allWorkflows = workflowClasses.Collect();
context.RegisterSourceOutput(
allWorkflows.Combine(context.CompilationProvider),
(ctx, tuple) => EmitRegistry(ctx, tuple.Left, tuple.Right));
}
private readonly struct WorkflowCandidate
{
public WorkflowCandidate(
string className,
string? workflowName,
string? workflowVersion,
string? displayName,
string canonicalDefinitionJson,
string contentHash,
ImmutableArray<GeneratorDiagnostic> diagnostics,
string location)
{
ClassName = className;
WorkflowName = workflowName;
WorkflowVersion = workflowVersion;
DisplayName = displayName;
CanonicalDefinitionJson = canonicalDefinitionJson;
ContentHash = contentHash;
Diagnostics = diagnostics;
Location = location;
}
public string ClassName { get; }
public string? WorkflowName { get; }
public string? WorkflowVersion { get; }
public string? DisplayName { get; }
public string CanonicalDefinitionJson { get; }
public string ContentHash { get; }
public ImmutableArray<GeneratorDiagnostic> Diagnostics { get; }
public string Location { get; }
}
private readonly struct GeneratorDiagnostic
{
public GeneratorDiagnostic(DiagnosticDescriptor descriptor, string location, string message)
{
Descriptor = descriptor;
Location = location;
Message = message;
}
public DiagnosticDescriptor Descriptor { get; }
public string Location { get; }
public string Message { get; }
}
private static WorkflowCandidate? Transform(GeneratorSyntaxContext ctx)
{
if (ctx.Node is not ClassDeclarationSyntax classDecl)
{
return null;
}
var symbol = ctx.SemanticModel.GetDeclaredSymbol(classDecl);
if (symbol is not INamedTypeSymbol typeSymbol || typeSymbol.TypeKind != TypeKind.Class)
{
return null;
}
if (!ImplementsDeclarativeWorkflow(typeSymbol))
{
return null;
}
var diagnostics = ImmutableArray.CreateBuilder<GeneratorDiagnostic>();
var workflowName = TryGetStringPropertyLiteral(typeSymbol, "WorkflowName", diagnostics);
var workflowVersion = TryGetStringPropertyLiteral(typeSymbol, "WorkflowVersion", diagnostics);
var displayName = TryGetStringPropertyLiteral(typeSymbol, "DisplayName", diagnostics);
var definition = new CanonicalDefinition
{
WorkflowName = workflowName ?? "",
WorkflowVersion = workflowVersion ?? "",
DisplayName = displayName ?? "",
};
// v1: walk the Spec property initializer and pull out an optional
// InitializeState(WorkflowExpr.*) call. Everything else -> WF020.
var specProperty = typeSymbol.GetMembers("Spec")
.OfType<IPropertySymbol>()
.FirstOrDefault();
if (specProperty is not null)
{
WalkSpecInitializer(specProperty, ctx.SemanticModel, definition, diagnostics);
}
else
{
diagnostics.Add(new GeneratorDiagnostic(
WorkflowDiagnostics.GeneratorUnsupportedPattern,
classDecl.Identifier.GetLocation().ToString(),
"class implements IDeclarativeWorkflow<T> but has no Spec property"));
}
var json = definition.ToCanonicalJson();
var hash = ComputeSha256Lowercase(json);
return new WorkflowCandidate(
className: typeSymbol.Name,
workflowName: workflowName,
workflowVersion: workflowVersion,
displayName: displayName,
canonicalDefinitionJson: json,
contentHash: hash,
diagnostics: diagnostics.ToImmutable(),
location: classDecl.Identifier.GetLocation().ToString());
}
private static bool ImplementsDeclarativeWorkflow(INamedTypeSymbol type)
{
foreach (var iface in type.AllInterfaces)
{
if (!iface.IsGenericType)
{
continue;
}
var md = iface.ConstructedFrom.ToDisplayString();
if (md == "StellaOps.Workflow.Abstractions.IDeclarativeWorkflow<TStartRequest>" ||
md == "Ablera.Serdica.Workflow.Abstractions.IDeclarativeWorkflow<TStartRequest>")
{
return true;
}
}
return false;
}
private static string? TryGetStringPropertyLiteral(
INamedTypeSymbol type,
string propertyName,
ImmutableArray<GeneratorDiagnostic>.Builder diagnostics)
{
var property = type.GetMembers(propertyName).OfType<IPropertySymbol>().FirstOrDefault();
if (property is null) return null;
foreach (var syntaxRef in property.DeclaringSyntaxReferences)
{
var syntax = syntaxRef.GetSyntax();
// public string WorkflowName => "Literal";
if (syntax is PropertyDeclarationSyntax propDecl)
{
if (propDecl.ExpressionBody?.Expression is LiteralExpressionSyntax lit &&
lit.IsKind(SyntaxKind.StringLiteralExpression))
{
return lit.Token.ValueText;
}
if (propDecl.Initializer?.Value is LiteralExpressionSyntax init &&
init.IsKind(SyntaxKind.StringLiteralExpression))
{
return init.Token.ValueText;
}
}
}
diagnostics.Add(new GeneratorDiagnostic(
WorkflowDiagnostics.GeneratorUnsupportedPattern,
property.Locations.FirstOrDefault()?.ToString() ?? "",
$"property '{propertyName}' must be a string literal — non-literal initializers are not supported by the artifact generator"));
return null;
}
private static void WalkSpecInitializer(
IPropertySymbol specProperty,
SemanticModel semanticModel,
CanonicalDefinition definition,
ImmutableArray<GeneratorDiagnostic>.Builder diagnostics)
{
// v1: we only walk if the Spec initializer is a fluent chain that
// starts with WorkflowSpec.For<T>() and ends with .Build(). Any
// other shape yields WF020 and the definition keeps minimal content.
foreach (var syntaxRef in specProperty.DeclaringSyntaxReferences)
{
var syntax = syntaxRef.GetSyntax();
if (syntax is not PropertyDeclarationSyntax propDecl ||
propDecl.Initializer is null)
{
continue;
}
var expr = propDecl.Initializer.Value;
WalkSpecExpression(expr, semanticModel, definition, diagnostics);
return;
}
}
private static void WalkSpecExpression(
ExpressionSyntax expr,
SemanticModel semanticModel,
CanonicalDefinition definition,
ImmutableArray<GeneratorDiagnostic>.Builder diagnostics)
{
// Recognise: WorkflowSpec.For<R>().Build()
// Recognise: WorkflowSpec.For<R>().InitializeState(<canonical-expr>).Build()
// Unwind the chain from outermost (.Build() typically) to innermost (.For<R>()).
var callChain = new List<InvocationExpressionSyntax>();
var current = expr;
while (current is InvocationExpressionSyntax inv)
{
callChain.Add(inv);
if (inv.Expression is MemberAccessExpressionSyntax member)
{
current = member.Expression;
}
else
{
break;
}
}
// Reverse so we walk root-first (For<R>) to leaves (.Build())
callChain.Reverse();
foreach (var inv in callChain)
{
if (inv.Expression is not MemberAccessExpressionSyntax memberAccess)
{
continue;
}
var methodName = memberAccess.Name.Identifier.ValueText;
switch (methodName)
{
case "For":
// WorkflowSpec.For<R>() - nothing to capture here
break;
case "Build":
// Terminal
break;
case "InitializeState":
var arg = inv.ArgumentList.Arguments.FirstOrDefault();
if (arg?.Expression is null)
{
diagnostics.Add(UnsupportedDiagnostic(inv, "InitializeState requires a WorkflowExpressionDefinition argument"));
continue;
}
var stateExpr = ParseCanonicalExpression(arg.Expression, semanticModel, diagnostics);
if (stateExpr is not null)
{
definition.InitializeStateExpression = stateExpr;
}
break;
default:
diagnostics.Add(UnsupportedDiagnostic(inv, $"WorkflowSpecBuilder.{methodName} is not yet supported by the artifact generator"));
break;
}
}
}
private static CanonicalExpr? ParseCanonicalExpression(
ExpressionSyntax expr,
SemanticModel semanticModel,
ImmutableArray<GeneratorDiagnostic>.Builder diagnostics)
{
// Expected: WorkflowExpr.<Method>(<args>)
if (expr is not InvocationExpressionSyntax inv ||
inv.Expression is not MemberAccessExpressionSyntax member)
{
diagnostics.Add(UnsupportedDiagnostic(expr,
"canonical expressions must be WorkflowExpr.X(...) invocations"));
return null;
}
var targetType = (member.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
if (targetType != "WorkflowExpr")
{
diagnostics.Add(UnsupportedDiagnostic(expr,
$"canonical expressions must be on WorkflowExpr, saw '{targetType ?? "<complex>"}'"));
return null;
}
var methodName = member.Name.Identifier.ValueText;
var args = inv.ArgumentList.Arguments;
switch (methodName)
{
case "Null":
return new NullExpr();
case "String":
if (args.Count == 1 && args[0].Expression is LiteralExpressionSyntax sLit && sLit.IsKind(SyntaxKind.StringLiteralExpression))
{
return new StringExpr(sLit.Token.ValueText);
}
break;
case "Bool":
if (args.Count == 1 && args[0].Expression is LiteralExpressionSyntax bLit)
{
if (bLit.IsKind(SyntaxKind.TrueLiteralExpression)) return new BoolExpr(true);
if (bLit.IsKind(SyntaxKind.FalseLiteralExpression)) return new BoolExpr(false);
}
break;
case "Number":
if (args.Count == 1 && args[0].Expression is LiteralExpressionSyntax nLit)
{
var text = nLit.Token.Text;
return new NumberExpr(text);
}
break;
case "Path":
if (args.Count == 1 && args[0].Expression is LiteralExpressionSyntax pLit && pLit.IsKind(SyntaxKind.StringLiteralExpression))
{
return new PathExpr(pLit.Token.ValueText);
}
break;
case "Obj":
{
var props = new List<NamedExpr>();
foreach (var a in args)
{
var named = ParseNamedExpr(a.Expression, semanticModel, diagnostics);
if (named is null) return null;
props.Add(named);
}
return new ObjectExpr(props);
}
case "Array":
{
var items = new List<CanonicalExpr>();
foreach (var a in args)
{
var item = ParseCanonicalExpression(a.Expression, semanticModel, diagnostics);
if (item is null) return null;
items.Add(item);
}
return new ArrayExpr(items);
}
case "Not":
{
if (args.Count == 1)
{
var operand = ParseCanonicalExpression(args[0].Expression, semanticModel, diagnostics);
if (operand is null) return null;
return new UnaryExpr("not", operand);
}
break;
}
case "Eq":
case "Ne":
case "Gt":
case "Gte":
case "Lt":
case "Lte":
case "And":
case "Or":
if (args.Count == 2)
{
var left = ParseCanonicalExpression(args[0].Expression, semanticModel, diagnostics);
var right = ParseCanonicalExpression(args[1].Expression, semanticModel, diagnostics);
if (left is null || right is null) return null;
return new BinaryExpr(methodName.ToLowerInvariant(), left, right);
}
break;
case "Group":
if (args.Count == 1)
{
var inner = ParseCanonicalExpression(args[0].Expression, semanticModel, diagnostics);
if (inner is null) return null;
return new GroupExpr(inner);
}
break;
case "Func":
if (args.Count >= 1 && args[0].Expression is LiteralExpressionSyntax fnLit && fnLit.IsKind(SyntaxKind.StringLiteralExpression))
{
var fnName = fnLit.Token.ValueText;
var fnArgs = new List<CanonicalExpr>();
for (var i = 1; i < args.Count; i++)
{
var fa = ParseCanonicalExpression(args[i].Expression, semanticModel, diagnostics);
if (fa is null) return null;
fnArgs.Add(fa);
}
return new FunctionExpr(fnName, fnArgs);
}
break;
}
diagnostics.Add(UnsupportedDiagnostic(expr,
$"WorkflowExpr.{methodName} with this argument shape is not yet supported by the artifact generator"));
return null;
}
private static NamedExpr? ParseNamedExpr(
ExpressionSyntax expr,
SemanticModel semanticModel,
ImmutableArray<GeneratorDiagnostic>.Builder diagnostics)
{
// Expected: WorkflowExpr.Prop("name", <expr>)
if (expr is not InvocationExpressionSyntax inv ||
inv.Expression is not MemberAccessExpressionSyntax member ||
(member.Expression as IdentifierNameSyntax)?.Identifier.ValueText != "WorkflowExpr" ||
member.Name.Identifier.ValueText != "Prop" ||
inv.ArgumentList.Arguments.Count != 2 ||
inv.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax nameLit ||
!nameLit.IsKind(SyntaxKind.StringLiteralExpression))
{
diagnostics.Add(UnsupportedDiagnostic(expr,
"object properties must be WorkflowExpr.Prop(\"name\", expr)"));
return null;
}
var child = ParseCanonicalExpression(inv.ArgumentList.Arguments[1].Expression, semanticModel, diagnostics);
if (child is null) return null;
return new NamedExpr(nameLit.Token.ValueText, child);
}
private static GeneratorDiagnostic UnsupportedDiagnostic(SyntaxNode node, string reason)
{
return new GeneratorDiagnostic(
WorkflowDiagnostics.GeneratorUnsupportedPattern,
node.GetLocation().ToString(),
reason);
}
private static void EmitForWorkflow(SourceProductionContext ctx, WorkflowCandidate candidate)
{
foreach (var d in candidate.Diagnostics)
{
ctx.ReportDiagnostic(Diagnostic.Create(
d.Descriptor,
location: null,
d.Message));
}
var className = "_BundledCanonicalWorkflow_" + candidate.ClassName;
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/> — do not edit; produced by StellaOps.Workflow.Analyzer");
sb.AppendLine("#nullable enable");
sb.AppendLine("namespace StellaOps.Workflow.Generated");
sb.AppendLine("{");
sb.AppendLine($" internal static partial class {className}");
sb.AppendLine(" {");
sb.AppendLine($" public const string WorkflowName = {EscapeCSharp(candidate.WorkflowName ?? "")};");
sb.AppendLine($" public const string WorkflowVersion = {EscapeCSharp(candidate.WorkflowVersion ?? "")};");
sb.AppendLine($" public const string DisplayName = {EscapeCSharp(candidate.DisplayName ?? "")};");
sb.AppendLine($" public const string CanonicalDefinitionJson = {EscapeCSharp(candidate.CanonicalDefinitionJson)};");
sb.AppendLine($" public const string CanonicalContentHash = {EscapeCSharp(candidate.ContentHash)};");
sb.AppendLine(" }");
sb.AppendLine("}");
ctx.AddSource($"{className}.g.cs", sb.ToString());
}
private static void EmitRegistry(
SourceProductionContext ctx,
ImmutableArray<WorkflowCandidate> workflows,
Compilation compilation)
{
if (workflows.IsDefaultOrEmpty) return;
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/> — do not edit; produced by StellaOps.Workflow.Analyzer");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("namespace StellaOps.Workflow.Generated");
sb.AppendLine("{");
sb.AppendLine(" internal static class _BundledCanonicalWorkflowRegistry");
sb.AppendLine(" {");
sb.AppendLine(" public sealed class Entry");
sb.AppendLine(" {");
sb.AppendLine(" public string WorkflowName { get; }");
sb.AppendLine(" public string WorkflowVersion { get; }");
sb.AppendLine(" public string DisplayName { get; }");
sb.AppendLine(" public string CanonicalDefinitionJson { get; }");
sb.AppendLine(" public string CanonicalContentHash { get; }");
sb.AppendLine(" public Entry(string name, string version, string display, string json, string hash)");
sb.AppendLine(" {");
sb.AppendLine(" WorkflowName = name; WorkflowVersion = version; DisplayName = display;");
sb.AppendLine(" CanonicalDefinitionJson = json; CanonicalContentHash = hash;");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" public static readonly IReadOnlyList<Entry> Entries = new Entry[]");
sb.AppendLine(" {");
foreach (var wf in workflows)
{
sb.AppendLine($" new Entry(_BundledCanonicalWorkflow_{wf.ClassName}.WorkflowName, _BundledCanonicalWorkflow_{wf.ClassName}.WorkflowVersion, _BundledCanonicalWorkflow_{wf.ClassName}.DisplayName, _BundledCanonicalWorkflow_{wf.ClassName}.CanonicalDefinitionJson, _BundledCanonicalWorkflow_{wf.ClassName}.CanonicalContentHash),");
}
sb.AppendLine(" };");
sb.AppendLine(" }");
sb.AppendLine("}");
ctx.AddSource("_BundledCanonicalWorkflowRegistry.g.cs", sb.ToString());
}
private static string EscapeCSharp(string s)
{
var sb = new StringBuilder();
sb.Append('"');
foreach (var c in s)
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (c < 0x20) sb.Append($"\\u{(int)c:x4}");
else sb.Append(c);
break;
}
}
sb.Append('"');
return sb.ToString();
}
private static string ComputeSha256Lowercase(string input)
{
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input));
var sb = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}

View File

@@ -21,6 +21,7 @@ public sealed class WorkflowCanonicalityAnalyzer : DiagnosticAnalyzer
WorkflowDiagnostics.NonTrustedObjectCreation,
WorkflowDiagnostics.CSharpConditionalOperator,
WorkflowDiagnostics.NonTrustedMemberAccess,
WorkflowDiagnostics.GeneratorUnsupportedPattern,
WorkflowDiagnostics.ReachableHelperViolation);
public override void Initialize(AnalysisContext context)

View File

@@ -67,6 +67,16 @@ internal static class WorkflowDiagnostics
description: "Reading a non-const field or property on a type from a compiled assembly (not StellaOps.Workflow.* / Ablera.Serdica.Workflow.* and not project-reference source) evaluates at workflow-build time. The resulting canonical JSON depends on whatever value that field/property held when the workflow Spec was constructed — machine name, current time, environment variables, etc. Use WorkflowExpr.Path(\"start.*\" | \"state.*\" | \"payload.*\" | \"result.*\") for runtime-bound values or a compile-time const for build-time constants.",
helpLinkUri: HelpUri + "#wf006");
public static readonly DiagnosticDescriptor GeneratorUnsupportedPattern = new(
id: "WF020",
title: "Workflow pattern not supported by the canonical artifact generator",
messageFormat: "The source generator cannot emit canonical JSON for this pattern: {0}",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "The analyzer accepted this code as canonicalizable but the source generator has no rule for this shape yet. File the exact pattern so it can be added, or rewrite using a supported shape. See docs/modules/workflow/analyzer.md for the list of supported builder methods and WorkflowExpr.* variants.",
helpLinkUri: HelpUri + "#wf020");
public static readonly DiagnosticDescriptor ReachableHelperViolation = new(
id: "WF010",
title: "Helper reachable from workflow contains non-canonical construct",

View File

@@ -71,4 +71,36 @@ internal static class AnalyzerTestHarness
var diagnostics = await RunAsync(source, additionalSources);
return diagnostics;
}
public static Task<(ImmutableArray<Diagnostic> Diagnostics, ImmutableArray<GeneratedSourceResult> GeneratedSources)> RunGeneratorAsync(
string source,
params (string Name, string Source)[] additionalSources)
{
var trees = new System.Collections.Generic.List<SyntaxTree>
{
CSharpSyntaxTree.ParseText(source, path: "Workflow.cs"),
};
foreach (var extra in additionalSources)
{
trees.Add(CSharpSyntaxTree.ParseText(extra.Source, path: extra.Name));
}
var compilation = CSharpCompilation.Create(
assemblyName: "WorkflowGeneratorTest_" + System.Guid.NewGuid().ToString("N"),
syntaxTrees: trees,
references: BaseReferences,
options: new CSharpCompilationOptions(
OutputKind.DynamicallyLinkedLibrary,
nullableContextOptions: NullableContextOptions.Enable));
var generator = new WorkflowCanonicalArtifactGenerator();
var driver = CSharpGeneratorDriver.Create(generator)
.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics);
var result = driver.GetRunResult();
var generatedSources = result.Results
.SelectMany(r => r.GeneratedSources)
.ToImmutableArray();
return Task.FromResult((diagnostics, generatedSources));
}
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Immutable;
using System.Linq;
using FluentAssertions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using NUnit.Framework;
using StellaOps.Workflow.Analyzer;
namespace StellaOps.Workflow.Analyzer.Tests;
[TestFixture]
public class GeneratorSmokeTests
{
[Test]
public async Task MinimalWorkflow_ProducesBundledJsonConst()
{
const string source = """
using System;
using System.Collections.Generic;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
public sealed class R { public string? Id { get; set; } }
public sealed class MyWf : IDeclarativeWorkflow<R>
{
public string WorkflowName => "MyWorkflow";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "My Workflow";
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
.InitializeState(WorkflowExpr.Obj(
WorkflowExpr.Prop("id", WorkflowExpr.Path("start.id"))))
.Build();
}
""";
var (diagnostics, generatedSources) = await AnalyzerTestHarness.RunGeneratorAsync(source);
// No WF diagnostics expected for a canonical workflow
diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty();
// Two generated sources: per-workflow class + registry
var perWorkflow = generatedSources.FirstOrDefault(s => s.HintName == "_BundledCanonicalWorkflow_MyWf.g.cs");
perWorkflow.HintName.Should().NotBeNull();
var generated = perWorkflow.SourceText.ToString();
generated.Should().Contain("public const string WorkflowName = \"MyWorkflow\";");
generated.Should().Contain("public const string WorkflowVersion = \"1.0.0\";");
generated.Should().Contain("public const string DisplayName = \"My Workflow\";");
generated.Should().Contain("public const string CanonicalDefinitionJson = ");
generated.Should().Contain("public const string CanonicalContentHash = ");
// The JSON should carry the initializeStateExpression we supplied
generated.Should().Contain("\\\"workflowName\\\"");
generated.Should().Contain("\\\"MyWorkflow\\\"");
generated.Should().Contain("\\\"initializeStateExpression\\\"");
generated.Should().Contain("\\\"path\\\"");
generated.Should().Contain("\\\"start.id\\\"");
var registry = generatedSources.FirstOrDefault(s => s.HintName == "_BundledCanonicalWorkflowRegistry.g.cs");
registry.HintName.Should().NotBeNull();
registry.SourceText.ToString().Should().Contain("_BundledCanonicalWorkflow_MyWf.WorkflowName");
}
}