Repository: ChristopheBougere/asl-validator (ts)

Issue:
add configuration to the validation to enable or disable named validations
**Is your feature request related to a problem? Please describe.**

There isn't a way to enforce a `strict` mode where the process definition is valid according to the ASL definition and doesn't contain any extension elements like a cloud flare deployment function call. 

It would also be nice to disable one or more validations. The primary example of this is disabling the path expression checks. There's value in knowing the basic flow of the step function is valid without having to implement every expression. It's also possible that the expressions could be checked elsewhere in the build by another tool and the check in this lib is therefore redundant.

**Describe the solution you'd like**

Provide a config to enable/disable some of the checkers. 



Pull request:
issue97/config
Added support for two new flags:

```
  .option('--no-path-check', 'skips checking path expressions')
  .option('--no-arn-check', 'skips the arn check for Resource values')
```

closes #97 

Patch:
diff --git a/bin/asl-validator.ts b/bin/asl-validator.ts
index a596c22..563ba9e 100755
--- a/bin/asl-validator.ts
+++ b/bin/asl-validator.ts
@@ -5,13 +5,15 @@ import fs from 'fs';
 import program from 'commander';
 
 import validator from '../src/validator';
-import { StateMachine } from '../src/types';
+import {StateMachine, ValidationOptions} from '../src/types';
 
 program
   .description('Amazon States Language validator')
   .option('--json-definition <jsonDefinition>', 'JSON definition')
   .option('--json-path <jsonPath>', 'JSON path')
   .option('--silent', 'silent mode')
+  .option('--no-path-check', 'skips checking path expressions')
+  .option('--no-arn-check', 'skips the arn check for Resource values')
   .parse(process.argv);
 
 let definition;
@@ -31,7 +33,11 @@ try {
   process.exit(2);
 }
 try {
-  const result = validator(definition);
+  const validationOpts: ValidationOptions = {
+    checkArn: !program.noArnCheck,
+    checkPaths: !program.noPathCheck
+  }
+  const result = validator(definition, validationOpts);
   if (result.isValid) {
     if (!program.silent) {
       console.log('✓ State machine definition is valid');
@@ -48,4 +54,4 @@ try {
     console.error('Validator exception:', e);
   }
   process.exit(2);
-}
+}
\ No newline at end of file
diff --git a/src/checks/duplicate-payload-template-fields.ts b/src/checks/duplicate-payload-template-fields.ts
index cf58d19..4397ffb 100644
--- a/src/checks/duplicate-payload-template-fields.ts
+++ b/src/checks/duplicate-payload-template-fields.ts
@@ -1,10 +1,10 @@
 import {JSONPath} from 'jsonpath-plus';
-import {StateMachine, StateMachineError, StateMachineErrorCode} from '../types';
+import {AslChecker, StateMachineError, StateMachineErrorCode} from '../types';
 
 // From the ASL spec:
 // > A JSON object MUST NOT have duplicate field names after fields
 // > ending with the characters ".$" are renamed to strip the ".$" suffix.
-export const mustNotHaveDuplicateFieldNamesAfterEvaluation = (definition: StateMachine): StateMachineError[] => {
+export const mustNotHaveDuplicateFieldNamesAfterEvaluation: AslChecker = (definition) => {
     // find each `Parameters` or `ResultSelector` field
     // for each one, examine each node's children for field names
     // that will be in conflict after evaluation.
@@ -38,4 +38,4 @@ export const mustNotHaveDuplicateFieldNamesAfterEvaluation = (definition: StateM
             });
         });
     return errorMessages;
-}
+}
\ No newline at end of file
diff --git a/src/checks/duplicate-state-names-errors.ts b/src/checks/duplicate-state-names-errors.ts
index 09e6408..0fdea14 100644
--- a/src/checks/duplicate-state-names-errors.ts
+++ b/src/checks/duplicate-state-names-errors.ts
@@ -1,24 +1,24 @@
-import { JSONPath } from 'jsonpath-plus';
-import { StateMachine, StateMachineError, StateMachineErrorCode, States } from '../types';
+import {JSONPath} from 'jsonpath-plus';
+import {AslChecker, StateMachineError, StateMachineErrorCode, States} from '../types';
 
