eslint-plugin-xstate icon indicating copy to clipboard operation
eslint-plugin-xstate copied to clipboard

Plugin does not detect any errors if a generic FSM is structured in two distinct files

Open skyFabioCozz opened this issue 2 years ago • 6 comments

Describe the bug The plugin does not detect any errors if a generic FSM is structured in two distinct files, as follows:

authFSM.ts

import { Machine } from 'xstate';
import { AuthFSMEvent, IAuthFSMContext, IAuthFSMStateSchema } from './authFSMInterfaces';
import { authFSMSchema } from './authFSMSchema';

export const authFSM = Machine<IAuthFSMContext, IAuthFSMStateSchema, AuthFSMEvent>(
     authFSMSchema
);

authFSMSchema.ts

import { actions, assign, DoneInvokeEvent, MachineConfig, sendParent } from 'xstate';
import { AuthFSMEvent, IAuthFSMContext, IAuthFSMStateSchema } from './authFSMInterfaces';

export const authFSMSchema: MachineConfig<IAuthFSMContext, IAuthFSMStateSchema, AuthFSMEvent> = {
    id: 'authentication',
    initial: 'initialState',

    states: {
        initialState: {
            entry: [
                actions.log('---- [authFSM] initialState state ----'),
                // 'assignAuthBackground',
                // 'drawBackground'
            ],
            always: {
                target: 'insertPinForm'
            }
        },

        insertPinForm: {
            entry: [
                actions.log('---- [authFSM] insertPinForm state ----'),
            ],
            /*invoke: {
                id: 'insertPinForm',
                src: 'drawingInsertPinForm'
            },*/
            on: {
                DISMISS: {
                    actions: [
                        sendParent((context: IAuthFSMContext, event: any) => ({
                            type: 'GO_BACK',
                        }))
                    ],
                    target: 'finalState'
                },
                GO_TO_FORGOT_PIN: {
                    target: 'resetPinInfo'
                },
                PIN_DIGITS_COMPLETE: {
                    actions: [
                        (_, event) => console.log('insertPinForm state, received PIN_DIGITS_COMPLETE event, pin: ', event.pin),
                        assign({
                            pin: (context, event: any) => event.pin
                        })
                    ],
                    target: 'getSystemInfo'
                },
            },
        },

        getSystemInfo: {
            entry: [
                actions.log('---- [authFSM] getSystemInfo state ----'),
            ],
            invoke: {
                src: 'getSystemInfoFromAS',
                onDone: {
                    target: 'sendInfoToCerebro',
                    actions: [
                        (_, event: DoneInvokeEvent<any>) => console.log('systemInfo: ', event.data.systemInfo),
                        assign({
                            systemInfo: (context, event: DoneInvokeEvent<any>) => event.data.systemInfo
                        })
                    ]
                },
                onError: {
                    actions: [
                        sendParent((context: IAuthFSMContext, event: any) => ({
                            type: 'LOGIN_ERROR',
                        }))
                    ],
                    target: 'finalState'
                }
            },
        },

        sendInfoToCerebro: {
            entry: [
                actions.log('---- [authFSM] sendInfoToCerebro state ----'),
            ],
            /*invoke: {
                id: 'sendInfoToCerebro',
                src: 'sendingInfoToCerebro',
            },*/
            on: {
                PIN_OK: {
                    actions: [
                        (_, event: any) => console.log('PIN_OK event, auth token: ', event.res.login.accessToken),
                        assign({
                            authToken: (context, event: any) => event.res.login.accessToken
                        }),
                        'writeOAuthTokenIntoCookie',
                        // 'assignMainPageBackground',
                        // 'drawBackground',
                        sendParent((context: IAuthFSMContext, event: any) => ({
                            type: 'LOGIN_SUCCESS',
                        }))
                    ],
                    target: 'finalState'
                },
                WRONG_PIN: {
                    actions: [
                        (context, event: any) => console.log('sendInfoToCerebro received WRONG_PIN event, remainingAttempt: ', event.res.login.remainingAttempt),
                        assign({
                            remainingAttempt: (context, event: any) => event.res.login.remainingAttempt
                        })
                    ],
                    target: 'insertPinForm'
                },
                PIN_BLOCKED: {
                    actions: [
                        (_, event: any) => console.log('PIN_BLOCKED event'),
                        assign({
                            blockingMinutes: (context, event: any) => event.res.login.blockingMinutes
                        })
                    ],
                    target: 'blockedPin'
                },
                GENERIC_ERROR: {
                    actions: [
                        (_, event: any) => console.log('sendInfoToCerebro state, received GENERIC_ERROR event'),
                        sendParent((context: IAuthFSMContext, event: any) => ({
                            type: 'LOGIN_ERROR',
                        }))
                    ],
                    target: 'finalState'
                }
            },
        },

        blockedPin: {
            entry: [
                actions.log('---- [authFSM] blockedPin state ----'),
            ],
            /*invoke: {
                id: 'blockedPin',
                src: 'drawingBlockedPin',
            },*/
            on: {
                DISMISS: {
                    actions: [
                        (_, event: any) => console.log('blockedPin state, DISMISS event'),
                        sendParent((context: IAuthFSMContext, event: any) => ({
                            type: 'GO_BACK',
                        }))
                    ],
                    target: 'finalState'
                },
                GO_TO_FORGOT_PIN: {
                    actions: [
                        (_, event: any) => console.log('blockedPin state, GO_TO_FORGOT_PIN event')
                    ],
                    target: 'resetPinInfo'
                }
            }
        },

        resetPinInfo: {
            entry: [
                actions.log('---- [authFSM] resetPinInfo state ----'),
            ],
            /*invoke: {
                id: 'resetPinInfo',
                src: 'drawingResetPinInfo',
            },*/
            on: {
                DISMISS: {
                    actions: [
                        (_, event: any) => console.log('resetPinInfo state, DISMISS event'),
                        sendParent((context: IAuthFSMContext, event: any) => ({
                            type: 'GO_BACK',
                        }))
                    ],
                    target: 'finalState'
                },
            }
        },

        finalState: {
            type: 'final'
        }
    }
};

