rlaffers / eslint-plugin-xstate

ESLint plugin to check for common mistakes and enforce good practices when using XState.
MIT License
48 stars 4 forks source link

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

Open skyFabioCozz opened 2 years ago

skyFabioCozz commented 2 years ago

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):

rlaffers commented 2 years ago

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.

skyFabioCozz commented 2 years ago

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 commented 2 years ago

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

rlaffers commented 2 years ago

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.

markNZed commented 10 months ago

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

rlaffers commented 10 months ago

Yes indeed, that would be a workaround.

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

/* eslint-plugin-xstate-include */