-export default function duplicateStateNames(definition: StateMachine): StateMachineError[] {
-  const errorMessages: StateMachineError[] = [];
-  const names: Record<string, number> = {};
-  JSONPath<States[]>({ json: definition, path: '$..[\'States\']' })
-    .forEach((states) => {
-      Object.keys(states).forEach((stateName) => {
-        const current = names[stateName];
-        names[stateName] = current ? current + 1 : 1;
-      });
-    });
-  for (const [key, value] of Object.entries(names)) {
-    if (value > 1) {
-      errorMessages.push({
-        'Error code': StateMachineErrorCode.DuplicateStateNames,
-        Message: `A state with this name already exists: ${key}`,
-      });
+export const duplicateStateNames: AslChecker = (definition) => {
+    const errorMessages: StateMachineError[] = [];
+    const names: Record<string, number> = {};
+    JSONPath<States[]>({json: definition, path: '$..[\'States\']'})
+        .forEach((states) => {
+            Object.keys(states).forEach((stateName) => {
+                const current = names[stateName];
+                names[stateName] = current ? current + 1 : 1;
+            });
+        });
+    for (const [key, value] of Object.entries(names)) {
+        if (value > 1) {
+            errorMessages.push({
+                'Error code': StateMachineErrorCode.DuplicateStateNames,
+                Message: `A state with this name already exists: ${key}`,
+            });
+        }
     }
-  }
 
-  return errorMessages;
-}
+    return errorMessages;
+}
\ No newline at end of file
diff --git a/src/checks/json-schema-errors.ts b/src/checks/json-schema-errors.ts
index 3760fb4..333a03d 100644
--- a/src/checks/json-schema-errors.ts
+++ b/src/checks/json-schema-errors.ts
@@ -11,11 +11,11 @@ import task from '../schemas/task.json';
 import wait from '../schemas/wait.json';
 import map from '../schemas/map.json';
 import errors from '../schemas/errors.json';
-import {StateMachine, StateMachineError, StateMachineErrorCode} from '../types';
+import {AslChecker, StateMachineErrorCode} from '../types';
 import {registerAll} from "asl-path-validator";
 import {isArnFormatValid} from "./formats";
 
-export default function jsonSchemaErrors(definition: StateMachine): StateMachineError[] {
+export const jsonSchemaErrors: AslChecker = (definition, options) => {
     const ajv = new Ajv({
         schemas: [
             paths,
@@ -33,8 +33,18 @@ export default function jsonSchemaErrors(definition: StateMachine): StateMachine
         ],
         allowUnionTypes: true
     });
-    registerAll(ajv);
-    ajv.addFormat("asl_arn", isArnFormatValid)
+    if (options.checkPaths) {
+        registerAll(ajv);
+    } else {
+        ajv.addFormat("asl_path", () => true);
+        ajv.addFormat("asl_ref_path", () => true);
+        ajv.addFormat("asl_payload_template", () => true);
+    }
+    if (options.checkArn) {
+        ajv.addFormat("asl_arn", isArnFormatValid)
+    } else {
+        ajv.addFormat("asl_arn", () => true);
+    }
     ajv.validate('http://asl-validator.cloud/state-machine.json#', definition);
 
     // the use of oneOf can generate a lot of errors since it'll test
@@ -44,8 +54,8 @@ export default function jsonSchemaErrors(definition: StateMachine): StateMachine
     // these for us in their errors. The challenge is which to show when there
     // are multiple.
     //
-    // for example, given the simplest example with a one step definition with only
-    // a single Pass state. Introducing a typo for the OutputPath expression generates
+    // for example, given the simplest example with a one-step definition with only
+    // a Pass state. Introducing a typo for the OutputPath expression generates
     // 9 errors from AJV.
     //
     if (!ajv.errors) {
@@ -67,7 +77,7 @@ export default function jsonSchemaErrors(definition: StateMachine): StateMachine
         deepest = Math.max(deepest, depth);
     });
 
-    // if there is a oneOf keyword error, then remove all of the
+    // if there is a oneOf keyword error, then remove the
     // other non-format related errors before proceeding
     const instancePathsWithOneOfKeyword = new Set<string>();
     selectedErrors
@@ -86,4 +96,4 @@ export default function jsonSchemaErrors(definition: StateMachine): StateMachine
                 schemaPath: error.schemaPath
             }
         }));