if I explode the authFSMSchema object inside the authFSM object (copy and paste) as in the example below, then everything works fine and I can see every eslint error.

export const authFSM = Machine<IAuthFSMContext, IAuthFSMStateSchema, AuthFSMEvent>(
    {
        id: 'authentication',
        initial: 'initialState',

        states: {
            initialState: {
                entry: [
                    actions.log('---- [authFSM] initialState state ----'),
                    // 'assignAuthBackground',
                    // 'drawBackground'
                ],
                always: {
                    target: 'insertPinForm'
                }
            },

            insertPinForm: {
                entry: [
                    actions.log('---- [authFSM] insertPinForm state ----'),
                ],
                /*invoke: {
                    id: 'insertPinForm',
                    src: 'drawingInsertPinForm'
                },*/
                on: {
                    DISMISS: {
                        actions: [
                            sendParent((context: IAuthFSMContext, event: any) => ({
                                type: 'GO_BACK',
                            }))
                        ],
                        target: 'finalState'
                    },
                    GO_TO_FORGOT_PIN: {
                        target: 'resetPinInfo'
                    },
                    PIN_DIGITS_COMPLETE: {
                        actions: [
                            (_, event) => console.log('insertPinForm state, received PIN_DIGITS_COMPLETE event, pin: ', event.pin),
                            assign({
                                pin: (context, event: any) => event.pin
                            })
                        ],
                        target: 'getSystemInfo'
                    },
                },
            },

            getSystemInfo: {
                entry: [
                    actions.log('---- [authFSM] getSystemInfo state ----'),
                ],
                invoke: {
                    src: 'getSystemInfoFromAS',
                    onDone: {
                        target: 'sendInfoToCerebro',
                        actions: [
                            (_, event: DoneInvokeEvent<any>) => console.log('systemInfo: ', event.data.systemInfo),
                            assign({
                                systemInfo: (context, event: DoneInvokeEvent<any>) => event.data.systemInfo
                            })
                        ]
                    },
                    onError: {
                        actions: [
                            sendParent((context: IAuthFSMContext, event: any) => ({
                                type: 'LOGIN_ERROR',
                            }))
                        ],
                        target: 'finalState'
                    }
                },
            },

            sendInfoToCerebro: {
                entry: [
                    actions.log('---- [authFSM] sendInfoToCerebro state ----'),
                ],
                /*invoke: {
                    id: 'sendInfoToCerebro',
                    src: 'sendingInfoToCerebro',
                },*/
                on: {
                    PIN_OK: {
                        actions: [
                            (_, event: any) => console.log('PIN_OK event, auth token: ', event.res.login.accessToken),
                            assign({
                                authToken: (context, event: any) => event.res.login.accessToken
                            }),
                            'writeOAuthTokenIntoCookie',
                            // 'assignMainPageBackground',
                            // 'drawBackground',
                            sendParent((context: IAuthFSMContext, event: any) => ({
                                type: 'LOGIN_SUCCESS',
                            }))
                        ],
                        target: 'finalState'
                    },
                    WRONG_PIN: {
                        actions: [
                            (context, event: any) => console.log('sendInfoToCerebro received WRONG_PIN event, remainingAttempt: ', event.res.login.remainingAttempt),
                            assign({
                                remainingAttempt: (context, event: any) => event.res.login.remainingAttempt
                            })
                        ],
                        target: 'insertPinForm'
                    },
                    PIN_BLOCKED: {
                        actions: [
                            (_, event: any) => console.log('PIN_BLOCKED event'),
                            assign({
                                blockingMinutes: (context, event: any) => event.res.login.blockingMinutes
                            })
                        ],
                        target: 'blockedPin'
                    },
                    GENERIC_ERROR: {
                        actions: [
                            (_, event: any) => console.log('sendInfoToCerebro state, received GENERIC_ERROR event'),
                            sendParent((context: IAuthFSMContext, event: any) => ({
                                type: 'LOGIN_ERROR',
                            }))
                        ],
                        target: 'finalState'
                    }
                },
            },

            blockedPin: {
                entry: [
                    actions.log('---- [authFSM] blockedPin state ----'),
                ],
                /*invoke: {
                    id: 'blockedPin',
                    src: 'drawingBlockedPin',
                },*/
                on: {
                    DISMISS: {
                        actions: [
                            (_, event: any) => console.log('blockedPin state, DISMISS event'),
                            sendParent((context: IAuthFSMContext, event: any) => ({
                                type: 'GO_BACK',
                            }))
                        ],
                        target: 'finalState'
                    },
                    GO_TO_FORGOT_PIN: {
                        actions: [
                            (_, event: any) => console.log('blockedPin state, GO_TO_FORGOT_PIN event')
                        ],
                        target: 'resetPinInfo'
                    }
                }
            },

            resetPinInfo: {
                entry: [
                    actions.log('---- [authFSM] resetPinInfo state ----'),
                ],
                /*invoke: {
                    id: 'resetPinInfo',
                    src: 'drawingResetPinInfo',
                },*/
                on: {
                    DISMISS: {
                        actions: [
                            (_, event: any) => console.log('resetPinInfo state, DISMISS event'),
                            sendParent((context: IAuthFSMContext, event: any) => ({
                                type: 'GO_BACK',
                            }))
                        ],
                        target: 'finalState'
                    },
                }
            },

            finalState: {
                type: 'final'
            }
        }
    }
);

