Repository: ChristopheBougere/asl-validator (ts)

Issue:
Validator accepting state machine definition with no terminal state
**Input-**
````Json
{
    "Comment": "A simple minimal example of the States language",
    "StartAt": "Hello World",
    "States": {
        "Hello World": {
            "Type": "Task",
            "Resource": "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld",
            "Next": "Hello World2"
        },
        "Hello World2": {
            "Type": "Task",
            "Resource": "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld",
            "Next": "Hello World3"
        },
        "Hello World3": {
            "Type": "Task",
            "Resource": "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld",
            "Next": "Hello World"
        }
    }
}
````
**Output-**
`✓ State machine definition is valid`

Pull request:
add rule to check for missing terminal
fixes #59 

Patch:
diff --git a/src/lib/terminals.js b/src/lib/terminals.js
new file mode 100644
index 0000000..ab12784
--- /dev/null
+++ b/src/lib/terminals.js
@@ -0,0 +1,38 @@
+const jp = require('jsonpath');
+
+module.exports = (definition) => {
+  const errorMessages = [];
+  // find each enclosed state machine and verify that
+  // each state machine contains at least one of the following:
+  // - state with End: true
+  // - state with Type: Succeed
+  // - state with Type: Fail
+  const terminals = new Map();
+  let fsmId = 1;
+  jp.query(definition, '$..[\'States\']')
+    .forEach((states) => {
+      const fsmKey = `fsm${fsmId}`;
+      // initialize the nested fsm to have a terminal count of zero
+      terminals.set(fsmKey, 0);
+      // check each of its states to see if the fsm terminates
+      Object.keys(states).forEach((stateName) => {
+        const state = states[stateName];
+        const count = terminals.get(fsmKey);
+        if (['Succeed', 'Fail'].indexOf(state.Type) !== -1 || state.End) {
+          terminals.set(fsmKey, count ? count + 1 : 1);
+        }
+      });
+      fsmId += 1;
+    });
+  terminals.forEach((value, key) => {
+    if (value === 0) {
+      errorMessages.push({
+        'Error code': 'MISSING_TERMINAL_STATE',
+        // better to have a line number here
+        Message: `State machine ${key} is missing a terminal state`,
+      });
+    }
+  });
+
+  return errorMessages;
+};
diff --git a/src/validator.js b/src/validator.js
index d060f87..ea2ae84 100644
--- a/src/validator.js
+++ b/src/validator.js
@@ -15,6 +15,7 @@ const checkJsonPath = require('./lib/json-path-errors');
 const missingTransitionTarget = require('./lib/missing-transition-target');
 const stateTransitions = require('./lib/state-transitions');
 const stateNames = require('./lib/state-names');
+const terminals = require('./lib/terminals');
 
 function formatError(e) {
   const code = e.Code ? e.Code : e['Error code'];
@@ -47,6 +48,7 @@ function validator(definition) {
     postSchemaValidationErrors.push(...missingTransitionTarget(definition));
     postSchemaValidationErrors.push(...stateTransitions(definition));
     postSchemaValidationErrors.push(...stateNames(definition));
+    postSchemaValidationErrors.push(...terminals(definition));
   }
 
   return {


Test patch:
diff --git a/src/__tests__/definitions/invalid-missing-terminal-map.json b/src/__tests__/definitions/invalid-missing-terminal-map.json
new file mode 100644
index 0000000..ccf313f
--- /dev/null
+++ b/src/__tests__/definitions/invalid-missing-terminal-map.json
@@ -0,0 +1,27 @@
+{
+  "Comment": "The state machine within the Map is missing a terminal state",
+  "StartAt": "Map",
+  "States": {
+    "Map": {
+      "Type": "Map",
+      "Next": "Final State",
+      "InputPath": "$.input",
+      "ItemsPath": "$.items",
+      "MaxConcurrency": 0,
+      "Iterator": {
+        "StartAt": "Wait 20s",
+        "States": {
+          "Wait 20s": {
+            "Type": "Wait",
+            "Seconds": 20
+          }
+        }
+      },
+      "ResultPath": "$.result"
+    },
+    "Final State": {
+      "Type": "Pass",
+      "End": true
+    }
+  }
+}
diff --git a/src/__tests__/definitions/invalid-missing-terminal-parallel.json b/src/__tests__/definitions/invalid-missing-terminal-parallel.json
new file mode 100644
index 0000000..650c143
--- /dev/null
+++ b/src/__tests__/definitions/invalid-missing-terminal-parallel.json
@@ -0,0 +1,38 @@
+{
+  "Comment": "The first branch in the parallel state is missing a terminal state",
+  "StartAt": "Parallel",
+  "States": {
+    "Parallel": {
+      "Type": "Parallel",
+      "Next": "Final State",
+      "Branches": [{
+          "StartAt": "Wait 20s",
+          "States": {
+            "Wait 20s": {
+              "Type": "Wait",
+              "Seconds": 20
+            }
+          }
+        },
+        {
+          "StartAt": "Pass",
+          "States": {
+            "Pass": {
+              "Type": "Pass",
+              "Next": "Wait 10s"
+            },
+            "Wait 10s": {
+              "Type": "Wait",
+              "Seconds": 10,
+              "End": true
+            }
+          }
+        }
+      ]
+    },
+    "Final State": {
+      "Type": "Pass",
+      "End": true
+    }
+  }
+}
diff --git a/src/__tests__/definitions/invalid-missing-terminal.json b/src/__tests__/definitions/invalid-missing-terminal.json
new file mode 100644
index 0000000..2ae5d66
--- /dev/null
+++ b/src/__tests__/definitions/invalid-missing-terminal.json
@@ -0,0 +1,21 @@
+{
+  "Comment": "The state machine is missing a terminal state",
+  "StartAt": "Hello World",
+  "States": {
+    "Hello World": {
+      "Type": "Task",
+      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld",
+      "Next": "Hello World2"
+    },
+    "Hello World2": {
+      "Type": "Task",
+      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld",
+      "Next": "Hello World3"
+    },
+    "Hello World3": {
+      "Type": "Task",
+      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld",
+      "Next": "Hello World"
+    }
+  }
+}
\ No newline at end of file