From da5827984d21a13740eb8c39aadf44f5a7e397a1 Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Mon, 21 Sep 2026 14:06:44 +0100 Subject: [PATCH 1/4] Import current state of tool from old repo Signed-off-by: Sean Kavanagh --- .gitignore | 6 + CONVERSION_NOTES.md | 517 +++++++++++++++ README.md | 92 +++ build.sh | 10 + dependency-reduced-pom.xml | 89 +++ install.ps1 | 33 + install.sh | 43 ++ pom.xml | 137 ++++ run.sh | 13 + .../java/com/specconvert/SpecConvert.java | 377 +++++++++++ .../specconvert/report/JsonReportWriter.java | 21 + .../report/MarkdownReportWriter.java | 120 ++++ .../specconvert/report/MigrationReport.java | 107 ++++ .../specconvert/report/ReportCollector.java | 98 +++ .../com/specconvert/report/ReportWriter.java | 36 ++ .../com/specconvert/transformer/Callback.java | 133 ++++ .../com/specconvert/transformer/Event.java | 106 +++ .../com/specconvert/transformer/ForEach.java | 80 +++ .../com/specconvert/transformer/Inject.java | 27 + .../specconvert/transformer/Operation.java | 67 ++ .../com/specconvert/transformer/Parallel.java | 61 ++ .../com/specconvert/transformer/Sleep.java | 59 ++ .../com/specconvert/transformer/Switch.java | 182 ++++++ .../com/specconvert/transformer/util.java | 159 +++++ .../validator/OutputValidator.java | 606 ++++++++++++++++++ .../validator/ValidationResult.java | 34 + .../META-INF/native-image/reflect-config.json | 66 ++ workflows/release.yml | 80 +++ 28 files changed, 3359 insertions(+) create mode 100644 .gitignore create mode 100644 CONVERSION_NOTES.md create mode 100644 README.md create mode 100755 build.sh create mode 100644 dependency-reduced-pom.xml create mode 100644 install.ps1 create mode 100644 install.sh create mode 100644 pom.xml create mode 100755 run.sh create mode 100644 src/main/java/com/specconvert/SpecConvert.java create mode 100644 src/main/java/com/specconvert/report/JsonReportWriter.java create mode 100644 src/main/java/com/specconvert/report/MarkdownReportWriter.java create mode 100644 src/main/java/com/specconvert/report/MigrationReport.java create mode 100644 src/main/java/com/specconvert/report/ReportCollector.java create mode 100644 src/main/java/com/specconvert/report/ReportWriter.java create mode 100644 src/main/java/com/specconvert/transformer/Callback.java create mode 100644 src/main/java/com/specconvert/transformer/Event.java create mode 100644 src/main/java/com/specconvert/transformer/ForEach.java create mode 100644 src/main/java/com/specconvert/transformer/Inject.java create mode 100644 src/main/java/com/specconvert/transformer/Operation.java create mode 100644 src/main/java/com/specconvert/transformer/Parallel.java create mode 100644 src/main/java/com/specconvert/transformer/Sleep.java create mode 100644 src/main/java/com/specconvert/transformer/Switch.java create mode 100644 src/main/java/com/specconvert/transformer/util.java create mode 100644 src/main/java/com/specconvert/validator/OutputValidator.java create mode 100644 src/main/java/com/specconvert/validator/ValidationResult.java create mode 100644 src/main/resources/META-INF/native-image/reflect-config.json create mode 100644 workflows/release.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad2d4c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +out/ +lib/ +.vscode/ +samples/ +results/ \ No newline at end of file diff --git a/CONVERSION_NOTES.md b/CONVERSION_NOTES.md new file mode 100644 index 0000000..c9c9042 --- /dev/null +++ b/CONVERSION_NOTES.md @@ -0,0 +1,517 @@ +# SpecConvert — Conversion Logic Notes + +CNCF Serverless Workflow **0.8 → 1.0** | `src/main/java/com/specconvert/SpecConvert.java` + +--- + +## Processing Pipeline + +``` +1. Read → 2. Convert → 3. Serialise → 4. Validate → 5. Write report +``` + +The input file is parsed into a 0.8 SDK object (`io.serverlessworkflow.api.Workflow`). The `convert()` method builds a **new** 1.0 SDK object tree — nothing from the source is mutated. The output is serialised to the format implied by `-f` (default `yaml`). After the file is written, `OutputValidator` reads it back as a `JsonNode` and validates the structural correctness of every translated element. Findings are forwarded to the `ReportCollector` and included in the migration report. + +--- + +## CLI Usage + +``` +swf-migrate [options] + + -o, --output Output file path (default: -migrated.yaml) + -f, --format Output format: yaml or json (default: yaml) + -n, --namespace Namespace in the 1.0 document header (default: default) + -r, --report Report file path (default: -report.json|md) + --report-format Report format: json or markdown (default: json) + --strict Treat warnings as failures; exit 1 if any warnings occur (default: false) +``` + +### Argument validation + +- `-o` and `-f` must agree on extension. Passing `-o out.json -f yaml` (or vice-versa) throws an `IllegalArgumentException` before any conversion work begins. +- `--report` and `--report-format` are subject to the same check (`.json` ↔ `json`, `.md`/`.markdown` ↔ `markdown`). + +--- + +## Top-level Structure + +The 0.8 document is a flat object. The 1.0 document wraps everything inside two top-level keys: `document` and `do`. + +**0.8 input** +```json +{ + "id": "helloworld", + "version": "1.0", + "specVersion": "0.8", + "namespace": "default", + "states": [ ... ] +} +``` + +**1.0 output** +```json +{ + "document": { + "dsl": "1.0.0", + "namespace": "default", + "name": "helloworld", + "version": "1.0" + }, + "do": [ ... ] +} +``` + +--- + +## `document` Block — Field Mappings + +| 0.8 field | 1.0 field | Notes | +|----------------|----------------------|-------------------------------------------| +| `specVersion` | `document.dsl` | Hard-coded to `"1.0.0"` | +| `id` | `document.name` | Direct copy; defaults to `"unnamed"` | +| `namespace` | `document.namespace` | Set from `-n` flag; defaults to `"default"` | +| `version` | `document.version` | Direct value copy; defaults to `"0.0.1"` | + +--- + +## `do` Block — State Conversion + +The 0.8 `states` array becomes a 1.0 `do` array. Each state becomes a single-key object keyed by the state's `name`. + +| 0.8 state type | 1.0 task type | Transformer class | +|----------------|------------------------------------|------------------------------| +| `inject` | `set` | `Inject` | +| `sleep` | `wait` | `Sleep` | +| `switch` | `switch` (data) / `do [ listen + switch ]` (event) | `Switch` | +| `parallel` | `fork` | `Parallel` | +| `operation` | `do` (sequential) / `fork` (parallel) | `Operation` | +| `event` | `listen` | `Event` | +| `forEach` | `for` | `ForEach` | +| `callback` | `do [ call + listen + switch ]` | `Callback` | + +Any state type not listed above is skipped with an `[ERROR]` report entry and a manual task. + +--- + +## State Conversion Details + +### `inject` → `set` + +The state's `data` object is copied directly into a `set` wrapper. + +```yaml +# 0.8 +name: Hello State +type: inject +data: + result: Hello World! + +# 1.0 +Hello State: + set: + result: Hello World! +``` + +--- + +### `sleep` → `wait` + +The `duration` ISO 8601 string is parsed into discrete `DurationInline` components. Years and months have no `DurationInline` fields and are folded into `days` (years × 365, months × 30). Zero-valued components are suppressed from the output. + +```yaml +# 0.8 +name: SleepFiveSeconds +type: sleep +duration: PT5S + +# 1.0 +SleepFiveSeconds: + wait: + seconds: 5 +``` + +| Input | Output `wait` | +|------------|----------------------------------------| +| `PT5S` | `seconds: 5` | +| `PT1H30M` | `hours: 1, minutes: 30` | +| `P2DT3H4M` | `days: 2, hours: 3, minutes: 4` | +| `P1Y` | `days: 365` (approximate) | + +--- + +### `switch` → `switch` / `do [ listen + switch ]` + +The translation depends on which condition type the state uses. + +#### Data conditions (`dataConditions`) → plain `switch` + +Each `dataConditions` entry becomes a named switch case with a `when` predicate on the current workflow data. The `defaultCondition` becomes the `"default"` case. + +```yaml +# 0.8 +name: CheckApplicant +type: switch +dataConditions: + - name: Applicant is adult + condition: "${ .age >= 18 }" + transition: ApproveApplication +defaultCondition: + transition: RejectApplication + +# 1.0 +CheckApplicant: + switch: + - applicantIsAdult: + when: .age >= 18 + then: ApproveApplication + - default: + then: RejectApplication +``` + +Condition names are **camelCased** for use as YAML keys (e.g. `"Applicant is adult"` → `applicantIsAdult`). + +##### EL expression handling + +0.8 conditions written as `${ ... }` are not valid jq. The converter: +1. Strips the `${ }` wrapper from the `when` value. +2. Logs a `[WARN]` to stderr. +3. Adds a `WARNING / expression_conversion` issue to the migration report. + +These conditions require **manual translation** to jq syntax before the workflow will run correctly. + +--- + +#### Event conditions (`eventConditions`) → `do [ listen + switch ]` + +An event-based switch waits for one of N events and routes based on which arrived. The correct 1.0 idiom is a two-step composite: + +1. **`listen`** with `any: [...]` — one filter per `eventRef`, resolved to a CloudEvent `type`. Blocks until one of the listed events arrives and makes the received event available as task output. +2. **`switch`** — one trivial `when: .type == ""` case per `eventCondition`, routing to the transition target. The `defaultCondition` becomes the `"default"` case. + +The `eventRef` name is resolved against the workflow's top-level `events` definitions to obtain the CloudEvent `type`; if no definition is found the `eventRef` name is used as-is. The `eventRef` name is lowercased to form the switch case key. + +```yaml +# 0.8 +name: CheckVisaStatus +type: switch +eventConditions: + - eventRef: visaApprovedEvent + transition: HandleApprovedVisa + - eventRef: visaRejectedEvent + transition: HandleRejectedVisa +defaultCondition: + transition: HandleNoVisaDecision + +# 1.0 +CheckVisaStatus: + do: + - CheckVisaStatusListen: + listen: + to: + any: + - with: + type: visaApprovedEvent + - with: + type: visaRejectedEvent + - CheckVisaStatusRoute: + switch: + - visaapprovedevent: + when: .type == "visaApprovedEvent" + then: HandleApprovedVisa + - visarejectedevent: + when: .type == "visaRejectedEvent" + then: HandleRejectedVisa + - default: + then: HandleNoVisaDecision +``` + +Each `EventCondition` may have a `transition` **or** an `end` marker. If `end` is present, the case `then` is set to `"end"`. If neither is present a `"TODO"` placeholder is emitted with a `WARNING / state_transformation` report entry. + +--- + +### `parallel` → `fork` + +Each named branch becomes a `do` task inside `fork.branches`. The `completionType` field controls `compete`: + +| 0.8 `completionType` | 1.0 `fork.compete` | Meaning | +|----------------------|--------------------|----------------------------------| +| `allOf` (default) | `false` | All branches must finish | +| `atLeast` | `true` | First branch to finish wins | + +```yaml +# 0.8 +name: ParallelExec +type: parallel +completionType: allOf +branches: + - name: BranchA + actions: + - functionRef: { refName: doA } + - name: BranchB + actions: + - functionRef: { refName: doB } + +# 1.0 +ParallelExec: + fork: + compete: false + branches: + - BranchA: + do: + - doA: + call: doA + with: {} + - BranchB: + do: + - doB: + call: doB + with: {} +``` + +--- + +### `operation` → `do` / `fork` + +The `actionMode` field determines the task type: + +| 0.8 `actionMode` | 1.0 task type | Structure | +|-----------------------|---------------|------------------------------------------------| +| `sequential` (default)| `do` | Actions in order inside a `do` task | +| `parallel` | `fork` | Each action becomes its own branch (`compete: false`) | + +Each action's `functionRef` becomes a `call` task keyed by `refName`. + +```yaml +# 0.8 sequential +name: CallServices +type: operation +actionMode: sequential +actions: + - name: stepOne + functionRef: { refName: serviceA, arguments: { id: "${ .id }" } } + - name: stepTwo + functionRef: { refName: serviceB } + +# 1.0 +CallServices: + do: + - stepOne: + call: serviceA + with: + id: "${ .id }" + - stepTwo: + call: serviceB + with: {} +``` + +Actions with no `functionRef` emit a placeholder `set` task with a `_warning` property and a `WARNING / unsupported_feature` report entry. + +--- + +### `event` → `listen` + +The `exclusive` flag maps to the consumption strategy: + +| 0.8 `exclusive` | 1.0 `listen.to` key | Meaning | +|-----------------|---------------------|--------------------------------------------| +| `true` (default)| `any` | First matching event triggers the state | +| `false` | `all` | All listed events must arrive | + +Each `eventRef` in `onEvents` is resolved against the workflow's top-level `events` definitions to obtain the CloudEvent `type` string. If no definition is found, the `eventRef` name is used as-is. + +If any `onEvents` entry has `actions`, a `foreach` iterator is attached to the `listen` task. All actions across all `onEvents` entries are flattened into a single `do` list. The iteration variable is `"item"` (the received CloudEvent). + +```yaml +# 0.8 +name: WaitForApproval +type: event +exclusive: true +onEvents: + - eventRefs: [approvalEvent] + actions: + - functionRef: { refName: logApproval } + +# 1.0 +WaitForApproval: + listen: + to: + any: + - with: + type: com.example.approval.received + foreach: + item: item + do: + - logApproval: + call: logApproval + with: {} +``` + +--- + +### `forEach` → `for` + +| 0.8 field | 1.0 field | Notes | +|--------------------|-------------|---------------------------------------------------------| +| `inputCollection` | `for.in` | Collection expression; defaults to `${ .[] }` if absent | +| `iterationParam` | `for.each` | Iteration variable; defaults to `"item"` if absent | +| `actions` | `do` | Each action converted via `util.convertAction()` | +| `outputCollection` | — | No 1.0 equivalent; dropped with `WARNING` report entry | +| `batchSize` | — | No 1.0 equivalent; dropped with `WARNING` report entry | + +```yaml +# 0.8 +name: ProcessOrders +type: forEach +inputCollection: "${ .orders }" +iterationParam: order +actions: + - functionRef: { refName: processOrder, arguments: { id: "${ .order.id }" } } + +# 1.0 +ProcessOrders: + for: + each: order + in: "${ .orders }" + do: + - processOrder: + call: processOrder + with: + id: "${ .order.id }" +``` + +--- + +### `callback` → `do [ call + listen + switch ]` + +A callback state is expanded into a three-step composite `do` task. A `high`-priority manual task is always added to the migration report asking the operator to verify the converted semantics. + +**Steps:** + +1. **call** (optional) — the outgoing `action` becomes a `call` task. Omitted if the state has no `action`. +2. **listen** — waits for the CloudEvent named by `eventRef`. The event `type` is resolved from the workflow's top-level `events` definitions; the `eventRef` name is used as-is if no definition is found. +3. **switch** — routes on the received event's `type` field: + - Named case `callbackReceived`: `when: ${ .type == "" }` → transition target. + - Default case: `then: end` (fallback for unexpected outcomes). + +**Transition target resolution:** + +| 0.8 state field | 1.0 `then` value | +|----------------------------|-------------------------------| +| `transition.nextState` | Named next state | +| `end` (any truthy value) | `"end"` | +| Neither present | `"TODO"` + `ERROR` report entry | + +```yaml +# 0.8 +name: RequestVitals +type: callback +action: + name: sendVitalsRequest + functionRef: + refName: sendVitalsRequest + arguments: + patientId: "${ .patientId }" +eventRef: VitalsReceived +transition: ProcessVitals + +# 1.0 +RequestVitals: + do: + - sendVitalsRequest: + call: sendVitalsRequest + with: + patientId: "${ .patientId }" + - RequestVitalsListen: + listen: + to: + any: + - with: + type: com.hospital.vitals.received + - RequestVitalsRoute: + switch: + - callbackReceived: + when: "${ .type == \"com.hospital.vitals.received\" }" + then: ProcessVitals + - default: + then: end +``` + +--- + +## Action Conversion (`util.convertAction`) + +Used by `operation`, `forEach`, `event` (foreach body), `parallel`, and `callback`. A `functionRef` action becomes a `call` task: + +```yaml +# action with functionRef +- stepOne: + call: myFunction + with: + argA: value1 + argB: value2 +``` + +If the action has no `functionRef`, a placeholder `set` task is emitted with `_warning: "unsupported action type"` and a `WARNING / unsupported_feature` entry is added to the report. + +--- + +## Event Type Resolution + +At the start of `buildDo()`, a `Map` of `eventRef name → CloudEvent type` is built from the workflow's top-level `events` block. Both `Listen` and `Callback` transformers use this map to resolve symbolic event names to their CloudEvent `type` strings. If a name has no definition, the name itself is used as the type. + +--- + +## ISO 8601 Duration Parsing + +Used by the `sleep` → `wait` conversion. The regex `P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?` is applied and each component is stored as a discrete `DurationInline` field. Years and months are folded into days (approximate: 1y = 365d, 1mo = 30d). + +--- + +## Output Validation + +After the converted file is written, `OutputValidator` (`src/main/java/com/specconvert/validator/OutputValidator.java`) reads it back as a `JsonNode` and checks every translated element. Findings become `validation`-category issues in the migration report. + +| Task type | Key checks | +|-----------|---------------------------------------------------------------------------| +| `document`| `dsl` = `"1.0.0"`; `name`, `namespace` non-blank; `version` present | +| `set` | `set` field is a non-null object | +| `wait` | `wait` object present; at least one positive duration component | +| `switch` | Non-empty array; exactly one `default`; all non-default cases have `when`; all cases have `then`; no `TODO` placeholders | +| `call` | `call` is a non-blank string; `with` (if present) is an object | +| `for` | `for.in` and `for.each` non-blank; `do` present and non-empty | +| `fork` | `fork.branches` non-empty array; `fork.compete` is a boolean | +| `listen` | `listen.to` has exactly one of `any`/`all`; array non-empty; each filter has `with.type` | +| `do` | `do` array non-empty; recursively validated | + +Validator findings are printed to stderr alongside converter output and contribute to `warnings_count`/`errors_count` in the report summary. + +--- + +## Migration Report + +Written alongside the converted file. Format is controlled by `--report-format` (`json` default, `markdown` also supported). + +### `overall_status` values + +| Value | Condition | +|--------------------------|-------------------------------------------------------| +| `success` | No errors, no warnings | +| `success_with_warnings` | Warnings present, `--strict false` (default) | +| `failed` | Warnings present and `--strict true` | +| `partial` | One or more `ERROR`-severity issues | + +### Issue categories + +| Category | Source | +|------------------------|---------------------------------------------------------| +| `expression_conversion`| EL `${ }` expression stripped; needs jq translation | +| `state_transformation` | Transition/end missing on a state | +| `data_flow` | Reserved for data mapping issues | +| `error_handling` | Reserved for error/retry mapping issues | +| `authentication` | Reserved for auth-related issues | +| `unsupported_feature` | State type has no 1.0 equivalent; field was dropped | +| `extension` | Reserved for extension-related issues | +| `validation` | Structural defect detected in the serialised output | + +Manual tasks (always `high` priority) are emitted for callback states to prompt human review of the converted composite flow. diff --git a/README.md b/README.md new file mode 100644 index 0000000..556ae4b --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# swf-migrate + +CNCF Serverless Workflow **0.8 → 1.0** converter. + +--- + +## Install (no Java required) + +Download a pre-built binary from the [releases page](../../releases) and place it on your PATH, or use the one-liner for your platform. + +**macOS / Linux** +```bash +curl -fsSL https://raw.githubusercontent.com/skavgou/spec-convert/main/install.sh | bash +``` + +**Windows (PowerShell)** +```powershell +irm https://raw.githubusercontent.com/skavgou/spec-convert/main/install.ps1 | iex +``` + +--- + +## Usage + +``` +swf-migrate [-o ] [-f yaml|json] [-n ] +``` + +| Flag | Description | Default | +|------|-------------|---------| +| `-o`, `--output` | Output file path (format inferred from extension) | `-migrated.yaml` | +| `-f`, `--format` | Output format: `yaml` or `json` | `yaml` | +| `-n`, `--namespace` | Namespace written to the 1.0 document header | `default` | +| `-r`, `--report`| Report file path (format inferred from extension) | `-report.json` | +| `--report-format`| Report file format: `json` or `md`/`markdown` | `json` | +| `--strict`| Treat migration warnings as failures | `false` | + +- Input can be `.json`, `.yaml`, or `.yml` +- In cases where the format inferred from the extesion for `-o` or `-r` conflicts with the format of `-f` or `--report-format` an error will be thrown. + +**Examples** + +```bash +# Default output → samples/hello-migrated.yaml +swf-migrate samples/hello.json + +# Explicit output path +swf-migrate samples/hello.json -o results/hello-v1.yaml + +# Output as JSON +swf-migrate samples/hello.json -f json + +# Custom output path and namespace +swf-migrate samples/hello.json -o results/hello-v1.yaml -n my-org + +# Custon report output +swf-migrate samples/hello.json -r reports/hello-report.json + +# Report output as md +swf-migrate samples/hello.json --report-format md + +# Strict Migration +swf-migrate samples/hello.json --strict true +``` + +--- + +## Build from source + +Requires Java 17+ and Maven 3.8+. + +**Fat jar (requires Java to run)** +```bash +mvn package +java -jar target/spec-convert.jar [-o ] [-f yaml|json] [-n ] +``` + +**Native binary (no Java required to run)** + +Requires [GraalVM JDK 21](https://www.graalvm.org/downloads/) to build. + +```bash +mvn package -Pnative +./target/swf-migrate [-o ] [-f yaml|json] [-n ] +``` + +--- + + +## Conversion details + +See [`CONVERSION_NOTES.md`](CONVERSION_NOTES.md) for a full description of the field mappings and state-type handling. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..9f3a837 --- /dev/null +++ b/build.sh @@ -0,0 +1,10 @@ +# Compiles SpecConvert and packages it into target/spec-convert.jar +# Requires Maven; dependencies are resolved from configured repositories. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "Building with Maven..." +mvn -f "$SCRIPT_DIR/pom.xml" package -q + +echo "Build complete" diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml new file mode 100644 index 0000000..046d25a --- /dev/null +++ b/dependency-reduced-pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + com.specconvert + spec-convert + 1.0-SNAPSHOT + + + + maven-compiler-plugin + 3.13.0 + + 17 + 17 + + + + maven-shade-plugin + 3.6.0 + + + package + + shade + + + spec-convert + + + com.specconvert.SpecConvert + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + + native + + + + org.graalvm.buildtools + native-maven-plugin + 0.10.4 + true + + + build-native + package + + compile-no-fork + + + + + swf-migrate + com.specconvert.SpecConvert + + --no-fallback + -H:+ReportExceptionStackTraces + --initialize-at-build-time=org.slf4j + + + + + + + + + 17 + 17 + UTF-8 + 2.19.0 + + diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..7ed9763 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,33 @@ +# Installs swf-migrate to $Env:USERPROFILE\bin and adds it to the user PATH. +# Usage: irm https://raw.githubusercontent.com/skavgou/spec-convert/main/install.ps1 | iex +$ErrorActionPreference = 'Stop' + +$Repo = "skavgou/spec-convert" +$Asset = "swf-migrate-windows.exe" +$BinDir = "$Env:USERPROFILE\bin" + +# Resolve latest release tag +$Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" +$Tag = $Release.tag_name + +if (-not $Tag) { + Write-Error "Could not determine latest release tag." + exit 1 +} + +$Url = "https://github.com/$Repo/releases/download/$Tag/$Asset" +$Dest = "$BinDir\swf-migrate.exe" + +Write-Host "Downloading swf-migrate $Tag for Windows..." +New-Item -ItemType Directory -Force -Path $BinDir | Out-Null +Invoke-WebRequest -Uri $Url -OutFile $Dest + +# Add $BinDir to user PATH if not already present +$CurrentPath = [Environment]::GetEnvironmentVariable("PATH", "User") +if ($CurrentPath -notlike "*$BinDir*") { + [Environment]::SetEnvironmentVariable("PATH", "$CurrentPath;$BinDir", "User") + Write-Host "Added $BinDir to your PATH (restart your terminal to apply)." +} + +Write-Host "Installed to $Dest" +Write-Host "Run: swf-migrate --help" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..53efa00 --- /dev/null +++ b/install.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Installs swf-migrate to /usr/local/bin (or ~/bin if not writable). +# Usage: curl -fsSL https://raw.githubusercontent.com/skavgou/spec-convert/main/install.sh | bash +set -euo pipefail + +REPO="skavgou/spec-convert" +INSTALL_DIR="/usr/local/bin" + +# Detect platform +OS="$(uname -s)" +case "$OS" in + Linux*) ASSET="swf-migrate-linux" ;; + Darwin*) ASSET="swf-migrate-macos" ;; + *) echo "Unsupported OS: $OS" >&2; exit 1 ;; +esac + +# Resolve latest release tag +TAG=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/') + +if [[ -z "$TAG" ]]; then + echo "Could not determine latest release tag." >&2 + exit 1 +fi + +URL="https://github.com/${REPO}/releases/download/${TAG}/${ASSET}" +TMP=$(mktemp) + +echo "Downloading swf-migrate ${TAG} for ${OS}..." +curl -fsSL "$URL" -o "$TMP" +chmod +x "$TMP" + +# Fall back to ~/bin if /usr/local/bin is not writable +if [[ ! -w "$INSTALL_DIR" ]]; then + INSTALL_DIR="$HOME/bin" + mkdir -p "$INSTALL_DIR" + echo "Note: /usr/local/bin is not writable, installing to $INSTALL_DIR" + echo "Make sure $INSTALL_DIR is on your PATH." +fi + +mv "$TMP" "${INSTALL_DIR}/swf-migrate" +echo "Installed to ${INSTALL_DIR}/swf-migrate" +echo "Run: swf-migrate --help" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8a7aa2d --- /dev/null +++ b/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + com.specconvert + spec-convert + 1.0-SNAPSHOT + jar + + + 17 + 17 + UTF-8 + 2.19.0 + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${jackson.version} + + + + + io.serverlessworkflow.v08 + serverlessworkflow-api + 4.1.0.Final + + + org.slf4j + slf4j-simple + 2.0.17 + + + + + io.serverlessworkflow + serverlessworkflow-types + 7.25.0.Final + + + + + io.serverlessworkflow + serverlessworkflow-api + 7.25.0.Final + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + 17 + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + spec-convert + + + com.specconvert.SpecConvert + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + + native + + + + org.graalvm.buildtools + native-maven-plugin + 0.10.4 + true + + + build-native + + compile-no-fork + + package + + + + swf-migrate + com.specconvert.SpecConvert + + --no-fallback + -H:+ReportExceptionStackTraces + --initialize-at-build-time=org.slf4j + + + + + + + + + diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..da2d1e2 --- /dev/null +++ b/run.sh @@ -0,0 +1,13 @@ +# Runs SpecConvert. Build first with ./build.sh +# Usage: ./run.sh [output-file] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JAR="$SCRIPT_DIR/target/spec-convert.jar" + +if [[ ! -f "$JAR" ]]; then + echo "Error: $JAR not found. Run ./build.sh first." >&2 + exit 1 +fi + +java -jar "$JAR" "$@" diff --git a/src/main/java/com/specconvert/SpecConvert.java b/src/main/java/com/specconvert/SpecConvert.java new file mode 100644 index 0000000..8ef87e5 --- /dev/null +++ b/src/main/java/com/specconvert/SpecConvert.java @@ -0,0 +1,377 @@ +package com.specconvert; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.specconvert.report.MigrationReport; +import com.specconvert.report.ReportCollector; +import com.specconvert.report.ReportWriter; +import com.specconvert.validator.OutputValidator; +import com.specconvert.validator.ValidationResult; +import com.specconvert.transformer.Callback; +import com.specconvert.transformer.Event; +import com.specconvert.transformer.ForEach; +import com.specconvert.transformer.Inject; +import com.specconvert.transformer.Operation; +import com.specconvert.transformer.Parallel; +import com.specconvert.transformer.Sleep; +import com.specconvert.transformer.Switch; +import com.specconvert.transformer.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +// 0.8 +import io.serverlessworkflow.api.mapper.JsonObjectMapper; +import io.serverlessworkflow.api.mapper.YamlObjectMapper; +import io.serverlessworkflow.api.states.CallbackState; +import io.serverlessworkflow.api.states.EventState; +import io.serverlessworkflow.api.states.ForEachState; +import io.serverlessworkflow.api.states.InjectState; +import io.serverlessworkflow.api.states.ParallelState; +import io.serverlessworkflow.api.states.OperationState; +import io.serverlessworkflow.api.states.SleepState; +import io.serverlessworkflow.api.states.SwitchState; +import io.serverlessworkflow.api.interfaces.State; + +// 1.0 +import io.serverlessworkflow.api.types.Document; +import io.serverlessworkflow.api.types.DurationInline; +import io.serverlessworkflow.api.types.Task; +import io.serverlessworkflow.api.types.TaskItem; +import jakarta.validation.constraints.Null; +import io.serverlessworkflow.api.WorkflowFormat; +import io.serverlessworkflow.api.WorkflowWriter; +import java.util.Map; + +// Workflow10 = io.serverlessworkflow.api.types.Workflow (1.0 output) +// Workflow08 = io.serverlessworkflow.api.Workflow (0.8 input, referenced by FQN) + +/** MixIn that suppresses zero-valued fields on DurationInline during serialisation. */ +@JsonInclude(JsonInclude.Include.NON_DEFAULT) +interface DurationInlineMixIn {} + +/** + * SpecConvert — CNCF Serverless Workflow spec 0.8 -> 1.0 converter. + * + * Input is parsed via the 0.8 SDK (serverlessworkflow-api 4.1.0.Final). + * Output is built via the 1.0 SDK (serverlessworkflow-types 7.25.0.Final). + * + * Usage: + * swf-migrate [-o ] [-f yaml|json] [-n ] [--strict true|false] [--report-format json|markdown] + * + * Output defaults to -migrated.yaml if -o is not given. + * Both JSON (.json) and YAML (.yaml / .yml) input files are supported. + */ +public class SpecConvert { + + private static final Logger log = LoggerFactory.getLogger(SpecConvert.class); + + public static void main(String[] args) throws IOException { + if (args.length == 0 || "-h".equals(args[0]) || "--help".equals(args[0])) { + util.printUsage(); + return; + } + + // Parse arguments: swf-migrate [-o ] + Path inputPath = null; + Path outputPath = null; + Path reportPath = null; + String outFormat = "yaml"; + boolean outFormatExplicit = false; + String namespace = "default"; + boolean strict = false; + String reportFormat = "json"; + + for (int i = 0; i < args.length; i++) { + if ("-o".equals(args[i]) || "--output".equals(args[i])) { + if (i + 1 >= args.length) { + throw new IllegalArgumentException(args[i] + " requires a file path argument."); + } + outputPath = Path.of(args[++i]); + } else if ("-f".equals(args[i]) || "--format".equals(args[i])) { + if (i + 1 >= args.length) { + throw new IllegalArgumentException("-f requires a format argument."); + } else if (!(args[i+1].equals("yaml") || args[i+1].equals("json"))){ + throw new IllegalArgumentException(args[i] + " requires either 'json' or 'yaml' as format."); + } + outFormat = (args[++i]); + outFormatExplicit = true; + } else if ("-n".equals(args[i]) || "--namespace".equals(args[i])) { + if (i + 1 >= args.length) { + throw new IllegalArgumentException(args[i] + " requires a namespace argument."); + } + namespace = (args[++i]); + } else if ("-r".equals(args[i]) || "--report".equals(args[i])) { + if (i + 1 >= args.length) { + throw new IllegalArgumentException(args[i] + " requires a file path argument."); + } + reportPath = Path.of(args[++i]); + } else if ("--report-format".equals(args[i])) { + if (i + 1 >= args.length) { + throw new IllegalArgumentException("--report-format requires 'json' or 'markdown' as an argument."); + } + String val = args[++i]; + if ("json".equals(val) || "markdown".equals(val)) { + reportFormat = val; + } else { + throw new IllegalArgumentException("--report-format requires 'json' or 'markdown', got: '" + val + "'."); + } + } else if ("--strict".equals(args[i])) { + if (i + 1 >= args.length) { + throw new IllegalArgumentException("--strict requires 'true' or 'false' as an argument."); + } + String val = args[++i]; + if ("true".equals(val)) { + strict = true; + } else if ("false".equals(val)) { + strict = false; + } else { + throw new IllegalArgumentException("--strict requires 'true' or 'false', got: '" + val + "'."); + } + } else if (inputPath == null) { + inputPath = Path.of(args[i]); + } else { + throw new IllegalArgumentException("Unexpected argument: " + args[i]); + } + } + + if (inputPath == null) { + throw new IllegalArgumentException("No input file specified."); + } + + // If -o was given without -f, infer the format from the output file extension. + // If both were given explicitly and they conflict, throw. + if (outputPath != null) { + String outputFileName = outputPath.getFileName().toString().toLowerCase(); + boolean isJsonExt = outputFileName.endsWith(".json"); + boolean isYamlExt = outputFileName.endsWith(".yaml") || outputFileName.endsWith(".yml"); + + if (outFormatExplicit) { + // Both -o and -f supplied — they must agree. + boolean matches = "json".equals(outFormat) ? isJsonExt : isYamlExt; + if (!matches) { + throw new IllegalArgumentException( + "Output path '" + outputPath.getFileName() + "' does not match -f '" + outFormat + "'. " + + "Expected extension: " + ("yaml".equals(outFormat) ? ".yaml or .yml" : ".json") + "."); + } + } else if (isJsonExt) { + // -o given alone with a .json extension — infer json format + outFormat = "json"; + } + // .yaml/.yml with no -f keeps the default "yaml"; any other extension also keeps "yaml" + } + + // Validate that an explicit --report path extension matches --report-format + if (reportPath != null) { + String reportFileName = reportPath.getFileName().toString().toLowerCase(); + boolean extensionMatchesFormat; + if ("markdown".equals(reportFormat)) { + extensionMatchesFormat = reportFileName.endsWith(".md") || reportFileName.endsWith(".markdown"); + } else { + extensionMatchesFormat = reportFileName.endsWith(".json"); + } + if (!extensionMatchesFormat) { + throw new IllegalArgumentException( + "Report path '" + reportPath.getFileName() + "' does not match --report-format '" + reportFormat + "'. " + + "Expected extension: " + ("markdown".equals(reportFormat) ? ".md or .markdown" : ".json") + "."); + } + } + + // Default output: -migrated.yaml alongside the input file + if (outputPath == null) { + String inputName = inputPath.getFileName().toString(); + String stem = inputName.contains(".") + ? inputName.substring(0, inputName.lastIndexOf('.')) + : inputName; + Path parent = inputPath.getParent(); + outputPath = (parent != null ? parent : Path.of(".")).resolve(stem + "-migrated." + outFormat); + } + + // Initialise report collector for this run + ReportCollector.init(inputPath.getFileName().toString()); + + io.serverlessworkflow.api.Workflow wf08 = read(inputPath); + int totalStates = wf08.getStates() != null ? wf08.getStates().size() : 0; + + io.serverlessworkflow.api.types.Workflow wf10 = convert(wf08, namespace); + + WorkflowFormat format = WorkflowFormat.fromPath(outputPath); + + // Suppress zero-valued duration fields (days:0, hours:0, etc.) from the output + format.mapper().addMixIn(DurationInline.class, DurationInlineMixIn.class); + + WorkflowWriter.writeWorkflow(outputPath, wf10, format); + log.info("Wrote converted file to: {}", outputPath); + + // ---------------------------------------------------------------- + // Validate the serialised 1.0 output + // ---------------------------------------------------------------- + ObjectMapper validationMapper = util.isYaml(outputPath) + ? new com.fasterxml.jackson.dataformat.yaml.YAMLMapper() + : new ObjectMapper(); + JsonNode outputTree = validationMapper.readTree(outputPath.toFile()); + List validationResults = new OutputValidator().validate(outputTree); + for (ValidationResult vr : validationResults) { + System.err.println("[" + vr.severity + "] validation: " + vr.path + " — " + vr.rule + ": " + vr.message); + MigrationReport.Severity severity = vr.severity == ValidationResult.Severity.ERROR + ? MigrationReport.Severity.ERROR + : MigrationReport.Severity.WARNING; + ReportCollector.get().addIssue( + severity, + MigrationReport.Category.validation, + vr.path, + vr.message, + null, null, + vr.rule); + } + if (validationResults.isEmpty()) { + System.err.println("[INFO] Output validation passed with no findings."); + } + + // Finalise and write the migration report + int migratedStates = wf10.getDo() != null ? wf10.getDo().size() : 0; + boolean failed = ReportCollector.get().finalise(totalStates, migratedStates, strict); + MigrationReport report = ReportCollector.get().getReport(); + + String inputName = inputPath.getFileName().toString(); + String stem = inputName.contains(".") + ? inputName.substring(0, inputName.lastIndexOf('.')) + : inputName; + if (reportPath == null) { + String reportExtension = "markdown".equals(reportFormat) ? "md" : "json"; + reportPath = (outputPath.getParent() != null + ? outputPath.getParent() : Path.of(".")).resolve(stem + "-report." + reportExtension); + } + ReportWriter.forFormat(reportFormat).write(report, reportPath); + log.info("Wrote migration report to: {}", reportPath); + + if (failed) { + log.error("Strict mode is enabled and warnings were produced — exiting with failure."); + System.exit(1); + } + } + + /** + * Parse a JSON or YAML file into a 0.8 workflow instance. + */ + public static io.serverlessworkflow.api.Workflow read(Path path) throws IOException { + ObjectMapper mapper = util.isYaml(path) ? new YamlObjectMapper() : new JsonObjectMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return mapper.readValue(path.toFile(), io.serverlessworkflow.api.Workflow.class); + } + + /** + * Convert a parsed 0.8 workflow into 1.0. + */ + public static io.serverlessworkflow.api.types.Workflow convert( + io.serverlessworkflow.api.Workflow src, String namespace) { + + Document document = buildDocument(src, namespace); + List doList = buildDo(src); + return new io.serverlessworkflow.api.types.Workflow(document, doList); + } + + // --------------------------------------------------------------- + // 1.0 document builder + // --------------------------------------------------------------- + + /** + * Build the top-level document block from 0.8 fields + */ + private static Document buildDocument(io.serverlessworkflow.api.Workflow src, String namespace) { + // dsl — always "1.0.0" for output + String dsl = "1.0.0"; + System.err.println("[INFO] dsl set to " + dsl); + + // namespace — 0.8 spec has no namespace field; fall back to "default" + System.err.println("[INFO] namespace set to " + namespace); + + // name — mapped from 0.8 "id" + String name = src.getId() != null ? src.getId() : "unnamed"; + System.err.println("[INFO] name set to " + name); + + // version — carried over as-is + String version = src.getVersion() != null ? src.getVersion() : "0.0.1"; + System.err.println("[INFO] version set to " + version); + + return new Document(dsl, namespace, name, version); + } + + // ----------------------------------------------------------------------- + // 1.0 do-list builder + // ----------------------------------------------------------------------- + + /** + * Build the 1.0 do block from the 0.8 states list. + * Each state becomes a TaskItem keyed by the state's name. + * + * Handled mappings: + * inject - set (state data → set variables) + * sleep - wait (ISO 8601 duration → DurationInline) + * switch - switch (dataConditions + defaultCondition) + * parallel - fork (branches + completionType) + * event - listen (onEvents + exclusive flag) + * operation - call (actions → call tasks; sequential = do, parallel = fork) + * forEach - for (inputCollection + iterationParam + actions) + * callback - do[call+listen+switch] (action → listen → conditional route) + */ + private static List buildDo(io.serverlessworkflow.api.Workflow src) { + List items = new ArrayList<>(); + + if (src.getStates() == null) { + return items; + } + + // Build a name→type lookup from the workflow's top-level event definitions + Map eventTypeByName = util.buildEventTypeMap(src); + + for (State state : src.getStates()) { + String stateName = state.getName() != null ? state.getName() : "unnamed"; + + if (state instanceof InjectState) { + items.add(Inject.handleInject(stateName, (InjectState) state)); + + } else if (state instanceof SleepState) { + items.add(new TaskItem(stateName, new Task().withWaitTask(Sleep.handleWait((SleepState) state)))); + + } else if (state instanceof SwitchState) { + items.add(Switch.handleSwitch(stateName, (SwitchState) state, eventTypeByName)); + + } else if (state instanceof ParallelState) { + items.add(Parallel.handleParallel(stateName, (ParallelState) state)); + + } else if (state instanceof OperationState) { + items.add(Operation.handleOperation(stateName, (OperationState) state)); + + } else if (state instanceof EventState) { + items.add(Event.handleEvent(stateName, (EventState) state, eventTypeByName)); + + } else if (state instanceof ForEachState) { + items.add(ForEach.handleForEach(stateName, (ForEachState) state)); + + } else if (state instanceof CallbackState) { + items.add(Callback.handleCallback(stateName, (CallbackState) state, eventTypeByName)); + + } else { + System.err.println("[WARN] Unsupported state type for state '" + + stateName + "' (" + state.getClass().getSimpleName() + "); skipping."); + ReportCollector.get().addIssue( + com.specconvert.report.MigrationReport.Severity.ERROR, + com.specconvert.report.MigrationReport.Category.unsupported_feature, + "states[" + stateName + "]", + "State type " + state.getClass().getSimpleName() + " has no 1.0 equivalent; state was skipped.", + null, null, "Manually implement this state in the converted workflow."); + } + } + + return items; + } + +} diff --git a/src/main/java/com/specconvert/report/JsonReportWriter.java b/src/main/java/com/specconvert/report/JsonReportWriter.java new file mode 100644 index 0000000..e148ae1 --- /dev/null +++ b/src/main/java/com/specconvert/report/JsonReportWriter.java @@ -0,0 +1,21 @@ +package com.specconvert.report; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * Writes a {@link MigrationReport} to disk as indented JSON. + */ +public class JsonReportWriter implements ReportWriter { + + private static final ObjectMapper MAPPER = + new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + @Override + public void write(MigrationReport report, Path path) throws IOException { + MAPPER.writeValue(path.toFile(), report); + } +} diff --git a/src/main/java/com/specconvert/report/MarkdownReportWriter.java b/src/main/java/com/specconvert/report/MarkdownReportWriter.java new file mode 100644 index 0000000..ccd2406 --- /dev/null +++ b/src/main/java/com/specconvert/report/MarkdownReportWriter.java @@ -0,0 +1,120 @@ +package com.specconvert.report; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * Renders a {@link MigrationReport} as a Markdown document and writes it to disk. + */ +public class MarkdownReportWriter implements ReportWriter { + + @Override + public void write(MigrationReport report, Path path) throws IOException { + Files.writeString(path, render(report)); + } + + /** + * Render {@code report} to a Markdown string. + */ + static String render(MigrationReport report) { + StringBuilder sb = new StringBuilder(); + + // ---------------------------------------------------------------- + // Title + // ---------------------------------------------------------------- + sb.append("# Migration Report\n\n"); + + // ---------------------------------------------------------------- + // Summary table + // ---------------------------------------------------------------- + MigrationReport.Summary s = report.summary; + sb.append("## Summary\n\n"); + sb.append("| Field | Value |\n"); + sb.append("|---|---|\n"); + appendRow(sb, "Source file", s.sourceFile); + appendRow(sb, "Source version", s.sourceVersion); + appendRow(sb, "Target version", s.targetVersion); + appendRow(sb, "Migration date", s.migrationDate); + appendRow(sb, "Overall status", s.overallStatus); + sb.append("\n"); + + // ---------------------------------------------------------------- + // Statistics table + // ---------------------------------------------------------------- + if (s.statistics != null) { + MigrationReport.Statistics st = s.statistics; + sb.append("## Statistics\n\n"); + sb.append("| Metric | Count |\n"); + sb.append("|---|---|\n"); + appendRow(sb, "Total states", String.valueOf(st.totalStates)); + appendRow(sb, "Migrated states", String.valueOf(st.migratedStates)); + appendRow(sb, "Warnings", String.valueOf(st.warningsCount)); + appendRow(sb, "Errors", String.valueOf(st.errorsCount)); + sb.append("\n"); + } + + // ---------------------------------------------------------------- + // Issues + // ---------------------------------------------------------------- + List issues = report.issues; + sb.append("## Issues\n\n"); + if (issues == null || issues.isEmpty()) { + sb.append("_No issues recorded._\n\n"); + } else { + sb.append("| # | Severity | Category | Location | Message | Original | Converted | Action Required |\n"); + sb.append("|---|---|---|---|---|---|---|---|\n"); + for (int i = 0; i < issues.size(); i++) { + MigrationReport.Issue issue = issues.get(i); + sb.append("| ").append(i + 1) + .append(" | ").append(safe(issue.severity)) + .append(" | ").append(safe(issue.category)) + .append(" | ").append(safe(issue.sourceLocation)) + .append(" | ").append(safe(issue.message)) + .append(" | ").append(safe(issue.original)) + .append(" | ").append(safe(issue.converted)) + .append(" | ").append(safe(issue.actionRequired)) + .append(" |\n"); + } + sb.append("\n"); + } + + // ---------------------------------------------------------------- + // Manual migration tasks + // ---------------------------------------------------------------- + List tasks = report.manualTasks; + sb.append("## Manual Migration Tasks\n\n"); + if (tasks == null || tasks.isEmpty()) { + sb.append("_No manual tasks recorded._\n\n"); + } else { + sb.append("| # | Priority | Description | Details | Source Reference |\n"); + sb.append("|---|---|---|---|---|\n"); + for (MigrationReport.ManualTask task : tasks) { + sb.append("| ").append(task.taskId) + .append(" | ").append(safe(task.priority)) + .append(" | ").append(safe(task.description)) + .append(" | ").append(safe(task.details)) + .append(" | ").append(safe(task.sourceReference)) + .append(" |\n"); + } + sb.append("\n"); + } + + return sb.toString(); + } + + // ---------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------- + + private static void appendRow(StringBuilder sb, String field, String value) { + sb.append("| ").append(field).append(" | ").append(safe(value)).append(" |\n"); + } + + /** Escape pipe characters so they don't break the Markdown table, and handle nulls. */ + private static String safe(String value) { + if (value == null) return ""; + return value.replace("|", "\\|"); + } +} diff --git a/src/main/java/com/specconvert/report/MigrationReport.java b/src/main/java/com/specconvert/report/MigrationReport.java new file mode 100644 index 0000000..643d270 --- /dev/null +++ b/src/main/java/com/specconvert/report/MigrationReport.java @@ -0,0 +1,107 @@ +package com.specconvert.report; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.ArrayList; +import java.util.List; + +/** + * Data model for the migration report written alongside the converted workflow. + * Serialised to JSON by SpecConvert after conversion completes. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MigrationReport { + + @JsonProperty("migration_summary") + public Summary summary = new Summary(); + + @JsonProperty("issues") + public List issues = new ArrayList<>(); + + @JsonProperty("manual_migration_tasks") + public List manualTasks = new ArrayList<>(); + + // ------------------------------------------------------------------ + // Summary + // ------------------------------------------------------------------ + + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Summary { + @JsonProperty("source_file") public String sourceFile; + @JsonProperty("source_version") public String sourceVersion = "0.8"; + @JsonProperty("target_version") public String targetVersion = "1.0.0"; + @JsonProperty("migration_date") public String migrationDate; + @JsonProperty("overall_status") public String overallStatus = "success"; + @JsonProperty("statistics") public Statistics statistics = new Statistics(); + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Statistics { + @JsonProperty("total_states") public int totalStates; + @JsonProperty("migrated_states") public int migratedStates; + @JsonProperty("warnings_count") public int warningsCount; + @JsonProperty("errors_count") public int errorsCount; + } + + // ------------------------------------------------------------------ + // Issue + // ------------------------------------------------------------------ + + public enum Severity { INFO, WARNING, ERROR } + + public enum Category { + expression_conversion, + state_transformation, + data_flow, + error_handling, + authentication, + unsupported_feature, + extension, + validation + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Issue { + @JsonProperty("severity") public String severity; + @JsonProperty("category") public String category; + @JsonProperty("source_location") public String sourceLocation; + @JsonProperty("message") public String message; + @JsonProperty("original") public String original; + @JsonProperty("converted") public String converted; + @JsonProperty("action_required") public String actionRequired; + + public Issue(Severity severity, Category category, String sourceLocation, + String message, String original, String converted, String actionRequired) { + this.severity = severity.name(); + this.category = category.name(); + this.sourceLocation = sourceLocation; + this.message = message; + this.original = original; + this.converted = converted; + this.actionRequired = actionRequired; + } + } + + // ------------------------------------------------------------------ + // ManualTask + // ------------------------------------------------------------------ + + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ManualTask { + @JsonProperty("task_id") public int taskId; + @JsonProperty("priority") public String priority; + @JsonProperty("description") public String description; + @JsonProperty("details") public String details; + @JsonProperty("source_reference") public String sourceReference; + + public ManualTask(int taskId, String priority, String description, + String details, String sourceReference) { + this.taskId = taskId; + this.priority = priority; + this.description = description; + this.details = details; + this.sourceReference = sourceReference; + } + } +} diff --git a/src/main/java/com/specconvert/report/ReportCollector.java b/src/main/java/com/specconvert/report/ReportCollector.java new file mode 100644 index 0000000..8d0217a --- /dev/null +++ b/src/main/java/com/specconvert/report/ReportCollector.java @@ -0,0 +1,98 @@ +package com.specconvert.report; + +import com.specconvert.report.MigrationReport.Category; +import com.specconvert.report.MigrationReport.Issue; +import com.specconvert.report.MigrationReport.ManualTask; +import com.specconvert.report.MigrationReport.Severity; + +/** + * Call-scoped collector for migration issues and manual tasks. + * + * Usage pattern: + * 1. SpecConvert calls ReportCollector.init(sourceFile) before conversion. + * 2. Each transformer calls ReportCollector.get().addIssue(...) / addManualTask(...). + * 3. SpecConvert calls ReportCollector.get().finalise(totalStates, migratedStates) + * and then retrieves the completed report via ReportCollector.get().getReport(). + */ +public class ReportCollector { + + private static ReportCollector instance; + + private final MigrationReport report = new MigrationReport(); + private int nextTaskId = 1; + + private ReportCollector(String sourceFile) { + report.summary.sourceFile = sourceFile; + report.summary.migrationDate = java.time.Instant.now().toString(); + } + + /** Initialise a fresh collector for a new conversion run. */ + public static void init(String sourceFile) { + instance = new ReportCollector(sourceFile); + } + + /** Retrieve the active collector. Must be called after init(). */ + public static ReportCollector get() { + if (instance == null) { + throw new IllegalStateException("ReportCollector not initialised — call init() first."); + } + return instance; + } + + // ------------------------------------------------------------------ + // Recording helpers + // ------------------------------------------------------------------ + + public void addIssue(Severity severity, Category category, String sourceLocation, + String message, String original, String converted, String actionRequired) { + report.issues.add(new Issue(severity, category, sourceLocation, + message, original, converted, actionRequired)); + if (severity == Severity.WARNING) report.summary.statistics.warningsCount++; + if (severity == Severity.ERROR) report.summary.statistics.errorsCount++; + } + + /** Convenience overload for issues with no before/after values. */ + public void addIssue(Severity severity, Category category, + String sourceLocation, String message) { + addIssue(severity, category, sourceLocation, message, null, null, null); + } + + public void addManualTask(String priority, String description, + String details, String sourceReference) { + report.manualTasks.add(new ManualTask(nextTaskId++, priority, + description, details, sourceReference)); + } + + // ------------------------------------------------------------------ + // Finalisation + // ------------------------------------------------------------------ + + /** + * Compute derived summary fields and set the overall status. + * + * @param strict when {@code true}, any warnings are treated as failures. + * @return {@code true} if the run should be considered a failure (i.e. strict mode + * is active and at least one warning was recorded). + */ + public boolean finalise(int totalStates, int migratedStates, boolean strict) { + report.summary.statistics.totalStates = totalStates; + report.summary.statistics.migratedStates = migratedStates; + + int errors = report.summary.statistics.errorsCount; + int warnings = report.summary.statistics.warningsCount; + + if (errors > 0) { + report.summary.overallStatus = "partial"; + } else if (warnings > 0) { + report.summary.overallStatus = strict ? "failed" : "success_with_warnings"; + } else { + report.summary.overallStatus = "success"; + } + + return strict && warnings > 0; + } + + public MigrationReport getReport() { + return report; + } +} diff --git a/src/main/java/com/specconvert/report/ReportWriter.java b/src/main/java/com/specconvert/report/ReportWriter.java new file mode 100644 index 0000000..491e1af --- /dev/null +++ b/src/main/java/com/specconvert/report/ReportWriter.java @@ -0,0 +1,36 @@ +package com.specconvert.report; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * Common contract for writing a {@link MigrationReport} to disk. + * + *