Expected behavior The plugin must also work with a fsm structured in 2 files as in the previous example.

Actual behavior The plug-in does not detect any errors if a generic FSM is structured in two distinct files.

Versions (please complete the following information):

  • Node version: v14.17.0
  • ESLint version: v6.8.0
  • eslint-plugin-xstate version: v0.13.1

skyFabioCozz avatar Sep 02 '21 10:09 skyFabioCozz

Yes, that is to be expected. Since the machine configuration is just a plain JS object, we have no way of knowing it is going to be used as an Xstate config. ESLint, in and of itself, is not capable of performing type-aware linting. Linting Xstate config objects is based on the code context. In this case our ESLint rules detect whether the object in question is passed as an argument to Machine() or createMachine().

There is a project which strives to bring type-aware linting into the ESLint world: typescript-eslint I'm not familiar with it however, and don't know how well it works. Their docs say that not all ESLint rules are going to work with typescript-eslint out of the box. To be able to leverage it, our rules need to be adapted so they take into consideration the type information. Also importantly, typescript-eslint will be inherently slow because your source code would be first compiled into JS and then linted.

Can you perhaps help investigating how typescript-eslint works with our xstate rules? Then we can look into what needs to be adjusted in our rules.

rlaffers avatar Sep 03 '21 06:09 rlaffers