-}
+}
\ No newline at end of file
diff --git a/src/checks/missing-terminal-state-errors.ts b/src/checks/missing-terminal-state-errors.ts
index 7568359..858ddf4 100644
--- a/src/checks/missing-terminal-state-errors.ts
+++ b/src/checks/missing-terminal-state-errors.ts
@@ -1,39 +1,39 @@
-import { JSONPath } from 'jsonpath-plus';
-import { StateMachine, StateMachineError, StateMachineErrorCode, States } from '../types';
+import {JSONPath} from 'jsonpath-plus';
+import {AslChecker, StateMachineError, StateMachineErrorCode, States} from '../types';
 
-export default function missingTerminalStateErrors(definition: StateMachine): StateMachineError[] {
-  const errorMessages: StateMachineError[] = [];
-  // 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: Record<string, number> = {};
-  let fsmId = 1;
-  JSONPath<States[]>({ json: definition, path: '$..[\'States\']' })
-    .forEach((states) => {
-      const fsmKey = `fsm${fsmId}`;
-      // initialize the nested fsm to have a terminal count of zero
-      terminals[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[fsmKey];
-        if (['Succeed', 'Fail'].indexOf(state.Type as string) !== -1 || state.End) {
-          terminals[fsmKey] = count ? count + 1 : 1;
+export const missingTerminalStateErrors: AslChecker = (definition) => {
+    const errorMessages: StateMachineError[] = [];
+    // 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: Record<string, number> = {};
+    let fsmId = 1;
+    JSONPath<States[]>({json: definition, path: '$..[\'States\']'})
+        .forEach((states) => {
+            const fsmKey = `fsm${fsmId}`;
+            // initialize the nested fsm to have a terminal count of zero
+            terminals[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[fsmKey];
+                if (['Succeed', 'Fail'].indexOf(state.Type as string) !== -1 || state.End) {
+                    terminals[fsmKey] = count ? count + 1 : 1;
+                }
+            });
+            fsmId += 1;
+        });
+    for (const [key, value] of Object.entries(terminals)) {
+        if (value === 0) {
+            errorMessages.push({
+                'Error code': StateMachineErrorCode.MissingTerminalState,
+                // better to have a line number here
+                Message: `State machine ${key} is missing a terminal state`,
+            });
         }
-      });
-      fsmId += 1;
-    });
-  for (const [key, value] of Object.entries(terminals)) {
-    if (value === 0) {
-      errorMessages.push({
-        'Error code': StateMachineErrorCode.MissingTerminalState,
-        // better to have a line number here
-        Message: `State machine ${key} is missing a terminal state`,
-      });
     }
-  }
 
-  return errorMessages;
-}
+    return errorMessages;
+}
\ No newline at end of file
diff --git a/src/checks/missing-transition-target-errors.ts b/src/checks/missing-transition-target-errors.ts
index 7b72a2b..8f2692d 100644
--- a/src/checks/missing-transition-target-errors.ts
+++ b/src/checks/missing-transition-target-errors.ts
@@ -1,31 +1,31 @@
-import { JSONPath } from 'jsonpath-plus';
-import { StateMachine, StateMachineError, StateMachineErrorCode, States } from '../types';
+import {JSONPath} from 'jsonpath-plus';
+import {AslChecker, StateMachineErrorCode, States} from '../types';
 