Implementations are obtained via {@link #forFormat(String)}. + */ +public interface ReportWriter { + + /** + * Write {@code report} to the given {@code path}. + * + * @param report the completed migration report + * @param path destination file; parent directories must already exist + * @throws IOException if the file cannot be written + */ + void write(MigrationReport report, Path path) throws IOException; + + /** + * Return the {@link ReportWriter} for the given format string. + * + * @param format {@code "json"} or {@code "markdown"} (case-sensitive) + * @throws IllegalArgumentException for any other value + */ + static ReportWriter forFormat(String format) { + return switch (format) { + case "json" -> new JsonReportWriter(); + case "markdown" -> new MarkdownReportWriter(); + default -> throw new IllegalArgumentException( + "Unknown report format '" + format + "'. Expected 'json' or 'markdown'."); + }; + } +} diff --git a/src/main/java/com/specconvert/transformer/Callback.java b/src/main/java/com/specconvert/transformer/Callback.java new file mode 100644 index 0000000..02b5b0e --- /dev/null +++ b/src/main/java/com/specconvert/transformer/Callback.java @@ -0,0 +1,133 @@ +package com.specconvert.transformer; + +import com.specconvert.report.MigrationReport.Category; +import com.specconvert.report.MigrationReport.Severity; +import com.specconvert.report.ReportCollector; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +// 0.8 +import io.serverlessworkflow.api.actions.Action; +import io.serverlessworkflow.api.states.CallbackState; + +// 1.0 +import io.serverlessworkflow.api.types.AnyEventConsumptionStrategy; +import io.serverlessworkflow.api.types.DoTask; +import io.serverlessworkflow.api.types.EventFilter; +import io.serverlessworkflow.api.types.EventProperties; +import io.serverlessworkflow.api.types.FlowDirective; +import io.serverlessworkflow.api.types.ListenTask; +import io.serverlessworkflow.api.types.ListenTaskConfiguration; +import io.serverlessworkflow.api.types.ListenTo; +import io.serverlessworkflow.api.types.SwitchCase; +import io.serverlessworkflow.api.types.SwitchItem; +import io.serverlessworkflow.api.types.SwitchTask; +import io.serverlessworkflow.api.types.Task; +import io.serverlessworkflow.api.types.TaskItem; + +public class Callback { + + /** + * Convert a 0.8 callback state to a 1.0 do task + * + * The eventRef is resolved against the workflow's top-level event definitions to obtain + * the CloudEvent type string. If no definition is found, the eventRef name is used as-is. + * + * Transition target resolution: + * - If the state has a transition, then → the named next state. + * - If the state has an end marker, then → "end". + * - Otherwise a "TODO" placeholder is emitted with a warning. + */ + public static TaskItem handleCallback(String name,CallbackState state,Map eventTypeByName) { + return handleCallbackFunction(name, state, eventTypeByName); + } + + protected static TaskItem handleCallbackFunction( + String name, + CallbackState state, + Map eventTypeByName) { + + List steps = new ArrayList<>(); + + ReportCollector.get().addManualTask("high", + "Review callback state transformation", + "Callback state '" + name + "' was split into an outgoing call, " + + "a listen task, and a conditional switch. Verify the converted " + + "flow matches the original callback semantics.", + "states[" + name + "]"); + + // ---------------------------------------------------------------- + // Step 1 — outgoing action (optional) + // ---------------------------------------------------------------- + Action action = state.getAction(); + if (action != null) { + steps.add(util.convertAction(action)); + } + + // ---------------------------------------------------------------- + // Step 2 — listen for the callback event + // ---------------------------------------------------------------- + String eventRef = state.getEventRef() != null ? state.getEventRef() : "callbackEvent"; + String cloudEventType = eventTypeByName.getOrDefault(eventRef, eventRef); + + EventFilter filter = new EventFilter() + .withWith(new EventProperties().withType(cloudEventType)); + + ListenTo listenTo = new ListenTo() + .withAnyEventConsumptionStrategy( + new AnyEventConsumptionStrategy() + .withAny(java.util.Collections.singletonList(filter))); + + ListenTask listenTask = new ListenTask() + .withListen(new ListenTaskConfiguration().withTo(listenTo)); + + steps.add(new TaskItem(name + "Listen", new Task().withListenTask(listenTask))); + + // ---------------------------------------------------------------- + // Step 3 — conditional flow after the event + // ---------------------------------------------------------------- + String transitionTarget = resolveTransition(name, state); + + // Named case: when the expected callback event type is confirmed, go to the transition target + SwitchCase callbackCase = new SwitchCase() + .withWhen("${ .type == \"" + cloudEventType + "\" }") + .withThen(new FlowDirective().withString(transitionTarget)); + + // Default case: fallback — end the workflow segment (should not be reached in normal flow) + SwitchCase defaultCase = new SwitchCase() + .withThen(new FlowDirective().withString("end")); + + List switchItems = new ArrayList<>(); + switchItems.add(new SwitchItem("callbackReceived", callbackCase)); + switchItems.add(new SwitchItem("default", defaultCase)); + + SwitchTask switchTask = new SwitchTask().withSwitch(switchItems); + steps.add(new TaskItem(name + "Route", new Task().withSwitchTask(switchTask))); + + // ---------------------------------------------------------------- + // Wrap all steps in a do task keyed by the state name + // ---------------------------------------------------------------- + DoTask doTask = new DoTask().withDo(steps); + return new TaskItem(name, new Task().withDoTask(doTask)); + } + + /** + * Resolve the 1.0 flow-directive string from the 0.8 state's transition/end fields. + */ + private static String resolveTransition(String stateName, CallbackState state) { + if (state.getTransition() != null && state.getTransition().getNextState() != null) { + return state.getTransition().getNextState(); + } + if (state.getEnd() != null) { + return "end"; + } + System.err.println("[WARN] Callback state '" + stateName + + "' has no transition or end; emitting 'TODO' placeholder."); + ReportCollector.get().addIssue(Severity.ERROR, Category.state_transformation, + "states[" + stateName + "].transition", + "Callback state has no transition or end; a 'TODO' placeholder was emitted.", + null, null, "Set the correct next state or end condition."); + return "TODO"; + } +} diff --git a/src/main/java/com/specconvert/transformer/Event.java b/src/main/java/com/specconvert/transformer/Event.java new file mode 100644 index 0000000..32bedd6 --- /dev/null +++ b/src/main/java/com/specconvert/transformer/Event.java @@ -0,0 +1,106 @@ +package com.specconvert.transformer; + +import java.util.ArrayList; +import java.util.Map; + +import java.util.List; + +// 0.8 +import io.serverlessworkflow.api.actions.Action; +import io.serverlessworkflow.api.events.OnEvents; +import io.serverlessworkflow.api.states.EventState; + +// 1.0 +import io.serverlessworkflow.api.types.AllEventConsumptionStrategy; +import io.serverlessworkflow.api.types.AnyEventConsumptionStrategy; +import io.serverlessworkflow.api.types.EventFilter; +import io.serverlessworkflow.api.types.EventProperties; +import io.serverlessworkflow.api.types.ListenTask; +import io.serverlessworkflow.api.types.ListenTaskConfiguration; +import io.serverlessworkflow.api.types.ListenTo; +import io.serverlessworkflow.api.types.SubscriptionIterator; +import io.serverlessworkflow.api.types.Task; +import io.serverlessworkflow.api.types.TaskItem; + +public class Event { + /** + * Convert a 0.8 event state to a 1.0 listen task. + * + * exclusive mapping: + * true (default) → any: (first matching event triggers the state) + * false → all: (all listed events must arrive) + * + * Each OnEvents entry contributes one EventFilter per eventRef it lists. + * The CloudEvent type is resolved from the workflow's top-level event definitions; + * if no definition is found the eventRef name itself is used as the type. + * + * Actions mapping via foreach: + * 1.0 ListenTask carries a `foreach` (SubscriptionIterator) whose `do` list + * executes for every consumed event. The iterator variable "item" holds + * the received CloudEvent, so actions can inspect it. + * + * All onEvents actions are flattened into a single do list. If different onEvents + * entries have different actions, each distinct action list is appended in order. + * If no onEvents entries have any actions, foreach is omitted entirely. + */ + public static TaskItem handleEvent( + String name, + EventState state, + Map eventTypeByName) { + return handleEventFunction(name, state, eventTypeByName); + } + + protected static TaskItem handleEventFunction( + String name, + EventState state, + Map eventTypeByName) { + + List filters = new ArrayList<>(); + + // Collect all actions across onEvents entries for the foreach do list + List allActions = new ArrayList<>(); + + if (state.getOnEvents() != null) { + for (OnEvents onEvent : state.getOnEvents()) { + List actions = onEvent.getActions() != null + ? onEvent.getActions() : java.util.Collections.emptyList(); + + if (onEvent.getEventRefs() != null) { + for (String eventRef : onEvent.getEventRefs()) { + String cloudEventType = eventTypeByName.getOrDefault(eventRef, eventRef); + EventProperties props = new EventProperties().withType(cloudEventType); + filters.add(new EventFilter().withWith(props)); + } + } + allActions.addAll(actions); + } + } + + // exclusive=true → any (first matching event wins); exclusive=false → all (must all arrive) + ListenTo listenTo; + if (state.isExclusive()) { + listenTo = new ListenTo() + .withAnyEventConsumptionStrategy(new AnyEventConsumptionStrategy().withAny(filters)); + } else { + listenTo = new ListenTo() + .withAllEventConsumptionStrategy(new AllEventConsumptionStrategy().withAll(filters)); + } + + ListenTask listenTask = new ListenTask() + .withListen(new ListenTaskConfiguration().withTo(listenTo)); + + // Build the foreach iterator only when at least one onEvents entry has actions + if (!allActions.isEmpty()) { + List foreachDo = new ArrayList<>(); + for (Action action : allActions) { + foreachDo.add(util.convertAction(action)); + } + // item = the variable name that holds each received CloudEvent inside foreach.do + listenTask.withForeach(new SubscriptionIterator() + .withItem("item") + .withDo(foreachDo)); + } + + return new TaskItem(name, new Task().withListenTask(listenTask)); + } +} diff --git a/src/main/java/com/specconvert/transformer/ForEach.java b/src/main/java/com/specconvert/transformer/ForEach.java new file mode 100644 index 0000000..410420e --- /dev/null +++ b/src/main/java/com/specconvert/transformer/ForEach.java @@ -0,0 +1,80 @@ +package com.specconvert.transformer; + +import com.specconvert.report.MigrationReport.Category; +import com.specconvert.report.MigrationReport.Severity; +import com.specconvert.report.ReportCollector; +import java.util.ArrayList; +import java.util.List; + +// 0.8 +import io.serverlessworkflow.api.actions.Action; +import io.serverlessworkflow.api.states.ForEachState; + +// 1.0 +import io.serverlessworkflow.api.types.ForTask; +import io.serverlessworkflow.api.types.ForTaskConfiguration; +import io.serverlessworkflow.api.types.Task; +import io.serverlessworkflow.api.types.TaskItem; + +public class ForEach { + /** + * Convert a 0.8 forEach state to a 1.0 for task. + * + * Field mapping: + * inputCollection → for.in (the collection expression to iterate over) + * iterationParam → for.each (the variable name bound to each item; defaults to "item") + * actions → do (converted to call tasks via util.convertAction) + * + * outputCollection and batchSize have no direct 1.0 equivalents and are logged as warnings. + */ + public static TaskItem handleForEach(String name, ForEachState state) { + return handleForEachFunction(name, state); + } + + protected static TaskItem handleForEachFunction(String name, ForEachState state) { + String in = state.getInputCollection() != null ? state.getInputCollection() : "${ .[] }"; + String each = state.getIterationParam() != null ? state.getIterationParam() : "item"; + + System.err.println("[INFO] Converting forEach state '" + name + + "' (in=" + in + ", each=" + each + ")"); + + if (state.getOutputCollection() != null) { + System.err.println("[WARN] forEach state '" + name + + "': outputCollection has no 1.0 equivalent; value '" + + state.getOutputCollection() + "' will be dropped."); + ReportCollector.get().addIssue(Severity.WARNING, Category.unsupported_feature, + "states[" + name + "].outputCollection", + "outputCollection has no 1.0 equivalent and will be dropped.", + state.getOutputCollection(), null, + "Manually implement output collection logic if required."); + } + if (state.getBatchSize() > 0) { + System.err.println("[WARN] forEach state '" + name + + "': batchSize has no 1.0 equivalent; value " + + state.getBatchSize() + " will be dropped."); + ReportCollector.get().addIssue(Severity.WARNING, Category.unsupported_feature, + "states[" + name + "].batchSize", + "batchSize has no 1.0 equivalent and will be dropped.", + String.valueOf(state.getBatchSize()), null, + "Manually implement batching logic if required."); + } + + List actions = state.getActions() != null + ? state.getActions() : java.util.Collections.emptyList(); + + List doItems = new ArrayList<>(); + for (Action action : actions) { + doItems.add(util.convertAction(action)); + } + + ForTaskConfiguration forCfg = new ForTaskConfiguration() + .withEach(each) + .withIn(in); + + ForTask forTask = new ForTask() + .withFor(forCfg) + .withDo(doItems); + + return new TaskItem(name, new Task().withForTask(forTask)); + } +} diff --git a/src/main/java/com/specconvert/transformer/Inject.java b/src/main/java/com/specconvert/transformer/Inject.java new file mode 100644 index 0000000..baa9297 --- /dev/null +++ b/src/main/java/com/specconvert/transformer/Inject.java @@ -0,0 +1,27 @@ +package com.specconvert.transformer; + +import io.serverlessworkflow.api.states.InjectState; +import io.serverlessworkflow.api.types.Set; +import io.serverlessworkflow.api.types.SetTask; +import io.serverlessworkflow.api.types.SetTaskConfiguration; +import io.serverlessworkflow.api.types.Task; +import io.serverlessworkflow.api.types.TaskItem; + +public class Inject { + public static TaskItem handleInject(String name, InjectState state) { + return handleInjectFunction(name, state); + } + + protected static TaskItem handleInjectFunction(String name, InjectState state) { + SetTaskConfiguration cfg = new SetTaskConfiguration(); + + if (state.getData() != null && state.getData().isObject()) { + state.getData().fields().forEachRemaining(entry -> + cfg.setAdditionalProperty(entry.getKey(), entry.getValue()) + ); + } + + SetTask setTask = new SetTask().withSet(new Set().withSetTaskConfiguration(cfg)); + return new TaskItem(name, new Task().withSetTask(setTask)); + } +} diff --git a/src/main/java/com/specconvert/transformer/Operation.java b/src/main/java/com/specconvert/transformer/Operation.java new file mode 100644 index 0000000..e73d7c6 --- /dev/null +++ b/src/main/java/com/specconvert/transformer/Operation.java @@ -0,0 +1,67 @@ +package com.specconvert.transformer; + +import java.util.ArrayList; + +import java.util.List; + +// 0.8 +import io.serverlessworkflow.api.actions.Action; +import io.serverlessworkflow.api.states.OperationState; + +// 1.0 +import io.serverlessworkflow.api.types.ForkTask; +import io.serverlessworkflow.api.types.ForkTaskConfiguration; +import io.serverlessworkflow.api.types.Task; +import io.serverlessworkflow.api.types.TaskItem; + +public class Operation { + /** + * Convert a 0.8 operation state to one or more 1.0 tasks. + * + * actionMode mapping: + * sequential (default) → each action becomes an individual call task wrapped in + * a do task keyed by the state name, preserving execution order. + * parallel → actions are placed as branches inside a fork task keyed by + * the state name (compete: false — all branches must finish). + * + * Each action's functionRef becomes a call task: + * { "": { call: "", with: { } } } + */ + public static TaskItem handleOperation(String name, OperationState state) { + return handleOperationFunction(name, state); + } + + protected static TaskItem handleOperationFunction(String name, OperationState state) { + List actions = state.getActions() != null ? state.getActions() : java.util.Collections.emptyList(); + + boolean parallel = state.getActionMode() == OperationState.ActionMode.PARALLEL; + System.err.println("[INFO] Converting operation state '" + name + "' (actionMode=" + + (parallel ? "parallel" : "sequential") + ", actions=" + actions.size() + ")"); + + if (parallel) { + // parallel → fork task; each action becomes its own branch + List branchItems = new ArrayList<>(); + for (Action action : actions) { + TaskItem actionItem = util.convertAction(action); + io.serverlessworkflow.api.types.DoTask doTask = + new io.serverlessworkflow.api.types.DoTask() + .withDo(java.util.Collections.singletonList(actionItem)); + String branchName = actionItem.getName() != null ? actionItem.getName() : "branch"; + branchItems.add(new TaskItem(branchName, new Task().withDoTask(doTask))); + } + ForkTaskConfiguration forkCfg = new ForkTaskConfiguration() + .withCompete(false) + .withBranches(branchItems); + return new TaskItem(name, new Task().withForkTask(new ForkTask().withFork(forkCfg))); + } + + // sequential → do task containing each action as a call task in order + List actionItems = new ArrayList<>(); + for (Action action : actions) { + actionItems.add(util.convertAction(action)); + } + io.serverlessworkflow.api.types.DoTask doTask = + new io.serverlessworkflow.api.types.DoTask().withDo(actionItems); + return new TaskItem(name, new Task().withDoTask(doTask)); + } +} diff --git a/src/main/java/com/specconvert/transformer/Parallel.java b/src/main/java/com/specconvert/transformer/Parallel.java new file mode 100644 index 0000000..dad094b --- /dev/null +++ b/src/main/java/com/specconvert/transformer/Parallel.java @@ -0,0 +1,61 @@ +package com.specconvert.transformer; + +import java.util.ArrayList; +import java.util.List; + +// 0.8 +import io.serverlessworkflow.api.actions.Action; +import io.serverlessworkflow.api.branches.Branch; +import io.serverlessworkflow.api.states.ParallelState; + +// 1.0 +import io.serverlessworkflow.api.types.ForkTask; +import io.serverlessworkflow.api.types.ForkTaskConfiguration; +import io.serverlessworkflow.api.types.Task; +import io.serverlessworkflow.api.types.TaskItem; + +public class Parallel { + /** + * Convert a 0.8 parallel state to a 1.0 fork task. + * + * completionType mapping: + * allOf → compete: false (all branches must finish) + * atLeast → compete: true (first N to complete wins; 1.0 models this as compete) + */ + public static TaskItem handleParallel(String name, ParallelState state) { + return handleParallelFunction(name, state); + } + + protected static TaskItem handleParallelFunction(String name, ParallelState state) { + // compete: true when only a subset needs to complete (atLeast) + boolean compete = state.getCompletionType() == ParallelState.CompletionType.AT_LEAST; + + List branchItems = new ArrayList<>(); + + if (state.getBranches() != null) { + for (Branch branch : state.getBranches()) { + String branchName = branch.getName() != null ? branch.getName() : "branch"; + List actionItems = new ArrayList<>(); + + if (branch.getActions() != null) { + for (Action action : branch.getActions()) { + actionItems.add(util.convertAction(action)); + } + } + + // Each branch becomes a TaskItem whose value is a DoTask containing its actions + io.serverlessworkflow.api.types.DoTask doTask = + new io.serverlessworkflow.api.types.DoTask() + .withDo(actionItems); + branchItems.add(new TaskItem(branchName, new Task().withDoTask(doTask))); + } + } + + ForkTaskConfiguration forkCfg = new ForkTaskConfiguration() + .withCompete(compete) + .withBranches(branchItems); + + ForkTask forkTask = new ForkTask().withFork(forkCfg); + return new TaskItem(name, new Task().withForkTask(forkTask)); + } +} diff --git a/src/main/java/com/specconvert/transformer/Sleep.java b/src/main/java/com/specconvert/transformer/Sleep.java new file mode 100644 index 0000000..9967fcf --- /dev/null +++ b/src/main/java/com/specconvert/transformer/Sleep.java @@ -0,0 +1,59 @@ +package com.specconvert.transformer; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +// 0.8 +import io.serverlessworkflow.api.states.SleepState; + +// 1.0 +import io.serverlessworkflow.api.types.DurationInline; +import io.serverlessworkflow.api.types.TimeoutAfter; +import io.serverlessworkflow.api.types.WaitTask; + + +public class Sleep { + /** + * Parse an ISO 8601 duration string (e.g. "P2DT3H4M") into a 1.0 wait block, + * preserving each component as a discrete field on DurationInline: + * { "wait": { "days": 2, "hours": 3, "minutes": 4 } } + * + * Years and months are folded into days (approximate: 1y=365d, 1mo=30d) + * because DurationInline has no year/month fields. + */ + public static WaitTask handleWait(SleepState state) { + return handleWaitFunction(state); + } + + private static WaitTask handleWaitFunction(SleepState state) { + String iso8601Duration = state.getDuration(); + if (iso8601Duration == null) iso8601Duration = "PT0S"; + + Pattern pattern = Pattern.compile( + "P(?:(\\d+)Y)?(?:(\\d+)M)?(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?" + ); + Matcher m = pattern.matcher(iso8601Duration); + + if (!m.matches()) { + throw new IllegalArgumentException("Invalid ISO 8601 duration: " + iso8601Duration); + } + + int years = m.group(1) != null ? Integer.parseInt(m.group(1)) : 0; + int months = m.group(2) != null ? Integer.parseInt(m.group(2)) : 0; + int days = m.group(3) != null ? Integer.parseInt(m.group(3)) : 0; + int hours = m.group(4) != null ? Integer.parseInt(m.group(4)) : 0; + int minutes = m.group(5) != null ? Integer.parseInt(m.group(5)) : 0; + int seconds = m.group(6) != null ? Integer.parseInt(m.group(6)) : 0; + + // DurationInline has no year/month fields — fold into days (approximate) + int totalDays = days + years * 365 + months * 30; + + DurationInline dur = new DurationInline() + .withDays(totalDays) + .withHours(hours) + .withMinutes(minutes) + .withSeconds(seconds); + + return new WaitTask().withWait(new TimeoutAfter().withDurationInline(dur)); + } +} diff --git a/src/main/java/com/specconvert/transformer/Switch.java b/src/main/java/com/specconvert/transformer/Switch.java new file mode 100644 index 0000000..2419362 --- /dev/null +++ b/src/main/java/com/specconvert/transformer/Switch.java @@ -0,0 +1,182 @@ +package com.specconvert.transformer; + +import com.specconvert.report.MigrationReport.Category; +import com.specconvert.report.MigrationReport.Severity; +import com.specconvert.report.ReportCollector; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +// 0.8 +import io.serverlessworkflow.api.states.SwitchState; +import io.serverlessworkflow.api.switchconditions.DataCondition; +import io.serverlessworkflow.api.switchconditions.EventCondition; +import io.serverlessworkflow.api.defaultdef.DefaultConditionDefinition; + +// 1.0 +import io.serverlessworkflow.api.types.AnyEventConsumptionStrategy; +import io.serverlessworkflow.api.types.DoTask; +import io.serverlessworkflow.api.types.EventFilter; +import io.serverlessworkflow.api.types.EventProperties; +import io.serverlessworkflow.api.types.FlowDirective; +import io.serverlessworkflow.api.types.ListenTask; +import io.serverlessworkflow.api.types.ListenTaskConfiguration; +import io.serverlessworkflow.api.types.ListenTo; +import io.serverlessworkflow.api.types.SwitchCase; +import io.serverlessworkflow.api.types.SwitchItem; +import io.serverlessworkflow.api.types.SwitchTask; +import io.serverlessworkflow.api.types.Task; +import io.serverlessworkflow.api.types.TaskItem; + +public class Switch { + + public static TaskItem handleSwitch(String name, SwitchState state, + Map eventTypeByName) { + return handleSwitchFunction(name, state, eventTypeByName); + } + + protected static TaskItem handleSwitchFunction(String name, SwitchState state, + Map eventTypeByName) { + // Event-based switch → listen (with per-event filters) + switch (trivial .type checks) + if (state.getEventConditions() != null && !state.getEventConditions().isEmpty()) { + return handleEventSwitch(name, state, eventTypeByName); + } + + // Data-based switch → plain switch task (unchanged) + return handleDataSwitch(name, state); + } + + // ------------------------------------------------------------------ + // Event-based switch: listen + switch composite + // ------------------------------------------------------------------ + + /** + * A 0.8 event-switch waits for one of N events and routes based on which arrived. + * + * 1.0 translation: + * : + * do: + * - Listen: + * listen: + * to: + * any: + * - with: { type: "" } + * - with: { type: "" } + * - Route: + * switch: + * - : + * when: .type == "" + * then: + * - default: + * then: + * + * The listen task blocks until any of the listed CloudEvent types arrives and places + * the received event in the task output. The switch then performs a trivial .type + * equality check on that output to route to the correct next state. + */ + private static TaskItem handleEventSwitch(String name, SwitchState state, + Map eventTypeByName) { + List filters = new ArrayList<>(); + List switchItems = new ArrayList<>(); + + for (EventCondition cond : state.getEventConditions()) { + String eventRef = cond.getEventRef() != null ? cond.getEventRef() : "event"; + String cloudEventType = eventTypeByName.getOrDefault(eventRef, eventRef); + String caseName = util.toIdentifier(eventRef); + String nextState = resolveEventConditionTarget(name, eventRef, cond); + + // One listen filter per eventRef + filters.add(new EventFilter() + .withWith(new EventProperties().withType(cloudEventType))); + + // Trivial switch case: the received event's .type matches this CloudEvent type + switchItems.add(new SwitchItem(caseName, + new SwitchCase() + .withWhen(".type == \"" + cloudEventType + "\"") + .withThen(new FlowDirective().withString(nextState)))); + } + + // Default condition + DefaultConditionDefinition def = state.getDefaultCondition(); + if (def != null) { + String nextState = util.transitionName(def.getTransition()); + switchItems.add(new SwitchItem("default", + new SwitchCase() + .withThen(new FlowDirective().withString(nextState)))); + } + + // Build listen task: any of the listed event types unblocks it + ListenTo listenTo = new ListenTo() + .withAnyEventConsumptionStrategy( + new AnyEventConsumptionStrategy().withAny(filters)); + ListenTask listenTask = new ListenTask() + .withListen(new ListenTaskConfiguration().withTo(listenTo)); + + // Build switch task + SwitchTask switchTask = new SwitchTask().withSwitch(switchItems); + + // Wrap both in a do task keyed by the state name + List steps = List.of( + new TaskItem(name + "Listen", new Task().withListenTask(listenTask)), + new TaskItem(name + "Route", new Task().withSwitchTask(switchTask))); + + DoTask doTask = new DoTask().withDo(steps); + return new TaskItem(name, new Task().withDoTask(doTask)); + } + + /** + * Resolve the 1.0 flow-directive string for a single EventCondition. + * An EventCondition may have a transition OR an end marker. + */ + private static String resolveEventConditionTarget(String stateName, String eventRef, + EventCondition cond) { + if (cond.getTransition() != null && cond.getTransition().getNextState() != null) { + return cond.getTransition().getNextState(); + } + if (cond.getEnd() != null) { + return "end"; + } + System.err.println("[WARN] Event condition '" + eventRef + "' in switch state '" + + stateName + "' has no transition or end; emitting 'TODO' placeholder."); + ReportCollector.get().addIssue(Severity.WARNING, Category.state_transformation, + "states[" + stateName + "].eventConditions[" + eventRef + "].transition", + "Event condition has no transition or end; a 'TODO' placeholder was emitted.", + null, null, "Set the correct next state or end condition."); + return "TODO"; + } + + // ------------------------------------------------------------------ + // Data-based switch: plain switch task + // ------------------------------------------------------------------ + + private static TaskItem handleDataSwitch(String name, SwitchState state) { + List switchItems = new ArrayList<>(); + + if (state.getDataConditions() != null) { + for (DataCondition cond : state.getDataConditions()) { + String caseName = cond.getName() != null ? util.toIdentifier(cond.getName()) : "case"; + String rawExpression = cond.getCondition() != null ? cond.getCondition() : "TODO"; + String nextState = util.transitionName(cond.getTransition()); + + String strippedExpression = util.stripExpressionWrapper(rawExpression); + switchItems.add(new SwitchItem(caseName, + new SwitchCase() + .withWhen(strippedExpression) + .withThen(new FlowDirective().withString(nextState)))); + } + } + + // Default condition + DefaultConditionDefinition def = state.getDefaultCondition(); + if (def != null) { + String nextState = util.transitionName(def.getTransition()); + switchItems.add(new SwitchItem("default", + new SwitchCase() + .withThen(new FlowDirective().withString(nextState)))); + } + + SwitchTask switchTask = new SwitchTask().withSwitch(switchItems); + return new TaskItem(name, new Task().withSwitchTask(switchTask)); + } +} diff --git a/src/main/java/com/specconvert/transformer/util.java b/src/main/java/com/specconvert/transformer/util.java new file mode 100644 index 0000000..d0dae07 --- /dev/null +++ b/src/main/java/com/specconvert/transformer/util.java @@ -0,0 +1,159 @@ +package com.specconvert.transformer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.specconvert.report.MigrationReport.Category; +import com.specconvert.report.MigrationReport.Severity; +import com.specconvert.report.ReportCollector; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.nio.file.Path; +import java.util.HashMap; + +// 0.8 +import io.serverlessworkflow.api.actions.Action; +import io.serverlessworkflow.api.events.EventDefinition; +import io.serverlessworkflow.api.transitions.Transition; + +// 1.0 +import io.serverlessworkflow.api.types.CallFunction; +import io.serverlessworkflow.api.types.CallTask; +import io.serverlessworkflow.api.types.FunctionArguments; +import io.serverlessworkflow.api.types.Set; +import io.serverlessworkflow.api.types.SetTask; +import io.serverlessworkflow.api.types.SetTaskConfiguration; +import io.serverlessworkflow.api.types.Task; +import io.serverlessworkflow.api.types.TaskItem; +import java.util.Map; + +public class util { + + private static final Logger log = LoggerFactory.getLogger(util.class); + /** + * Convert a single 0.8 action to a 1.0 TaskItem. + * + * A functionRef action becomes a CallFunction task keyed by the function's refName. + * Arguments (JsonNode object) are copied as additional properties on FunctionArguments. + */ + protected static TaskItem convertAction(Action action) { + if (action.getFunctionRef() != null) { + String refName = action.getFunctionRef().getRefName() != null + ? action.getFunctionRef().getRefName() : "function"; + + FunctionArguments args = new FunctionArguments(); + JsonNode arguments = action.getFunctionRef().getArguments(); + if (arguments != null && arguments.isObject()) { + arguments.fields().forEachRemaining(e -> args.setAdditionalProperty(e.getKey(), e.getValue())); + } + + CallFunction callFn = new CallFunction() + .withCall(refName) + .withWith(args); + + String taskName = action.getName() != null ? action.getName() : refName; + return new TaskItem(taskName, new Task().withCallTask(new CallTask().withCallFunction(callFn))); + } + + // Fallback: unsupported action type — emit a set task with a warning marker + String actionName = action.getName() != null ? action.getName() : "unknown"; + System.err.println("[WARN] Action has no functionRef; emitting placeholder set task."); + ReportCollector.get().addIssue(Severity.WARNING, Category.unsupported_feature, + "action(" + actionName + ")", + "Action has no functionRef; a placeholder set task was emitted.", + null, null, "Replace the placeholder set task with the correct 1.0 call task."); + SetTaskConfiguration cfg = new SetTaskConfiguration(); + cfg.setAdditionalProperty("_warning", "unsupported action type"); + SetTask setTask = new SetTask().withSet(new Set().withSetTaskConfiguration(cfg)); + String taskName = action.getName() != null ? action.getName() : "unsupportedAction"; + return new TaskItem(taskName, new Task().withSetTask(setTask)); + } + + /** + * Extract the next-state name from a 0.8 transition + */ + protected static String transitionName(Transition t) { + if (t == null || t.getNextState() == null) return "TODO"; + return t.getNextState(); + } + + /** + * Convert a human-readable condition name like "Applicant is adult" + * into a valid camelCase identifier like "applicantIsAdult" for use as a YAML key. + */ + protected static String toIdentifier(String name) { + String[] words = name.trim().split("\\s+"); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < words.length; i++) { + String word = words[i].replaceAll("[^a-zA-Z0-9]", ""); + if (word.isEmpty()) continue; + if (i == 0) { + sb.append(word.toLowerCase()); + } else { + sb.append(Character.toUpperCase(word.charAt(0))); + sb.append(word.substring(1).toLowerCase()); + } + } + return sb.isEmpty() ? "case" : sb.toString(); + } + + /** + * Strip the 0.8 EL wrapper (${ ... }) from a condition string. + * If the expression is not wrapped, it is returned as-is. + * The inner content cannot be truly converted and will likely still need manual jq translation. + */ + protected static String stripExpressionWrapper(String expression) { + String trimmed = expression.trim(); + if (trimmed.startsWith("${") && trimmed.endsWith("}")) { + String inner = trimmed.substring(2, trimmed.length() - 1).trim(); + System.err.println("[WARN] EL expression '" + inner + "' may need manual translation to jq syntax."); + ReportCollector.get().addIssue(Severity.WARNING, Category.expression_conversion, + "expression", + "EL expression may need manual translation to jq syntax.", + trimmed, inner, + "Verify the jq expression produces the expected output."); + return inner; + } + return trimmed; + } + + public static void printUsage() { + log.info("Usage: swf-migrate [-o ] [-f yaml|json] [-n ] [--strict true|false] [--report-format json|markdown]"); + log.info("Convert a CNCF Serverless Workflow spec 0.8 document to 1.0."); + log.info(""); + log.info(" -o, --output Output file path (default: -migrated.yaml)"); + log.info(" -f, --format Output format: yaml or json (default: yaml)"); + log.info(" -n, --namespace Namespace in the 1.0 document header (default: default)"); + log.info(" -r, --report Report file path (default: -report.json|md)"); + log.info(" --report-format Report format: json or markdown (default: json)"); + log.info(" --strict Treat warnings as failures; exit with code 1 if any warnings occur (default: false)"); + } + + /** + * Returns true when the path has a .yaml or yml etension extension. + */ + public static boolean isYaml(Path path) { + if (path == null) return false; + String name = path.getFileName().toString().toLowerCase(); + return name.endsWith(".yaml") || name.endsWith(".yml"); + } + + /** + * Build a map of event-definition name → CloudEvent type string + * from the workflow's top-level events block. + * Used by handleListen() to resolve eventRef names to CloudEvent types. + */ + public static Map buildEventTypeMap(io.serverlessworkflow.api.Workflow src) { + Map map = new HashMap<>(); + if (src.getEvents() == null || src.getEvents().getEventDefs() == null) { + return map; + } + for (EventDefinition def : src.getEvents().getEventDefs()) { + if (def.getName() != null) { + // Use the declared CloudEvent type if present, otherwise fall back to the name + String type = def.getType() != null ? def.getType() : def.getName(); + map.put(def.getName(), type); + } + } + return map; + } + +} diff --git a/src/main/java/com/specconvert/validator/OutputValidator.java b/src/main/java/com/specconvert/validator/OutputValidator.java new file mode 100644 index 0000000..330c695 --- /dev/null +++ b/src/main/java/com/specconvert/validator/OutputValidator.java @@ -0,0 +1,606 @@ +package com.specconvert.validator; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.ArrayList; +import java.util.List; + +/** + * Validates a converted 1.0 Serverless Workflow document against the constraints that are + * relevant to the 0.8→1.0 migration produced by {@code swf-migrate}. + * + *

Scope

+ *

Only elements that are the direct product of the translation are checked here. + * Pure 1.0-only constructs with no 0.8 antecedent (e.g. {@code retry}, {@code timeout}, + * {@code auth}, {@code use}) are intentionally outside scope. + * + *

Checked constraints

+ *
+ *  document block
+ *    – dsl present, equals "1.0.0"
+ *    – namespace present and non-blank
+ *    – name present and non-blank
+ *    – version present and non-blank
+ *
+ *  do block
+ *    – present and is a non-empty array
+ *    – each element is a single-key object (task item)
+ *    – task key is a non-blank string
+ *
+ *  set task  (← inject state)
+ *    – set field present and is an object
+ *
+ *  wait task  (← sleep state)
+ *    – wait field present and is an object
+ *    – at least one of: seconds, minutes, hours, days is a positive integer
+ *
+ *  switch task  (← switch state / callback route)
+ *    – switch field present and is a non-empty array
+ *    – exactly one "default" case per switch array
+ *    – every non-default case has a "when" field
+ *    – every case has a "then" field
+ *    – no "_warning" marker fields (signals unresolved EL expressions)
+ *    – no "then" value equals the literal "TODO"
+ *
+ *  call task  (← operation state action / callback action)
+ *    – call field present and is a non-blank string
+ *    – with field, when present, is an object
+ *
+ *  for task  (← forEach state)
+ *    – for.in present and non-blank
+ *    – for.each present and non-blank
+ *    – do present and is a non-empty array
+ *
+ *  fork task  (← parallel state / parallel operation)
+ *    – fork.branches present and is a non-empty array
+ *    – fork.compete present and is a boolean
+ *
+ *  listen task  (← event state / callback listen step)
+ *    – listen.to present and is an object
+ *    – listen.to has exactly one of "any" or "all"
+ *    – the chosen array has at least one event filter
+ *    – each event filter has a "with.type" field
+ *
+ *  do task  (← callback composite / sequential operation)
+ *    – do field present and is a non-empty array
+ *    – recursively validated
+ * 
+ */ +public class OutputValidator { + + // Known 1.0 task types that are produced by the translator + private static final List TASK_TYPES = + List.of("set", "wait", "switch", "call", "for", "fork", "listen", "do"); + + /** + * Validate a converted 1.0 workflow document tree. + * + * @param root the root {@link JsonNode} of the serialised 1.0 workflow + * @return list of findings; empty means the output is structurally valid + */ + public List validate(JsonNode root) { + List results = new ArrayList<>(); + + validateDocument(root, results); + validateDoBlock(root, "do", results); + + return results; + } + + // ------------------------------------------------------------------ + // document block + // ------------------------------------------------------------------ + + private void validateDocument(JsonNode root, List results) { + JsonNode doc = root.get("document"); + if (doc == null || doc.isMissingNode()) { + error(results, "document", "missing_field", "'document' block is absent from the converted workflow."); + return; + } + + requireNonBlankString(doc, "document", "dsl", results); + requireNonBlankString(doc, "document", "name", results); + requireNonBlankString(doc, "document", "namespace", results); + // version may be serialised as a bare number (e.g. 1.0 in YAML) — accept text or number + requireNonBlankStringOrNumber(doc, "document", "version", results); + + // dsl must be exactly "1.0.0" — any other value indicates a mis-conversion + JsonNode dsl = doc.get("dsl"); + if (dsl != null && dsl.isTextual() && !"1.0.0".equals(dsl.asText())) { + error(results, "document.dsl", "invalid_value", + "Expected 'dsl' to be '1.0.0' but found '" + dsl.asText() + "'."); + } + } + + // ------------------------------------------------------------------ + // do block (top-level and recursive) + // ------------------------------------------------------------------ + + private void validateDoBlock(JsonNode parent, String fieldPath, List results) { + JsonNode doNode = parent.get("do"); + if (doNode == null || doNode.isMissingNode()) { + error(results, fieldPath, "missing_field", + "'" + fieldPath + "' block is absent; the workflow has no tasks."); + return; + } + if (!doNode.isArray()) { + error(results, fieldPath, "wrong_type", + "'" + fieldPath + "' must be an array of task items."); + return; + } + if (doNode.size() == 0) { + warn(results, fieldPath, "empty_list", + "'" + fieldPath + "' is empty; no tasks were translated."); + return; + } + + for (int i = 0; i < doNode.size(); i++) { + validateTaskItem(doNode.get(i), fieldPath + "[" + i + "]", results); + } + } + + // ------------------------------------------------------------------ + // Task item — single-key object { "": { } } + // ------------------------------------------------------------------ + + private void validateTaskItem(JsonNode item, String path, List results) { + if (!item.isObject()) { + error(results, path, "wrong_type", "Task item must be an object."); + return; + } + if (item.size() != 1) { + error(results, path, "invalid_structure", + "Task item must contain exactly one key (the task name), but found " + item.size() + " keys."); + return; + } + + String taskName = item.fieldNames().next(); + if (taskName == null || taskName.isBlank()) { + error(results, path, "blank_task_name", "Task item key (name) must be a non-blank string."); + return; + } + + String taskPath = path + "." + taskName; + JsonNode body = item.get(taskName); + + if (body == null || body.isMissingNode() || !body.isObject()) { + error(results, taskPath, "missing_task_body", + "Task '" + taskName + "' has no body object."); + return; + } + + validateTaskBody(body, taskPath, results); + } + + // ------------------------------------------------------------------ + // Task body — dispatch on the task-type key present in the body + // ------------------------------------------------------------------ + + private void validateTaskBody(JsonNode body, String path, List results) { + // Determine which task type key is present + String foundType = null; + for (String type : TASK_TYPES) { + if (body.has(type)) { + foundType = type; + break; + } + } + + if (foundType == null) { + error(results, path, "unknown_task_type", + "Task body does not contain any recognised task-type field. " + + "Expected one of: " + String.join(", ", TASK_TYPES) + "."); + return; + } + + switch (foundType) { + case "set" -> validateSetTask(body, path, results); + case "wait" -> validateWaitTask(body, path, results); + case "switch" -> validateSwitchTask(body, path, results); + case "call" -> validateCallTask(body, path, results); + case "for" -> validateForTask(body, path, results); + case "fork" -> validateForkTask(body, path, results); + case "listen" -> validateListenTask(body, path, results); + case "do" -> validateDoTask(body, path, results); + } + } + + // ------------------------------------------------------------------ + // set task (← inject state) + // ------------------------------------------------------------------ + + private void validateSetTask(JsonNode body, String path, List results) { + JsonNode set = body.get("set"); + if (set == null || set.isMissingNode()) { + error(results, path + ".set", "missing_field", "'set' field is absent from set task."); + return; + } + if (!set.isObject()) { + error(results, path + ".set", "wrong_type", + "'set' must be an object containing the variables to assign."); + } + } + + // ------------------------------------------------------------------ + // wait task (← sleep state) + // ------------------------------------------------------------------ + + private void validateWaitTask(JsonNode body, String path, List results) { + JsonNode wait = body.get("wait"); + if (wait == null || wait.isMissingNode()) { + error(results, path + ".wait", "missing_field", "'wait' field is absent from wait task."); + return; + } + if (!wait.isObject()) { + error(results, path + ".wait", "wrong_type", + "'wait' must be an object containing duration fields."); + return; + } + + // At least one positive duration component must be present + boolean hasPositiveDuration = false; + for (String field : List.of("seconds", "minutes", "hours", "days")) { + JsonNode v = wait.get(field); + if (v != null && v.isInt() && v.asInt() > 0) { + hasPositiveDuration = true; + break; + } + } + if (!hasPositiveDuration) { + warn(results, path + ".wait", "zero_duration", + "wait task has no positive duration component (seconds/minutes/hours/days). " + + "This is a zero-duration wait — verify the original ISO 8601 duration was parsed correctly."); + } + } + + // ------------------------------------------------------------------ + // switch task (← switch state / callback route) + // ------------------------------------------------------------------ + + private void validateSwitchTask(JsonNode body, String path, List results) { + JsonNode sw = body.get("switch"); + if (sw == null || sw.isMissingNode()) { + error(results, path + ".switch", "missing_field", "'switch' field is absent from switch task."); + return; + } + if (!sw.isArray()) { + error(results, path + ".switch", "wrong_type", "'switch' must be an array of case items."); + return; + } + if (sw.size() == 0) { + error(results, path + ".switch", "empty_list", + "'switch' array is empty; at least one case (default) is required."); + return; + } + + int defaultCount = 0; + for (int i = 0; i < sw.size(); i++) { + JsonNode caseItem = sw.get(i); + String casePath = path + ".switch[" + i + "]"; + validateSwitchCaseItem(caseItem, casePath, results); + + // Count default cases + if (caseItem.isObject() && caseItem.has("default")) { + defaultCount++; + } + } + + if (defaultCount == 0) { + warn(results, path + ".switch", "no_default_case", + "switch task has no 'default' case. Workflows without a default may get stuck."); + } else if (defaultCount > 1) { + error(results, path + ".switch", "duplicate_default_case", + "switch task has " + defaultCount + " 'default' cases; exactly one is allowed."); + } + } + + private void validateSwitchCaseItem(JsonNode caseItem, String path, List results) { + if (!caseItem.isObject() || caseItem.size() != 1) { + error(results, path, "invalid_structure", + "Switch case item must be a single-key object keyed by the case name."); + return; + } + + String caseName = caseItem.fieldNames().next(); + JsonNode caseBody = caseItem.get(caseName); + String casePath = path + "." + caseName; + + if (caseBody == null || !caseBody.isObject()) { + error(results, casePath, "missing_case_body", + "Switch case '" + caseName + "' has no body object."); + return; + } + + // Non-default cases must have a "when" predicate + if (!"default".equals(caseName)) { + if (!caseBody.has("when") || caseBody.get("when").asText("").isBlank()) { + error(results, casePath + ".when", "missing_field", + "Non-default switch case '" + caseName + "' is missing the 'when' predicate."); + } else { + // Check for unresolved EL expressions left as TODO + String when = caseBody.get("when").asText(""); + if ("TODO".equals(when)) { + warn(results, casePath + ".when", "todo_placeholder", + "Switch case '" + caseName + "' has a 'TODO' placeholder in 'when'. " + + "Manually replace with a valid jq expression."); + } + } + } + + // Every case must have a "then" directive + if (!caseBody.has("then")) { + error(results, casePath + ".then", "missing_field", + "Switch case '" + caseName + "' is missing the 'then' flow directive."); + } else { + String then = caseBody.get("then").asText(""); + if ("TODO".equals(then)) { + error(results, casePath + ".then", "todo_placeholder", + "Switch case '" + caseName + "' has a 'TODO' placeholder in 'then'. " + + "Set the correct next state or 'end'."); + } + } + + // Warn on any _warning marker field injected by the EL-expression handler + if (caseBody.has("_warning")) { + warn(results, casePath + "._warning", "unresolved_expression", + "Switch case '" + caseName + "' contains a '_warning' marker: " + + caseBody.get("_warning").asText() + + " — the 'when' expression requires manual translation to jq syntax."); + } + } + + // ------------------------------------------------------------------ + // call task (← operation action / callback action) + // ------------------------------------------------------------------ + + private void validateCallTask(JsonNode body, String path, List results) { + JsonNode call = body.get("call"); + if (call == null || call.isMissingNode()) { + error(results, path + ".call", "missing_field", "'call' field is absent from call task."); + return; + } + if (!call.isTextual() || call.asText().isBlank()) { + error(results, path + ".call", "invalid_value", + "'call' must be a non-blank string naming the function to invoke."); + } + + JsonNode with = body.get("with"); + if (with != null && !with.isMissingNode() && !with.isObject()) { + error(results, path + ".with", "wrong_type", + "'with' must be an object containing the function arguments."); + } + } + + // ------------------------------------------------------------------ + // for task (← forEach state) + // ------------------------------------------------------------------ + + private void validateForTask(JsonNode body, String path, List results) { + JsonNode forNode = body.get("for"); + if (forNode == null || forNode.isMissingNode()) { + error(results, path + ".for", "missing_field", "'for' field is absent from for task."); + return; + } + if (!forNode.isObject()) { + error(results, path + ".for", "wrong_type", "'for' must be an object."); + return; + } + + // for.in — collection expression + JsonNode in = forNode.get("in"); + if (in == null || in.isMissingNode() || !in.isTextual() || in.asText().isBlank()) { + error(results, path + ".for.in", "missing_field", + "'for.in' must be a non-blank expression identifying the collection to iterate."); + } + + // for.each — iteration variable name + JsonNode each = forNode.get("each"); + if (each == null || each.isMissingNode() || !each.isTextual() || each.asText().isBlank()) { + error(results, path + ".for.each", "missing_field", + "'for.each' must be a non-blank variable name bound to each item."); + } + + // do — the body to execute for each item + if (!body.has("do")) { + error(results, path + ".do", "missing_field", + "for task is missing a 'do' block listing the tasks to run per iteration."); + } else { + JsonNode doNode = body.get("do"); + if (!doNode.isArray() || doNode.size() == 0) { + warn(results, path + ".do", "empty_list", + "for task 'do' is empty; no per-iteration tasks will execute."); + } else { + for (int i = 0; i < doNode.size(); i++) { + validateTaskItem(doNode.get(i), path + ".do[" + i + "]", results); + } + } + } + } + + // ------------------------------------------------------------------ + // fork task (← parallel state / parallel operation) + // ------------------------------------------------------------------ + + private void validateForkTask(JsonNode body, String path, List results) { + JsonNode fork = body.get("fork"); + if (fork == null || fork.isMissingNode()) { + error(results, path + ".fork", "missing_field", "'fork' field is absent from fork task."); + return; + } + if (!fork.isObject()) { + error(results, path + ".fork", "wrong_type", "'fork' must be an object."); + return; + } + + // compete — boolean + JsonNode compete = fork.get("compete"); + if (compete == null || compete.isMissingNode()) { + error(results, path + ".fork.compete", "missing_field", + "'fork.compete' is absent. Set to false (all branches) or true (first to complete wins)."); + } else if (!compete.isBoolean()) { + error(results, path + ".fork.compete", "wrong_type", + "'fork.compete' must be a boolean."); + } + + // branches — non-empty array + JsonNode branches = fork.get("branches"); + if (branches == null || branches.isMissingNode()) { + error(results, path + ".fork.branches", "missing_field", + "'fork.branches' is absent; at least one branch is required."); + } else if (!branches.isArray()) { + error(results, path + ".fork.branches", "wrong_type", + "'fork.branches' must be an array."); + } else if (branches.size() == 0) { + error(results, path + ".fork.branches", "empty_list", + "'fork.branches' is empty; at least one branch is required."); + } else { + for (int i = 0; i < branches.size(); i++) { + validateTaskItem(branches.get(i), path + ".fork.branches[" + i + "]", results); + } + } + } + + // ------------------------------------------------------------------ + // listen task (← event state / callback listen step) + // ------------------------------------------------------------------ + + private void validateListenTask(JsonNode body, String path, List results) { + JsonNode listen = body.get("listen"); + if (listen == null || listen.isMissingNode()) { + error(results, path + ".listen", "missing_field", "'listen' field is absent from listen task."); + return; + } + if (!listen.isObject()) { + error(results, path + ".listen", "wrong_type", "'listen' must be an object."); + return; + } + + JsonNode to = listen.get("to"); + if (to == null || to.isMissingNode()) { + error(results, path + ".listen.to", "missing_field", + "'listen.to' is absent; it must specify 'any' or 'all' event filters."); + return; + } + if (!to.isObject()) { + error(results, path + ".listen.to", "wrong_type", "'listen.to' must be an object."); + return; + } + + boolean hasAny = to.has("any"); + boolean hasAll = to.has("all"); + + if (!hasAny && !hasAll) { + error(results, path + ".listen.to", "missing_field", + "'listen.to' must contain either 'any' or 'all' to specify which events to listen for."); + return; + } + if (hasAny && hasAll) { + error(results, path + ".listen.to", "conflicting_fields", + "'listen.to' must not contain both 'any' and 'all'."); + return; + } + + String strategyKey = hasAny ? "any" : "all"; + JsonNode filters = to.get(strategyKey); + if (!filters.isArray() || filters.size() == 0) { + error(results, path + ".listen.to." + strategyKey, "empty_list", + "'listen.to." + strategyKey + "' must be a non-empty array of event filters."); + } else { + for (int i = 0; i < filters.size(); i++) { + validateEventFilter(filters.get(i), path + ".listen.to." + strategyKey + "[" + i + "]", results); + } + } + } + + private void validateEventFilter(JsonNode filter, String path, List results) { + if (!filter.isObject()) { + error(results, path, "wrong_type", "Event filter must be an object."); + return; + } + + JsonNode with = filter.get("with"); + if (with == null || with.isMissingNode()) { + warn(results, path + ".with", "missing_field", + "Event filter has no 'with' object. Typically at minimum 'with.type' should be set."); + return; + } + if (!with.isObject()) { + error(results, path + ".with", "wrong_type", "Event filter 'with' must be an object."); + return; + } + + JsonNode type = with.get("type"); + if (type == null || type.isMissingNode() || !type.isTextual() || type.asText().isBlank()) { + warn(results, path + ".with.type", "missing_field", + "Event filter is missing 'with.type'. A CloudEvent type filter is strongly recommended " + + "to avoid consuming unintended events."); + } + } + + // ------------------------------------------------------------------ + // do task (← callback composite / sequential operation) + // ------------------------------------------------------------------ + + private void validateDoTask(JsonNode body, String path, List results) { + JsonNode doNode = body.get("do"); + if (doNode == null || doNode.isMissingNode()) { + error(results, path + ".do", "missing_field", "'do' field is absent from do task."); + return; + } + if (!doNode.isArray()) { + error(results, path + ".do", "wrong_type", "'do' must be an array of task items."); + return; + } + if (doNode.size() == 0) { + warn(results, path + ".do", "empty_list", + "'do' task is empty; no steps will execute."); + return; + } + + for (int i = 0; i < doNode.size(); i++) { + validateTaskItem(doNode.get(i), path + ".do[" + i + "]", results); + } + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private void requireNonBlankString(JsonNode parent, String parentPath, + String field, List results) { + JsonNode node = parent.get(field); + if (node == null || node.isMissingNode()) { + error(results, parentPath + "." + field, "missing_field", + "'" + parentPath + "." + field + "' is absent."); + } else if (!node.isTextual() || node.asText().isBlank()) { + error(results, parentPath + "." + field, "invalid_value", + "'" + parentPath + "." + field + "' must be a non-blank string."); + } + } + + /** + * Like {@link #requireNonBlankString} but also accepts numeric nodes (e.g. YAML bare floats + * such as {@code 1.0} for version fields where the SDK omits quotes). + */ + private void requireNonBlankStringOrNumber(JsonNode parent, String parentPath, + String field, List results) { + JsonNode node = parent.get(field); + if (node == null || node.isMissingNode()) { + error(results, parentPath + "." + field, "missing_field", + "'" + parentPath + "." + field + "' is absent."); + } else if (!node.isTextual() && !node.isNumber()) { + error(results, parentPath + "." + field, "invalid_value", + "'" + parentPath + "." + field + "' must be a non-blank string or number."); + } else if (node.isTextual() && node.asText().isBlank()) { + error(results, parentPath + "." + field, "invalid_value", + "'" + parentPath + "." + field + "' must not be blank."); + } + } + + private static void error(List results, String path, String rule, String message) { + results.add(new ValidationResult(ValidationResult.Severity.ERROR, path, rule, message)); + } + + private static void warn(List results, String path, String rule, String message) { + results.add(new ValidationResult(ValidationResult.Severity.WARNING, path, rule, message)); + } +} diff --git a/src/main/java/com/specconvert/validator/ValidationResult.java b/src/main/java/com/specconvert/validator/ValidationResult.java new file mode 100644 index 0000000..9e57859 --- /dev/null +++ b/src/main/java/com/specconvert/validator/ValidationResult.java @@ -0,0 +1,34 @@ +package com.specconvert.validator; + +/** + * A single finding produced by {@link OutputValidator}. + * + *

Each result records: + *

    + *
  • severity — ERROR or WARNING
  • + *
  • path — JSON-pointer-style location in the 1.0 document (e.g. {@code do[0].HelloState.switch[1].default})
  • + *
  • rule — short identifier for the violated constraint (e.g. {@code missing_field})
  • + *
  • message — human-readable description of the finding
  • + *
+ */ +public final class ValidationResult { + + public enum Severity { ERROR, WARNING } + + public final Severity severity; + public final String path; + public final String rule; + public final String message; + + public ValidationResult(Severity severity, String path, String rule, String message) { + this.severity = severity; + this.path = path; + this.rule = rule; + this.message = message; + } + + @Override + public String toString() { + return "[" + severity + "] " + path + " — " + rule + ": " + message; + } +} diff --git a/src/main/resources/META-INF/native-image/reflect-config.json b/src/main/resources/META-INF/native-image/reflect-config.json new file mode 100644 index 0000000..4722a98 --- /dev/null +++ b/src/main/resources/META-INF/native-image/reflect-config.json @@ -0,0 +1,66 @@ +[ + { + "name": "com.fasterxml.jackson.databind.ext.Java7Support", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true + }, + { + "name": "com.fasterxml.jackson.databind.deser.std.NumberDeserializers$IntegerDeserializer", + "allDeclaredConstructors": true, + "allPublicConstructors": true + }, + { + "name": "com.fasterxml.jackson.dataformat.yaml.YAMLFactory", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true + }, + { + "name": "io.serverlessworkflow.api.Workflow", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "io.serverlessworkflow.api.types.Workflow", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "io.serverlessworkflow.api.states.DefaultState$Type", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "io.serverlessworkflow.api.states.OperationState$ActionMode", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "io.serverlessworkflow.api.states.ForEachState$Mode", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + } +] diff --git a/workflows/release.yml b/workflows/release.yml new file mode 100644 index 0000000..ac013e8 --- /dev/null +++ b/workflows/release.yml @@ -0,0 +1,80 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build-native: + name: Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + artifact: swf-migrate-linux + binary: target/swf-migrate + - os: macos-latest + artifact: swf-migrate-macos + binary: target/swf-migrate + - os: windows-latest + artifact: swf-migrate-windows.exe + binary: target/swf-migrate.exe + + steps: + - uses: actions/checkout@v4 + + - name: Set up GraalVM JDK 21 + uses: graalvm/setup-graalvm@v1 + with: + java-version: '21' + distribution: 'graalvm' + + - name: Install v08 SDK into local Maven repo + shell: bash + run: | + mvn dependency:get -q -Dartifact=io.serverlessworkflow:serverlessworkflow-api:4.1.0.Final + mvn install:install-file -q \ + -Dfile="${HOME}/.m2/repository/io/serverlessworkflow/serverlessworkflow-api/4.1.0.Final/serverlessworkflow-api-4.1.0.Final.jar" \ + -DgroupId=io.serverlessworkflow.v08 \ + -DartifactId=serverlessworkflow-api \ + -Dversion=4.1.0.Final \ + -Dpackaging=jar + + - name: Build native binary + run: mvn package -Pnative -q + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: ${{ matrix.binary }} + + release: + name: Create release + needs: build-native + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download all binaries + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Rename artifacts + run: | + mv artifacts/swf-migrate-linux/swf-migrate swf-migrate-linux + mv artifacts/swf-migrate-macos/swf-migrate swf-migrate-macos + mv artifacts/swf-migrate-windows.exe/swf-migrate.exe swf-migrate-windows.exe + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + files: | + swf-migrate-linux + swf-migrate-macos + swf-migrate-windows.exe + generate_release_notes: true From ef1584ee0e052ea43bfdd82379f2730bcce3a1cc Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Tue, 22 Sep 2026 16:27:35 +0100 Subject: [PATCH 2/4] package refactor Signed-off-by: Sean Kavanagh --- dependency-reduced-pom.xml | 6 ++-- pom.xml | 6 ++-- .../migrationtool}/SpecConvert.java | 34 +++++++++---------- .../report/JsonReportWriter.java | 2 +- .../report/MarkdownReportWriter.java | 2 +- .../report/MigrationReport.java | 2 +- .../report/ReportCollector.java | 10 +++--- .../migrationtool}/report/ReportWriter.java | 2 +- .../migrationtool}/transformer/Callback.java | 8 ++--- .../migrationtool}/transformer/Event.java | 2 +- .../migrationtool}/transformer/ForEach.java | 8 ++--- .../migrationtool}/transformer/Inject.java | 2 +- .../migrationtool}/transformer/Operation.java | 2 +- .../migrationtool}/transformer/Parallel.java | 2 +- .../migrationtool}/transformer/Sleep.java | 2 +- .../migrationtool}/transformer/Switch.java | 8 ++--- .../migrationtool}/transformer/util.java | 8 ++--- .../validator/OutputValidator.java | 2 +- .../validator/ValidationResult.java | 2 +- 19 files changed, 55 insertions(+), 55 deletions(-) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/SpecConvert.java (93%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/report/JsonReportWriter.java (92%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/report/MarkdownReportWriter.java (99%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/report/MigrationReport.java (98%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/report/ReportCollector.java (91%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/report/ReportWriter.java (96%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/transformer/Callback.java (95%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/transformer/Event.java (98%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/transformer/ForEach.java (92%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/transformer/Inject.java (95%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/transformer/Operation.java (98%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/transformer/Parallel.java (97%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/transformer/Sleep.java (97%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/transformer/Switch.java (96%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/transformer/util.java (96%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/validator/OutputValidator.java (99%) rename src/main/java/{com/specconvert => org/openworkflow/migrationtool}/validator/ValidationResult.java (95%) diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml index 046d25a..cde0a52 100644 --- a/dependency-reduced-pom.xml +++ b/dependency-reduced-pom.xml @@ -1,7 +1,7 @@ 4.0.0 - com.specconvert + org.openworkflow.migrationtool spec-convert 1.0-SNAPSHOT @@ -27,7 +27,7 @@ spec-convert - com.specconvert.SpecConvert + org.openworkflow.migrationtool.SpecConvert @@ -68,7 +68,7 @@ swf-migrate - com.specconvert.SpecConvert + org.openworkflow.migrationtool.SpecConvert --no-fallback -H:+ReportExceptionStackTraces diff --git a/pom.xml b/pom.xml index 8a7aa2d..558fe18 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.specconvert + org.openworkflow.migrationtool spec-convert 1.0-SNAPSHOT jar @@ -80,7 +80,7 @@ spec-convert - com.specconvert.SpecConvert + org.openworkflow.migrationtool.SpecConvert @@ -121,7 +121,7 @@ swf-migrate - com.specconvert.SpecConvert + org.openworkflow.migrationtool.SpecConvert --no-fallback -H:+ReportExceptionStackTraces diff --git a/src/main/java/com/specconvert/SpecConvert.java b/src/main/java/org/openworkflow/migrationtool/SpecConvert.java similarity index 93% rename from src/main/java/com/specconvert/SpecConvert.java rename to src/main/java/org/openworkflow/migrationtool/SpecConvert.java index 8ef87e5..97285bf 100644 --- a/src/main/java/com/specconvert/SpecConvert.java +++ b/src/main/java/org/openworkflow/migrationtool/SpecConvert.java @@ -1,23 +1,23 @@ -package com.specconvert; +package org.openworkflow.migrationtool; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.specconvert.report.MigrationReport; -import com.specconvert.report.ReportCollector; -import com.specconvert.report.ReportWriter; -import com.specconvert.validator.OutputValidator; -import com.specconvert.validator.ValidationResult; -import com.specconvert.transformer.Callback; -import com.specconvert.transformer.Event; -import com.specconvert.transformer.ForEach; -import com.specconvert.transformer.Inject; -import com.specconvert.transformer.Operation; -import com.specconvert.transformer.Parallel; -import com.specconvert.transformer.Sleep; -import com.specconvert.transformer.Switch; -import com.specconvert.transformer.util; +import org.openworkflow.migrationtool.report.MigrationReport; +import org.openworkflow.migrationtool.report.ReportCollector; +import org.openworkflow.migrationtool.report.ReportWriter; +import org.openworkflow.migrationtool.validator.OutputValidator; +import org.openworkflow.migrationtool.validator.ValidationResult; +import org.openworkflow.migrationtool.transformer.Callback; +import org.openworkflow.migrationtool.transformer.Event; +import org.openworkflow.migrationtool.transformer.ForEach; +import org.openworkflow.migrationtool.transformer.Inject; +import org.openworkflow.migrationtool.transformer.Operation; +import org.openworkflow.migrationtool.transformer.Parallel; +import org.openworkflow.migrationtool.transformer.Sleep; +import org.openworkflow.migrationtool.transformer.Switch; +import org.openworkflow.migrationtool.transformer.util; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -363,8 +363,8 @@ private static List buildDo(io.serverlessworkflow.api.Workflow src) { System.err.println("[WARN] Unsupported state type for state '" + stateName + "' (" + state.getClass().getSimpleName() + "); skipping."); ReportCollector.get().addIssue( - com.specconvert.report.MigrationReport.Severity.ERROR, - com.specconvert.report.MigrationReport.Category.unsupported_feature, + org.openworkflow.migrationtool.report.MigrationReport.Severity.ERROR, + org.openworkflow.migrationtool.report.MigrationReport.Category.unsupported_feature, "states[" + stateName + "]", "State type " + state.getClass().getSimpleName() + " has no 1.0 equivalent; state was skipped.", null, null, "Manually implement this state in the converted workflow."); diff --git a/src/main/java/com/specconvert/report/JsonReportWriter.java b/src/main/java/org/openworkflow/migrationtool/report/JsonReportWriter.java similarity index 92% rename from src/main/java/com/specconvert/report/JsonReportWriter.java rename to src/main/java/org/openworkflow/migrationtool/report/JsonReportWriter.java index e148ae1..151aa94 100644 --- a/src/main/java/com/specconvert/report/JsonReportWriter.java +++ b/src/main/java/org/openworkflow/migrationtool/report/JsonReportWriter.java @@ -1,4 +1,4 @@ -package com.specconvert.report; +package org.openworkflow.migrationtool.report; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; diff --git a/src/main/java/com/specconvert/report/MarkdownReportWriter.java b/src/main/java/org/openworkflow/migrationtool/report/MarkdownReportWriter.java similarity index 99% rename from src/main/java/com/specconvert/report/MarkdownReportWriter.java rename to src/main/java/org/openworkflow/migrationtool/report/MarkdownReportWriter.java index ccd2406..3009cd5 100644 --- a/src/main/java/com/specconvert/report/MarkdownReportWriter.java +++ b/src/main/java/org/openworkflow/migrationtool/report/MarkdownReportWriter.java @@ -1,4 +1,4 @@ -package com.specconvert.report; +package org.openworkflow.migrationtool.report; import java.io.IOException; import java.nio.file.Files; diff --git a/src/main/java/com/specconvert/report/MigrationReport.java b/src/main/java/org/openworkflow/migrationtool/report/MigrationReport.java similarity index 98% rename from src/main/java/com/specconvert/report/MigrationReport.java rename to src/main/java/org/openworkflow/migrationtool/report/MigrationReport.java index 643d270..c2fbfdb 100644 --- a/src/main/java/com/specconvert/report/MigrationReport.java +++ b/src/main/java/org/openworkflow/migrationtool/report/MigrationReport.java @@ -1,4 +1,4 @@ -package com.specconvert.report; +package org.openworkflow.migrationtool.report; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/specconvert/report/ReportCollector.java b/src/main/java/org/openworkflow/migrationtool/report/ReportCollector.java similarity index 91% rename from src/main/java/com/specconvert/report/ReportCollector.java rename to src/main/java/org/openworkflow/migrationtool/report/ReportCollector.java index 8d0217a..6ccac32 100644 --- a/src/main/java/com/specconvert/report/ReportCollector.java +++ b/src/main/java/org/openworkflow/migrationtool/report/ReportCollector.java @@ -1,9 +1,9 @@ -package com.specconvert.report; +package org.openworkflow.migrationtool.report; -import com.specconvert.report.MigrationReport.Category; -import com.specconvert.report.MigrationReport.Issue; -import com.specconvert.report.MigrationReport.ManualTask; -import com.specconvert.report.MigrationReport.Severity; +import org.openworkflow.migrationtool.report.MigrationReport.Category; +import org.openworkflow.migrationtool.report.MigrationReport.Issue; +import org.openworkflow.migrationtool.report.MigrationReport.ManualTask; +import org.openworkflow.migrationtool.report.MigrationReport.Severity; /** * Call-scoped collector for migration issues and manual tasks. diff --git a/src/main/java/com/specconvert/report/ReportWriter.java b/src/main/java/org/openworkflow/migrationtool/report/ReportWriter.java similarity index 96% rename from src/main/java/com/specconvert/report/ReportWriter.java rename to src/main/java/org/openworkflow/migrationtool/report/ReportWriter.java index 491e1af..aa6b526 100644 --- a/src/main/java/com/specconvert/report/ReportWriter.java +++ b/src/main/java/org/openworkflow/migrationtool/report/ReportWriter.java @@ -1,4 +1,4 @@ -package com.specconvert.report; +package org.openworkflow.migrationtool.report; import java.io.IOException; import java.nio.file.Path; diff --git a/src/main/java/com/specconvert/transformer/Callback.java b/src/main/java/org/openworkflow/migrationtool/transformer/Callback.java similarity index 95% rename from src/main/java/com/specconvert/transformer/Callback.java rename to src/main/java/org/openworkflow/migrationtool/transformer/Callback.java index 02b5b0e..e69659f 100644 --- a/src/main/java/com/specconvert/transformer/Callback.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Callback.java @@ -1,8 +1,8 @@ -package com.specconvert.transformer; +package org.openworkflow.migrationtool.transformer; -import com.specconvert.report.MigrationReport.Category; -import com.specconvert.report.MigrationReport.Severity; -import com.specconvert.report.ReportCollector; +import org.openworkflow.migrationtool.report.MigrationReport.Category; +import org.openworkflow.migrationtool.report.MigrationReport.Severity; +import org.openworkflow.migrationtool.report.ReportCollector; import java.util.ArrayList; import java.util.List; import java.util.Map; diff --git a/src/main/java/com/specconvert/transformer/Event.java b/src/main/java/org/openworkflow/migrationtool/transformer/Event.java similarity index 98% rename from src/main/java/com/specconvert/transformer/Event.java rename to src/main/java/org/openworkflow/migrationtool/transformer/Event.java index 32bedd6..d1a7220 100644 --- a/src/main/java/com/specconvert/transformer/Event.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Event.java @@ -1,4 +1,4 @@ -package com.specconvert.transformer; +package org.openworkflow.migrationtool.transformer; import java.util.ArrayList; import java.util.Map; diff --git a/src/main/java/com/specconvert/transformer/ForEach.java b/src/main/java/org/openworkflow/migrationtool/transformer/ForEach.java similarity index 92% rename from src/main/java/com/specconvert/transformer/ForEach.java rename to src/main/java/org/openworkflow/migrationtool/transformer/ForEach.java index 410420e..1c20886 100644 --- a/src/main/java/com/specconvert/transformer/ForEach.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/ForEach.java @@ -1,8 +1,8 @@ -package com.specconvert.transformer; +package org.openworkflow.migrationtool.transformer; -import com.specconvert.report.MigrationReport.Category; -import com.specconvert.report.MigrationReport.Severity; -import com.specconvert.report.ReportCollector; +import org.openworkflow.migrationtool.report.MigrationReport.Category; +import org.openworkflow.migrationtool.report.MigrationReport.Severity; +import org.openworkflow.migrationtool.report.ReportCollector; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/com/specconvert/transformer/Inject.java b/src/main/java/org/openworkflow/migrationtool/transformer/Inject.java similarity index 95% rename from src/main/java/com/specconvert/transformer/Inject.java rename to src/main/java/org/openworkflow/migrationtool/transformer/Inject.java index baa9297..7606533 100644 --- a/src/main/java/com/specconvert/transformer/Inject.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Inject.java @@ -1,4 +1,4 @@ -package com.specconvert.transformer; +package org.openworkflow.migrationtool.transformer; import io.serverlessworkflow.api.states.InjectState; import io.serverlessworkflow.api.types.Set; diff --git a/src/main/java/com/specconvert/transformer/Operation.java b/src/main/java/org/openworkflow/migrationtool/transformer/Operation.java similarity index 98% rename from src/main/java/com/specconvert/transformer/Operation.java rename to src/main/java/org/openworkflow/migrationtool/transformer/Operation.java index e73d7c6..e4c7ced 100644 --- a/src/main/java/com/specconvert/transformer/Operation.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Operation.java @@ -1,4 +1,4 @@ -package com.specconvert.transformer; +package org.openworkflow.migrationtool.transformer; import java.util.ArrayList; diff --git a/src/main/java/com/specconvert/transformer/Parallel.java b/src/main/java/org/openworkflow/migrationtool/transformer/Parallel.java similarity index 97% rename from src/main/java/com/specconvert/transformer/Parallel.java rename to src/main/java/org/openworkflow/migrationtool/transformer/Parallel.java index dad094b..22ce469 100644 --- a/src/main/java/com/specconvert/transformer/Parallel.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Parallel.java @@ -1,4 +1,4 @@ -package com.specconvert.transformer; +package org.openworkflow.migrationtool.transformer; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/com/specconvert/transformer/Sleep.java b/src/main/java/org/openworkflow/migrationtool/transformer/Sleep.java similarity index 97% rename from src/main/java/com/specconvert/transformer/Sleep.java rename to src/main/java/org/openworkflow/migrationtool/transformer/Sleep.java index 9967fcf..be9a916 100644 --- a/src/main/java/com/specconvert/transformer/Sleep.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Sleep.java @@ -1,4 +1,4 @@ -package com.specconvert.transformer; +package org.openworkflow.migrationtool.transformer; import java.util.regex.Matcher; import java.util.regex.Pattern; diff --git a/src/main/java/com/specconvert/transformer/Switch.java b/src/main/java/org/openworkflow/migrationtool/transformer/Switch.java similarity index 96% rename from src/main/java/com/specconvert/transformer/Switch.java rename to src/main/java/org/openworkflow/migrationtool/transformer/Switch.java index 2419362..b35918b 100644 --- a/src/main/java/com/specconvert/transformer/Switch.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Switch.java @@ -1,8 +1,8 @@ -package com.specconvert.transformer; +package org.openworkflow.migrationtool.transformer; -import com.specconvert.report.MigrationReport.Category; -import com.specconvert.report.MigrationReport.Severity; -import com.specconvert.report.ReportCollector; +import org.openworkflow.migrationtool.report.MigrationReport.Category; +import org.openworkflow.migrationtool.report.MigrationReport.Severity; +import org.openworkflow.migrationtool.report.ReportCollector; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/com/specconvert/transformer/util.java b/src/main/java/org/openworkflow/migrationtool/transformer/util.java similarity index 96% rename from src/main/java/com/specconvert/transformer/util.java rename to src/main/java/org/openworkflow/migrationtool/transformer/util.java index d0dae07..09c276c 100644 --- a/src/main/java/com/specconvert/transformer/util.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/util.java @@ -1,9 +1,9 @@ -package com.specconvert.transformer; +package org.openworkflow.migrationtool.transformer; import com.fasterxml.jackson.databind.JsonNode; -import com.specconvert.report.MigrationReport.Category; -import com.specconvert.report.MigrationReport.Severity; -import com.specconvert.report.ReportCollector; +import org.openworkflow.migrationtool.report.MigrationReport.Category; +import org.openworkflow.migrationtool.report.MigrationReport.Severity; +import org.openworkflow.migrationtool.report.ReportCollector; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.file.Path; diff --git a/src/main/java/com/specconvert/validator/OutputValidator.java b/src/main/java/org/openworkflow/migrationtool/validator/OutputValidator.java similarity index 99% rename from src/main/java/com/specconvert/validator/OutputValidator.java rename to src/main/java/org/openworkflow/migrationtool/validator/OutputValidator.java index 330c695..a52bcaf 100644 --- a/src/main/java/com/specconvert/validator/OutputValidator.java +++ b/src/main/java/org/openworkflow/migrationtool/validator/OutputValidator.java @@ -1,4 +1,4 @@ -package com.specconvert.validator; +package org.openworkflow.migrationtool.validator; import com.fasterxml.jackson.databind.JsonNode; import java.util.ArrayList; diff --git a/src/main/java/com/specconvert/validator/ValidationResult.java b/src/main/java/org/openworkflow/migrationtool/validator/ValidationResult.java similarity index 95% rename from src/main/java/com/specconvert/validator/ValidationResult.java rename to src/main/java/org/openworkflow/migrationtool/validator/ValidationResult.java index 9e57859..386bab9 100644 --- a/src/main/java/com/specconvert/validator/ValidationResult.java +++ b/src/main/java/org/openworkflow/migrationtool/validator/ValidationResult.java @@ -1,4 +1,4 @@ -package com.specconvert.validator; +package org.openworkflow.migrationtool.validator; /** * A single finding produced by {@link OutputValidator}. From 19724c394a2fd64b82ea03fdb0ff86e9144fe735 Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Thu, 24 Sep 2026 14:05:55 +0100 Subject: [PATCH 3/4] First set of changes from suggestions Signed-off-by: Sean Kavanagh --- {workflows => .github/workflows}/release.yml | 0 CONVERSION_NOTES.md | 4 ++-- README.md | 21 +++++++++++++++++-- build.sh | 13 +++++++++++- install.ps1 | 4 ++-- install.sh | 4 ++-- .../migrationtool/SpecConvert.java | 6 ++++-- .../migrationtool/report/ReportCollector.java | 2 +- .../migrationtool/transformer/Callback.java | 2 +- .../migrationtool/transformer/ForEach.java | 2 +- 10 files changed, 44 insertions(+), 14 deletions(-) rename {workflows => .github/workflows}/release.yml (100%) diff --git a/workflows/release.yml b/.github/workflows/release.yml similarity index 100% rename from workflows/release.yml rename to .github/workflows/release.yml diff --git a/CONVERSION_NOTES.md b/CONVERSION_NOTES.md index c9c9042..5d9777c 100644 --- a/CONVERSION_NOTES.md +++ b/CONVERSION_NOTES.md @@ -1,6 +1,6 @@ # SpecConvert — Conversion Logic Notes -CNCF Serverless Workflow **0.8 → 1.0** | `src/main/java/com/specconvert/SpecConvert.java` +CNCF Serverless Workflow **0.8 → 1.0** | `src/main/java/org/openworkflow/migrationtool/SpecConvert.java` --- @@ -470,7 +470,7 @@ Used by the `sleep` → `wait` conversion. The regex `P(?:(\d+)Y)?(?:(\d+)M)?(?: ## Output Validation -After the converted file is written, `OutputValidator` (`src/main/java/com/specconvert/validator/OutputValidator.java`) reads it back as a `JsonNode` and checks every translated element. Findings become `validation`-category issues in the migration report. +After the converted file is written, `OutputValidator` (`src/main/java/org/openworkflow/migrationtool/validator/OutputValidator.java`) reads it back as a `JsonNode` and checks every translated element. Findings become `validation`-category issues in the migration report. | Task type | Key checks | |-----------|---------------------------------------------------------------------------| diff --git a/README.md b/README.md index 556ae4b..52656f0 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ Download a pre-built binary from the [releases page](../../releases) and place i **macOS / Linux** ```bash -curl -fsSL https://raw.githubusercontent.com/skavgou/spec-convert/main/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/open-workflow-specification/migration-tool/main/install.sh | bash ``` **Windows (PowerShell)** ```powershell -irm https://raw.githubusercontent.com/skavgou/spec-convert/main/install.ps1 | iex +irm https://raw.githubusercontent.com/open-workflow-specification/migration-tool/main/install.ps1 | iex ``` --- @@ -69,6 +69,23 @@ swf-migrate samples/hello.json --strict true Requires Java 17+ and Maven 3.8+. +### Prerequisites: 0.8 SDK Installation +Because the 0.8 SDK shares package names with the 1.0 SDK, it is referenced under a local coordinate (`io.serverlessworkflow.v08:serverlessworkflow-api:4.1.0.Final`). Before building with Maven directly, install it into your local repository: + +```bash +mvn dependency:get -q -Dartifact=io.serverlessworkflow:serverlessworkflow-api:4.1.0.Final +mvn install:install-file -q \ + -Dfile="${HOME}/.m2/repository/io/serverlessworkflow/serverlessworkflow-api/4.1.0.Final/serverlessworkflow-api-4.1.0.Final.jar" \ + -DgroupId=io.serverlessworkflow.v08 \ + -DartifactId=serverlessworkflow-api \ + -Dversion=4.1.0.Final \ + -Dpackaging=jar +``` + +Alternatively, you can run `./build.sh` which executes this setup automatically. + +### Build Artifacts + **Fat jar (requires Java to run)** ```bash mvn package diff --git a/build.sh b/build.sh index 9f3a837..5ba68e4 100755 --- a/build.sh +++ b/build.sh @@ -1,9 +1,20 @@ +#!/usr/bin/env bash # Compiles SpecConvert and packages it into target/spec-convert.jar -# Requires Maven; dependencies are resolved from configured repositories. +# Requires Maven; installs the shaded 0.8 SDK dependency into the local Maven repository if not present. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Ensure the 0.8 SDK shaded coordinate is installed locally +echo "Ensuring 0.8 SDK dependency is installed..." +mvn dependency:get -q -Dartifact=io.serverlessworkflow:serverlessworkflow-api:4.1.0.Final +mvn install:install-file -q \ + -Dfile="${HOME}/.m2/repository/io/serverlessworkflow/serverlessworkflow-api/4.1.0.Final/serverlessworkflow-api-4.1.0.Final.jar" \ + -DgroupId=io.serverlessworkflow.v08 \ + -DartifactId=serverlessworkflow-api \ + -Dversion=4.1.0.Final \ + -Dpackaging=jar + echo "Building with Maven..." mvn -f "$SCRIPT_DIR/pom.xml" package -q diff --git a/install.ps1 b/install.ps1 index 7ed9763..f6f2b00 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,8 +1,8 @@ # Installs swf-migrate to $Env:USERPROFILE\bin and adds it to the user PATH. -# Usage: irm https://raw.githubusercontent.com/skavgou/spec-convert/main/install.ps1 | iex +# Usage: irm https://raw.githubusercontent.com/open-workflow-specification/migration-tool/main/install.ps1 | iex $ErrorActionPreference = 'Stop' -$Repo = "skavgou/spec-convert" +$Repo = "open-workflow-specification/migration-tool" $Asset = "swf-migrate-windows.exe" $BinDir = "$Env:USERPROFILE\bin" diff --git a/install.sh b/install.sh index 53efa00..04644b4 100644 --- a/install.sh +++ b/install.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # Installs swf-migrate to /usr/local/bin (or ~/bin if not writable). -# Usage: curl -fsSL https://raw.githubusercontent.com/skavgou/spec-convert/main/install.sh | bash +# Usage: curl -fsSL https://raw.githubusercontent.com/open-workflow-specification/migration-tool/main/install.sh | bash set -euo pipefail -REPO="skavgou/spec-convert" +REPO="open-workflow-specification/migration-tool" INSTALL_DIR="/usr/local/bin" # Detect platform diff --git a/src/main/java/org/openworkflow/migrationtool/SpecConvert.java b/src/main/java/org/openworkflow/migrationtool/SpecConvert.java index 97285bf..fcd68aa 100644 --- a/src/main/java/org/openworkflow/migrationtool/SpecConvert.java +++ b/src/main/java/org/openworkflow/migrationtool/SpecConvert.java @@ -118,10 +118,12 @@ public static void main(String[] args) throws IOException { throw new IllegalArgumentException("--report-format requires 'json' or 'markdown' as an argument."); } String val = args[++i]; - if ("json".equals(val) || "markdown".equals(val)) { + if ("json".equals(val)) { reportFormat = val; + } else if ("markdown".equals(val) || "md".equals(val)) { + reportFormat = "markdown"; } else { - throw new IllegalArgumentException("--report-format requires 'json' or 'markdown', got: '" + val + "'."); + throw new IllegalArgumentException("--report-format requires 'json', 'md', or 'markdown', got: '" + val + "'."); } } else if ("--strict".equals(args[i])) { if (i + 1 >= args.length) { diff --git a/src/main/java/org/openworkflow/migrationtool/report/ReportCollector.java b/src/main/java/org/openworkflow/migrationtool/report/ReportCollector.java index 6ccac32..cfc68b2 100644 --- a/src/main/java/org/openworkflow/migrationtool/report/ReportCollector.java +++ b/src/main/java/org/openworkflow/migrationtool/report/ReportCollector.java @@ -89,7 +89,7 @@ public boolean finalise(int totalStates, int migratedStates, boolean strict) { report.summary.overallStatus = "success"; } - return strict && warnings > 0; + return errors > 0 || (strict && warnings > 0); } public MigrationReport getReport() { diff --git a/src/main/java/org/openworkflow/migrationtool/transformer/Callback.java b/src/main/java/org/openworkflow/migrationtool/transformer/Callback.java index e69659f..9602ad4 100644 --- a/src/main/java/org/openworkflow/migrationtool/transformer/Callback.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Callback.java @@ -91,7 +91,7 @@ protected static TaskItem handleCallbackFunction( // Named case: when the expected callback event type is confirmed, go to the transition target SwitchCase callbackCase = new SwitchCase() - .withWhen("${ .type == \"" + cloudEventType + "\" }") + .withWhen(".type == \"" + cloudEventType + "\"") .withThen(new FlowDirective().withString(transitionTarget)); // Default case: fallback — end the workflow segment (should not be reached in normal flow) diff --git a/src/main/java/org/openworkflow/migrationtool/transformer/ForEach.java b/src/main/java/org/openworkflow/migrationtool/transformer/ForEach.java index 1c20886..6d9b47c 100644 --- a/src/main/java/org/openworkflow/migrationtool/transformer/ForEach.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/ForEach.java @@ -32,7 +32,7 @@ public static TaskItem handleForEach(String name, ForEachState state) { } protected static TaskItem handleForEachFunction(String name, ForEachState state) { - String in = state.getInputCollection() != null ? state.getInputCollection() : "${ .[] }"; + String in = state.getInputCollection() != null ? util.stripExpressionWrapper(state.getInputCollection()) : ".[]"; String each = state.getIterationParam() != null ? state.getIterationParam() : "item"; System.err.println("[INFO] Converting forEach state '" + name From da4055bd88b0be828cc24932b1af30ff5ef71c76 Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Fri, 25 Sep 2026 15:42:37 +0100 Subject: [PATCH 4/4] Preserve transition routing when converting do states Signed-off-by: Sean Kavanagh --- .../migrationtool/SpecConvert.java | 2 +- .../migrationtool/transformer/Event.java | 120 +++++++++++++----- .../migrationtool/transformer/ForEach.java | 6 + .../migrationtool/transformer/Inject.java | 5 + .../migrationtool/transformer/Operation.java | 12 +- .../migrationtool/transformer/Parallel.java | 5 + .../migrationtool/transformer/Sleep.java | 32 ++++- .../migrationtool/transformer/util.java | 36 ++++++ 8 files changed, 181 insertions(+), 37 deletions(-) diff --git a/src/main/java/org/openworkflow/migrationtool/SpecConvert.java b/src/main/java/org/openworkflow/migrationtool/SpecConvert.java index fcd68aa..d81657c 100644 --- a/src/main/java/org/openworkflow/migrationtool/SpecConvert.java +++ b/src/main/java/org/openworkflow/migrationtool/SpecConvert.java @@ -341,7 +341,7 @@ private static List buildDo(io.serverlessworkflow.api.Workflow src) { items.add(Inject.handleInject(stateName, (InjectState) state)); } else if (state instanceof SleepState) { - items.add(new TaskItem(stateName, new Task().withWaitTask(Sleep.handleWait((SleepState) state)))); + items.add(Sleep.handleSleep(stateName, (SleepState) state)); } else if (state instanceof SwitchState) { items.add(Switch.handleSwitch(stateName, (SwitchState) state, eventTypeByName)); diff --git a/src/main/java/org/openworkflow/migrationtool/transformer/Event.java b/src/main/java/org/openworkflow/migrationtool/transformer/Event.java index d1a7220..81bdc68 100644 --- a/src/main/java/org/openworkflow/migrationtool/transformer/Event.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Event.java @@ -1,9 +1,8 @@ package org.openworkflow.migrationtool.transformer; import java.util.ArrayList; -import java.util.Map; - import java.util.List; +import java.util.Map; // 0.8 import io.serverlessworkflow.api.actions.Action; @@ -13,8 +12,10 @@ // 1.0 import io.serverlessworkflow.api.types.AllEventConsumptionStrategy; import io.serverlessworkflow.api.types.AnyEventConsumptionStrategy; +import io.serverlessworkflow.api.types.DoTask; import io.serverlessworkflow.api.types.EventFilter; import io.serverlessworkflow.api.types.EventProperties; +import io.serverlessworkflow.api.types.FlowDirective; import io.serverlessworkflow.api.types.ListenTask; import io.serverlessworkflow.api.types.ListenTaskConfiguration; import io.serverlessworkflow.api.types.ListenTo; @@ -34,21 +35,33 @@ public class Event { * The CloudEvent type is resolved from the workflow's top-level event definitions; * if no definition is found the eventRef name itself is used as the type. * - * Actions mapping via foreach: - * 1.0 ListenTask carries a `foreach` (SubscriptionIterator) whose `do` list - * executes for every consumed event. The iterator variable "item" holds - * the received CloudEvent, so actions can inspect it. + * Per-event action association: + * + * exclusive=true: + * Only one event arrives. The foreach body must run only the actions associated + * with the event that actually arrived. Each onEvents entry becomes a separate + * DoTask in the foreach.do list, guarded by an `if` expression that tests whether + * the received event's `.type` matches any of the eventRefs in that entry. + * Multiple eventRefs in one entry are OR-ed: + * .item.type == "typeA" or .item.type == "typeB" + * Tasks whose `if` is false are skipped at runtime, so only the matching group + * executes — preserving the 0.8 per-event action association. * - * All onEvents actions are flattened into a single do list. If different onEvents - * entries have different actions, each distinct action list is appended in order. - * If no onEvents entries have any actions, foreach is omitted entirely. + * exclusive=false: + * All events must arrive before actions run. Because the runtime delivers all + * events together, all actions from all onEvents entries should run. Actions are + * still grouped per onEvents entry (each entry becomes its own DoTask), which is + * correct and preserves the 0.8 grouping; no if-guard is needed. + * + * If an onEvents entry has no actions, no DoTask is emitted for it. + * If no onEvents entry has any actions, foreach is omitted entirely. */ public static TaskItem handleEvent( String name, EventState state, Map eventTypeByName) { - return handleEventFunction(name, state, eventTypeByName); - } + return handleEventFunction(name, state, eventTypeByName); + } protected static TaskItem handleEventFunction( String name, @@ -56,29 +69,54 @@ protected static TaskItem handleEventFunction( Map eventTypeByName) { List filters = new ArrayList<>(); - - // Collect all actions across onEvents entries for the foreach do list - List allActions = new ArrayList<>(); + // Each onEvents entry → one DoTask in the foreach body (if it has actions) + List foreachGroups = new ArrayList<>(); + boolean exclusive = state.isExclusive(); if (state.getOnEvents() != null) { for (OnEvents onEvent : state.getOnEvents()) { + List eventRefs = onEvent.getEventRefs() != null + ? onEvent.getEventRefs() : java.util.Collections.emptyList(); List actions = onEvent.getActions() != null ? onEvent.getActions() : java.util.Collections.emptyList(); - if (onEvent.getEventRefs() != null) { - for (String eventRef : onEvent.getEventRefs()) { - String cloudEventType = eventTypeByName.getOrDefault(eventRef, eventRef); - EventProperties props = new EventProperties().withType(cloudEventType); - filters.add(new EventFilter().withWith(props)); - } + // Collect listen filters — one filter per eventRef + List cloudEventTypes = new ArrayList<>(); + for (String eventRef : eventRefs) { + String cloudEventType = eventTypeByName.getOrDefault(eventRef, eventRef); + cloudEventTypes.add(cloudEventType); + filters.add(new EventFilter().withWith(new EventProperties().withType(cloudEventType))); + } + + if (actions.isEmpty()) { + continue; // no actions for this entry; skip DoTask generation } - allActions.addAll(actions); + + // Build the action task items for this onEvents entry + List actionItems = new ArrayList<>(); + for (Action action : actions) { + actionItems.add(util.convertAction(action)); + } + + // Determine the key name for this group from the first eventRef (or a fallback) + String groupName = eventRefs.isEmpty() ? "onEvent" : util.toIdentifier(eventRefs.get(0)); + + DoTask groupTask = new DoTask().withDo(actionItems); + + if (exclusive && !cloudEventTypes.isEmpty()) { + // Guard: only execute this group when the received event matches one of + // the eventRefs in this onEvents entry. + groupTask.withIf(buildTypeGuard(cloudEventTypes)); + } + // exclusive=false: no guard needed — all events have arrived, all actions run + + foreachGroups.add(new TaskItem(groupName, new Task().withDoTask(groupTask))); } } - // exclusive=true → any (first matching event wins); exclusive=false → all (must all arrive) + // Build the listen directive ListenTo listenTo; - if (state.isExclusive()) { + if (exclusive) { listenTo = new ListenTo() .withAnyEventConsumptionStrategy(new AnyEventConsumptionStrategy().withAny(filters)); } else { @@ -89,18 +127,38 @@ protected static TaskItem handleEventFunction( ListenTask listenTask = new ListenTask() .withListen(new ListenTaskConfiguration().withTo(listenTo)); - // Build the foreach iterator only when at least one onEvents entry has actions - if (!allActions.isEmpty()) { - List foreachDo = new ArrayList<>(); - for (Action action : allActions) { - foreachDo.add(util.convertAction(action)); - } - // item = the variable name that holds each received CloudEvent inside foreach.do + // Attach foreach only when there are action groups to dispatch + if (!foreachGroups.isEmpty()) { listenTask.withForeach(new SubscriptionIterator() .withItem("item") - .withDo(foreachDo)); + .withDo(foreachGroups)); + } + + FlowDirective then = util.resolveThen(name, state); + if (then != null) { + listenTask.withThen(then); } return new TaskItem(name, new Task().withListenTask(listenTask)); } + + /** + * Build a jq boolean expression that is true when the received CloudEvent's type + * matches any of the given type strings. + * + * Single type: .item.type == "com.example.typeA" + * Multiple: (.item.type == "com.example.typeA" or .item.type == "com.example.typeB") + */ + private static String buildTypeGuard(List cloudEventTypes) { + if (cloudEventTypes.size() == 1) { + return ".item.type == \"" + cloudEventTypes.get(0) + "\""; + } + StringBuilder sb = new StringBuilder("("); + for (int i = 0; i < cloudEventTypes.size(); i++) { + if (i > 0) sb.append(" or "); + sb.append(".item.type == \"").append(cloudEventTypes.get(i)).append("\""); + } + sb.append(")"); + return sb.toString(); + } } diff --git a/src/main/java/org/openworkflow/migrationtool/transformer/ForEach.java b/src/main/java/org/openworkflow/migrationtool/transformer/ForEach.java index 6d9b47c..71484b0 100644 --- a/src/main/java/org/openworkflow/migrationtool/transformer/ForEach.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/ForEach.java @@ -11,6 +11,7 @@ import io.serverlessworkflow.api.states.ForEachState; // 1.0 +import io.serverlessworkflow.api.types.FlowDirective; import io.serverlessworkflow.api.types.ForTask; import io.serverlessworkflow.api.types.ForTaskConfiguration; import io.serverlessworkflow.api.types.Task; @@ -75,6 +76,11 @@ protected static TaskItem handleForEachFunction(String name, ForEachState state) .withFor(forCfg) .withDo(doItems); + FlowDirective then = util.resolveThen(name, state); + if (then != null) { + forTask.withThen(then); + } + return new TaskItem(name, new Task().withForTask(forTask)); } } diff --git a/src/main/java/org/openworkflow/migrationtool/transformer/Inject.java b/src/main/java/org/openworkflow/migrationtool/transformer/Inject.java index 7606533..8d64295 100644 --- a/src/main/java/org/openworkflow/migrationtool/transformer/Inject.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Inject.java @@ -1,6 +1,7 @@ package org.openworkflow.migrationtool.transformer; import io.serverlessworkflow.api.states.InjectState; +import io.serverlessworkflow.api.types.FlowDirective; import io.serverlessworkflow.api.types.Set; import io.serverlessworkflow.api.types.SetTask; import io.serverlessworkflow.api.types.SetTaskConfiguration; @@ -21,7 +22,11 @@ protected static TaskItem handleInjectFunction(String name, InjectState state) { ); } + FlowDirective then = util.resolveThen(name, state); SetTask setTask = new SetTask().withSet(new Set().withSetTaskConfiguration(cfg)); + if (then != null) { + setTask.withThen(then); + } return new TaskItem(name, new Task().withSetTask(setTask)); } } diff --git a/src/main/java/org/openworkflow/migrationtool/transformer/Operation.java b/src/main/java/org/openworkflow/migrationtool/transformer/Operation.java index e4c7ced..751d9f1 100644 --- a/src/main/java/org/openworkflow/migrationtool/transformer/Operation.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Operation.java @@ -9,6 +9,7 @@ import io.serverlessworkflow.api.states.OperationState; // 1.0 +import io.serverlessworkflow.api.types.FlowDirective; import io.serverlessworkflow.api.types.ForkTask; import io.serverlessworkflow.api.types.ForkTaskConfiguration; import io.serverlessworkflow.api.types.Task; @@ -38,6 +39,8 @@ protected static TaskItem handleOperationFunction(String name, OperationState st System.err.println("[INFO] Converting operation state '" + name + "' (actionMode=" + (parallel ? "parallel" : "sequential") + ", actions=" + actions.size() + ")"); + FlowDirective then = util.resolveThen(name, state); + if (parallel) { // parallel → fork task; each action becomes its own branch List branchItems = new ArrayList<>(); @@ -52,7 +55,11 @@ protected static TaskItem handleOperationFunction(String name, OperationState st ForkTaskConfiguration forkCfg = new ForkTaskConfiguration() .withCompete(false) .withBranches(branchItems); - return new TaskItem(name, new Task().withForkTask(new ForkTask().withFork(forkCfg))); + ForkTask forkTask = new ForkTask().withFork(forkCfg); + if (then != null) { + forkTask.withThen(then); + } + return new TaskItem(name, new Task().withForkTask(forkTask)); } // sequential → do task containing each action as a call task in order @@ -62,6 +69,9 @@ protected static TaskItem handleOperationFunction(String name, OperationState st } io.serverlessworkflow.api.types.DoTask doTask = new io.serverlessworkflow.api.types.DoTask().withDo(actionItems); + if (then != null) { + doTask.withThen(then); + } return new TaskItem(name, new Task().withDoTask(doTask)); } } diff --git a/src/main/java/org/openworkflow/migrationtool/transformer/Parallel.java b/src/main/java/org/openworkflow/migrationtool/transformer/Parallel.java index 22ce469..fb0a7e9 100644 --- a/src/main/java/org/openworkflow/migrationtool/transformer/Parallel.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Parallel.java @@ -9,6 +9,7 @@ import io.serverlessworkflow.api.states.ParallelState; // 1.0 +import io.serverlessworkflow.api.types.FlowDirective; import io.serverlessworkflow.api.types.ForkTask; import io.serverlessworkflow.api.types.ForkTaskConfiguration; import io.serverlessworkflow.api.types.Task; @@ -56,6 +57,10 @@ protected static TaskItem handleParallelFunction(String name, ParallelState stat .withBranches(branchItems); ForkTask forkTask = new ForkTask().withFork(forkCfg); + FlowDirective then = util.resolveThen(name, state); + if (then != null) { + forkTask.withThen(then); + } return new TaskItem(name, new Task().withForkTask(forkTask)); } } diff --git a/src/main/java/org/openworkflow/migrationtool/transformer/Sleep.java b/src/main/java/org/openworkflow/migrationtool/transformer/Sleep.java index be9a916..9b3f286 100644 --- a/src/main/java/org/openworkflow/migrationtool/transformer/Sleep.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/Sleep.java @@ -8,24 +8,48 @@ // 1.0 import io.serverlessworkflow.api.types.DurationInline; +import io.serverlessworkflow.api.types.FlowDirective; +import io.serverlessworkflow.api.types.Task; +import io.serverlessworkflow.api.types.TaskItem; import io.serverlessworkflow.api.types.TimeoutAfter; import io.serverlessworkflow.api.types.WaitTask; public class Sleep { /** - * Parse an ISO 8601 duration string (e.g. "P2DT3H4M") into a 1.0 wait block, - * preserving each component as a discrete field on DurationInline: + * Convert a 0.8 sleep state to a 1.0 TaskItem containing a wait task. + * + * The ISO 8601 duration string (e.g. "P2DT3H4M") is parsed into a DurationInline: * { "wait": { "days": 2, "hours": 3, "minutes": 4 } } * * Years and months are folded into days (approximate: 1y=365d, 1mo=30d) * because DurationInline has no year/month fields. + * + * The 0.8 transition/end fields are carried over as a 1.0 then directive. */ + public static TaskItem handleSleep(String name, SleepState state) { + return handleSleepFunction(name, state); + } + + /** + * @deprecated Use {@link #handleSleep(String, SleepState)} instead. + * Retained for any existing callers that consume only the WaitTask. + */ + @Deprecated public static WaitTask handleWait(SleepState state) { - return handleWaitFunction(state); + return buildWaitTask(state); + } + + private static TaskItem handleSleepFunction(String name, SleepState state) { + WaitTask waitTask = buildWaitTask(state); + FlowDirective then = util.resolveThen(name, state); + if (then != null) { + waitTask.withThen(then); + } + return new TaskItem(name, new Task().withWaitTask(waitTask)); } - private static WaitTask handleWaitFunction(SleepState state) { + private static WaitTask buildWaitTask(SleepState state) { String iso8601Duration = state.getDuration(); if (iso8601Duration == null) iso8601Duration = "PT0S"; diff --git a/src/main/java/org/openworkflow/migrationtool/transformer/util.java b/src/main/java/org/openworkflow/migrationtool/transformer/util.java index 09c276c..2361d77 100644 --- a/src/main/java/org/openworkflow/migrationtool/transformer/util.java +++ b/src/main/java/org/openworkflow/migrationtool/transformer/util.java @@ -12,11 +12,14 @@ // 0.8 import io.serverlessworkflow.api.actions.Action; import io.serverlessworkflow.api.events.EventDefinition; +import io.serverlessworkflow.api.states.DefaultState; import io.serverlessworkflow.api.transitions.Transition; // 1.0 import io.serverlessworkflow.api.types.CallFunction; import io.serverlessworkflow.api.types.CallTask; +import io.serverlessworkflow.api.types.FlowDirective; +import io.serverlessworkflow.api.types.FlowDirectiveEnum; import io.serverlessworkflow.api.types.FunctionArguments; import io.serverlessworkflow.api.types.Set; import io.serverlessworkflow.api.types.SetTask; @@ -75,6 +78,39 @@ protected static String transitionName(Transition t) { return t.getNextState(); } + /** + * Derive a 1.0 FlowDirective from the transition/end fields of a 0.8 state. + * + * Rules: + * - transition present → FlowDirective pointing to the next state name + * - end present → FlowDirectiveEnum.END + * - neither → null (no then emitted; runtime falls through to the next task) + * + * When neither field is set and the state is not the terminal node (rare but valid in 0.8), + * the generated 1.0 workflow will fall through to the next task in the do list, which matches + * the default 0.8 sequential behaviour only if the state is listed in order. A warning is + * emitted so the user can verify the output. + */ + public static FlowDirective resolveThen(String stateName, DefaultState state) { + if (state.getTransition() != null && state.getTransition().getNextState() != null) { + return new FlowDirective().withString(state.getTransition().getNextState()); + } + if (state.getEnd() != null) { + return new FlowDirective().withFlowDirectiveEnum(FlowDirectiveEnum.END); + } + // Neither transition nor end — emit a warning; fall-through is implicit in 1.0 + System.err.println("[WARN] State '" + stateName + + "' has no transition or end; no 'then' directive will be set. " + + "Verify that sequential fall-through in the 1.0 do list is correct."); + ReportCollector.get().addIssue(Severity.WARNING, Category.state_transformation, + "states[" + stateName + "].transition", + "State has no transition or end; no 'then' directive was emitted. " + + "Verify that sequential fall-through in the 1.0 do list is correct.", + null, null, + "Set the correct next state or end condition if fall-through is not intended."); + return null; + } + /** * Convert a human-readable condition name like "Applicant is adult" * into a valid camelCase identifier like "applicantIsAdult" for use as a YAML key.