I already use the typescript-eslint plugin in fact my .eslintrc.js file, which contains all the rules, is the following:

module.exports = {
  env: {
    commonjs: true,
    es6: true,
    node: true,
    mocha: true
  },
  extends: [
    'airbnb-base',
    'plugin:@typescript-eslint/recommended'
  ],
  globals: {
    Atomics: 'readonly',
    SharedArrayBuffer: 'readonly',
  },
  parser: '@typescript-eslint/parser',
  parserOptions: {
    project: "./tsconfig.json",
    tsconfigRootDir: __dirname,
    ecmaVersion: 2018,
    sourceType: "module"
  },
  plugins: [
    '@typescript-eslint/eslint-plugin', // TS Eslint plugin (https://www.npmjs.com/package/@typescript-eslint/eslint-plugin)
    'xstate' // Xstate Eslint plugin (https://www.npmjs.com/package/eslint-plugin-xstate)
  ],
  rules: {
    /////////////////////////////////////////////////////////////////
    // generic TS best practices and specific team conventions
    /////////////////////////////////////////////////////////////////
    'no-unused-vars': 0,
    'max-classes-per-file': ['warn', 2],
    'import/no-unresolved': 0,
    'import/prefer-default-export': 0,
    'no-useless-constructor': 0,
    'no-underscore-dangle': 0,
    'max-len': ['error', 200],
    'default-case': 0,
    'padded-blocks': 0,
    'lines-between-class-members': 0,
    'indent': [1, 4, { SwitchCase: 1, ignoreComments: true }],  // enforce consistent indentation
    'no-restricted-syntax': 0,
    'no-continue': 0,
    'no-param-reassign': 0,
    'no-case-declarations': 0,
    'no-multi-assign': 0,
    'no-nested-ternary': 0,
    'no-restricted-globals': 0,
    'operator-assignment': 0,
    'no-useless-concat': 0,
    'no-mixed-operators': 0,
    'object-curly-newline': 0,
    'comma-dangle': 0,
    'no-shadow': 0,
    'no-plusplus': 0,
    'prefer-destructuring': 0,
    'no-console': 0,
    'linebreak-style': 0,
    'no-trailing-spaces': 0,
    'no-eval': 0,
    'dot-notation': 0,
    'curly': ['warn', 'multi'],
    'nonblock-statement-body-position': ["error", "below"],
    'import/extensions': 0,
    'no-unused-expressions': 0,
    'spaced-comment': 0,
    'class-methods-use-this': 0,
    'no-else-return': 0,
    'arrow-body-style': ["warn"],
    '@typescript-eslint/interface-name-prefix': [2, {"prefixWithI": "always"}],
    '@typescript-eslint/no-empty-function': 0,
    '@typescript-eslint/camelcase': 0,
    'prefer-template': 0,
    'import/no-dynamic-require': 0,
    'global-require': 0,
    '@typescript-eslint/no-explicit-any': 0,
    'no-multiple-empty-lines': 0,
    '@typescript-eslint/explicit-function-return-type': 0,
    'object-shorthand': 0,
    /////////////////////////////////////////////////////////////////
    // generic Xstate best practices and specific team conventions
    /////////////////////////////////////////////////////////////////
    'xstate/prefer-always': "error",
    'xstate/no-inline-implementation': "error",
  }
};

skyFabioCozz avatar Sep 06 '21 07:09 skyFabioCozz

Hello and thank you very much for the replies ... any news/ideas about adjusting your rules to get "coexistence" with the typescript-eslint plugin?

skyFabioCozz avatar Sep 08 '21 09:09 skyFabioCozz

Unfortunately, I'm quite busy in the next few days so there will be no quick solution. I plan to look into how type-aware linting rules can be created and whether they can coexist with our current type-less rules.

rlaffers avatar Sep 08 '21 10:09 rlaffers

The fix for https://github.com/rlaffers/eslint-plugin-xstate/issues/20 might also help here ?

markNZed avatar Aug 28 '23 08:08 markNZed

Yes indeed, that would be a workaround.

@skyFabioCozz Try adding a comment to your file with machine configuration:

/* eslint-plugin-xstate-include */

rlaffers avatar Aug 28 '23 18:08 rlaffers