-export default function missingTransitionTargetErrors(definition: StateMachine): StateMachineError[] {
-  // retrieve all states
-  let machineStates: string[] = [];
-  JSONPath<States[]>({ json: definition, path: '$..States' })
-    .forEach((s) => {
-      machineStates = machineStates.concat(Object.keys(s));
-    });
+export const missingTransitionTargetErrors: AslChecker = (definition) => {
+    // retrieve all states
+    let machineStates: string[] = [];
+    JSONPath<States[]>({json: definition, path: '$..States'})
+        .forEach((s) => {
+            machineStates = machineStates.concat(Object.keys(s));
+        });
 
-  // retrieve all reachable states
-  const reachableStates = JSONPath<string[]>({ json: definition, path: '$..[StartAt,Next,Default]' })
-    .filter((path, pos, array) => array.indexOf(path) === pos);
+    // retrieve all reachable states
+    const reachableStates = JSONPath<string[]>({json: definition, path: '$..[StartAt,Next,Default]'})
+        .filter((path, pos, array) => array.indexOf(path) === pos);
 
-  // check if all states are reachable
-  const unreachable = machineStates.filter((state) => reachableStates.indexOf(state) === -1)
-    .map((state) => ({
-      'Error code': StateMachineErrorCode.MissingTransitionTarget,
-      Message: `State ${state} is not reachable`,
-    }));
+    // check if all states are reachable
+    const unreachable = machineStates.filter((state) => reachableStates.indexOf(state) === -1)
+        .map((state) => ({
+            'Error code': StateMachineErrorCode.MissingTransitionTarget,
+            Message: `State ${state} is not reachable`,
+        }));
 
-  // check if all 'Next', 'StartAt' and 'Default' states exist
-  const inexistant = reachableStates.filter((state) => machineStates.indexOf(state) === -1)
-    .map((state) => ({
-      'Error code': StateMachineErrorCode.MissingTransitionTarget,
-      Message: `Missing 'StartAt'|'Next'|'Default' target: ${state}`,
-    }));
+    // check if all 'Next', 'StartAt' and 'Default' states exist
+    const inexistant = reachableStates.filter((state) => machineStates.indexOf(state) === -1)
+        .map((state) => ({
+            'Error code': StateMachineErrorCode.MissingTransitionTarget,
+            Message: `Missing 'StartAt'|'Next'|'Default' target: ${state}`,
+        }));
 
-  return unreachable.concat(inexistant);
-}
+    return unreachable.concat(inexistant);
+}
\ No newline at end of file
diff --git a/src/checks/state-transitions-errors.ts b/src/checks/state-transitions-errors.ts
index f24a171..5ef47c4 100644
--- a/src/checks/state-transitions-errors.ts
+++ b/src/checks/state-transitions-errors.ts
@@ -1,7 +1,7 @@
 import { JSONPath } from 'jsonpath-plus';
-import { StateMachine, StateMachineError, StateMachineErrorCode, States } from '../types';
+import {AslChecker, StateMachine, StateMachineError, StateMachineErrorCode, States} from '../types';
 
-export default function stateTransitionsErrors(definition: StateMachine): StateMachineError[] {
+export const stateTransitionsErrors : AslChecker = (definition) => {
   const errorMessages: StateMachineError[] = [];
 
   // given a nested state machine, this function will examine
@@ -70,4 +70,4 @@ export default function stateTransitionsErrors(definition: StateMachine): StateM
     });
 
   return errorMessages;
-}
+}
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index 3a3fccd..f2d61e8 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -21,3 +21,10 @@ export type StateMachineError = {
     schemaPath: string;
   }
 };
+
+export interface ValidationOptions {
+  readonly checkPaths: boolean;
+  readonly checkArn: boolean;
+}
+
+export type AslChecker = (definition: StateMachine, options: ValidationOptions) => StateMachineError[];
\ No newline at end of file
diff --git a/src/validator.ts b/src/validator.ts
index 267393e..b174284 100644
--- a/src/validator.ts
+++ b/src/validator.ts
@@ -1,33 +1,38 @@
 import {ErrorObject} from 'ajv';
 
-import jsonSchemaErrors from './checks/json-schema-errors';
-import missingTransitionTargetErrors from  './checks/missing-transition-target-errors';
-import stateTransitionsErrors from  './checks/state-transitions-errors';
-import duplicateStateNamesErrors from './checks/duplicate-state-names-errors';
-import missingTerminalStateErrors from './checks/missing-terminal-state-errors';
-import { StateMachine, StateMachineError } from './types';
+import {jsonSchemaErrors} from './checks/json-schema-errors';
+import {missingTransitionTargetErrors} from './checks/missing-transition-target-errors';
+import {stateTransitionsErrors} from './checks/state-transitions-errors';
+import {duplicateStateNames} from './checks/duplicate-state-names-errors';
+import {missingTerminalStateErrors} from './checks/missing-terminal-state-errors';
+import {StateMachine, StateMachineError, ValidationOptions} from './types';
 import {mustNotHaveDuplicateFieldNamesAfterEvaluation} from "./checks/duplicate-payload-template-fields";
 
-export default function validator(definition: StateMachine): {
-  isValid: boolean;
-  errors: (ErrorObject | StateMachineError)[];
-  errorsText: (separator?: string) => string;
-} {
+const DefaultOptions: ValidationOptions = {
+    checkPaths: true,
+    checkArn: true,
+}
 
-  const errors = jsonSchemaErrors(definition);
-  if (errors.length === 0) {
-    errors.push(...missingTransitionTargetErrors(definition));
-    errors.push(...stateTransitionsErrors(definition));
-    errors.push(...duplicateStateNamesErrors(definition));
-    errors.push(...missingTerminalStateErrors(definition));
-    errors.push(...mustNotHaveDuplicateFieldNamesAfterEvaluation(definition))
-  }
+export default function validator(definition: StateMachine, opts?: ValidationOptions): {
+    isValid: boolean;
+    errors: (ErrorObject | StateMachineError)[];
+    errorsText: (separator?: string) => string;
+} {
+    const options = opts ?? DefaultOptions;
+    const errors = jsonSchemaErrors(definition, options);
+    if (errors.length === 0) {
+        errors.push(...missingTransitionTargetErrors(definition, options));
+        errors.push(...stateTransitionsErrors(definition, options));
+        errors.push(...duplicateStateNames(definition, options));
+        errors.push(...missingTerminalStateErrors(definition, options));
+        errors.push(...mustNotHaveDuplicateFieldNamesAfterEvaluation(definition, options))
+    }
 
-  return {
-    isValid: errors.length === 0,
-    errors,
-    errorsText: (separator = '\n') => errors
-      .map(error => `${error['Error code']}: ${error.Message}`)
-      .join(separator),
-  };
-}
+    return {
+        isValid: errors.length === 0,
+        errors,
+        errorsText: (separator = '\n') => errors
+            .map(error => `${error['Error code']}: ${error.Message}`)
+            .join(separator),
+    };
+}
\ No newline at end of file


Test patch:
diff --git a/src/__tests__/options.test.ts b/src/__tests__/options.test.ts
new file mode 100644
index 0000000..f7b5daa
--- /dev/null
+++ b/src/__tests__/options.test.ts
@@ -0,0 +1,59 @@
+import fs from "fs";
+import path from "path";
+import {StateMachine, ValidationOptions} from "../types";
+import validator from "../validator";
+
+describe("show how the options impact validation", () => {
+
+    const loadDef = (file: string): StateMachine => {
+        return JSON.parse(fs.readFileSync(path.join(__dirname, 'definitions', file), "utf-8")) as StateMachine;
+    }
+
+    const expectValid = (file: string, options: ValidationOptions): void => {
+        const definition = loadDef(file);
+        const {isValid, errors} = validator(definition, options);
+        expect(errors ?? []).toStrictEqual([]);
+        expect(isValid).toBe(true);
+    }
+
+    const expectInvalid = (file: string, options: ValidationOptions): void => {
+        const definition = loadDef(file);
+        const {isValid} = validator(definition, options);
+        expect(isValid).toBe(false);
+    }
+
+    describe("checkPaths", () => {
+        const options: ValidationOptions = {
+            checkPaths: false,
+            checkArn: true
+        }
+        it.each(
+            [
+                "invalid-json-path.json",
+                "invalid-payload-template.asl.json"
+            ]
+        )("%s should not report path error", (file) => {
+            expect.hasAssertions();
+            expectValid(file, options);
+        });
+        it("should still report other errors if path expressions are disabled", () => {
+            expect.hasAssertions();
+            expectInvalid("invalid-state-name-too-long.json", options);
+        });
+    });
+
+    describe("checkArn", () => {
+        const options: ValidationOptions = {
+            checkPaths: true,
+            checkArn: false
+        }
+        it("should ignore task arn errors", () => {
+            expect.hasAssertions();
+            expectValid("invalid-cfn-definition-substitutions.json", options);
+        });
+        it("should still report other errors if task arn checks are disabled", () => {
+            expect.hasAssertions();
+            expectInvalid("invalid-state-name-too-long.json", options);
+        });
+    })
+});
\ No newline at end of file