Skip to content

Commit

Permalink
feat: add no-invalid-state-props rule
Browse files Browse the repository at this point in the history
  • Loading branch information
rlaffers committed May 18, 2021
1 parent 03c61d5 commit d3cd6c2
Show file tree
Hide file tree
Showing 3 changed files with 209 additions and 0 deletions.
3 changes: 3 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ module.exports = {
'no-auto-forward': require('./rules/no-auto-forward'),
'no-misplaced-on-transition': require('./rules/no-misplaced-on-transition'),
'no-invalid-transition-props': require('./rules/no-invalid-transition-props'),
'no-invalid-state-props': require('./rules/no-invalid-state-props'),
'no-async-guard': require('./rules/no-async-guard'),
},
configs: {
Expand All @@ -41,6 +42,7 @@ module.exports = {
'xstate/prefer-always': 'error',
'xstate/no-misplaced-on-transition': 'error',
'xstate/no-invalid-transition-props': 'error',
'xstate/no-invalid-state-props': 'error',
'xstate/no-async-guard': 'error',
},
},
Expand All @@ -61,6 +63,7 @@ module.exports = {
'xstate/prefer-always': 'error',
'xstate/no-misplaced-on-transition': 'error',
'xstate/no-invalid-transition-props': 'error',
'xstate/no-invalid-state-props': 'error',
'xstate/no-async-guard': 'error',
},
},
Expand Down
201 changes: 201 additions & 0 deletions lib/rules/no-invalid-state-props.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
'use strict'

// TODO this rule supersedes no-root-ondone
const getDocsUrl = require('../utils/getDocsUrl')
const { propertyHasName, propertyHasValue } = require('../utils/predicates')
const { allPass } = require('../utils/combinators')

const validProperties = [
'after',
'always',
'entry',
'exit',
'history', // only when type=history
'id',
'initial',
'meta',
'on',
'onDone',
'states',
'tags',
'target', // only when type=history
'type',
]
function isValidStateProperty(property) {
return validProperties.includes(property.key.name)
}

const validRootProperties = [
'after',
'context',
'entry',
'history', // only when type=history
'id',
'initial',
'meta',
'on',
'states',
'tags',
'target', // only when type=history
'type',
]
function isValidRootStateProperty(property) {
return validRootProperties.includes(property.key.name)
}

function hasHistoryTypeProperty(node) {
return (
node.type === 'ObjectExpression' &&
node.properties.some(
allPass([propertyHasName('type'), propertyHasValue('history')])
)
)
}

const validTypes = ['atomic', 'compound', 'parallel', 'history', 'final']
function isValidTypePropertyValue(node) {
return node.type === 'Literal' && validTypes.includes(node.value)
}

const validHistoryTypes = ['shallow', 'deep']
function isValidHistoryPropertyValue(node) {
return node.type === 'Literal' && validHistoryTypes.includes(node.value)
}

function validateTypePropertyValue(prop, context) {
if (prop.key.name === 'type' && !isValidTypePropertyValue(prop.value)) {
context.report({
node: prop,
messageId: 'invalidTypeValue',
data: {
value:
prop.value.type === 'Literal' ? prop.value.value : prop.value.type,
},
})
return false
}
return true
}

function validateHistorySpecificProperty(prop, context, isHistoryNode) {
if (
(prop.key.name === 'history' || prop.key.name === 'target') &&
!isHistoryNode
) {
context.report({
node: prop,
messageId: 'propAllowedOnHistoryStateOnly',
data: { propName: prop.key.name },
})
return false
}
return true
}

function validateHistoryPropertyValue(prop, context) {
if (prop.key.name === 'history' && !isValidHistoryPropertyValue(prop.value)) {
context.report({
node: prop,
messageId: 'invalidHistoryValue',
data: {
value:
prop.value.type === 'Literal' ? prop.value.value : prop.value.type,
},
})
return false
}
return true
}

const stateDeclaration =
'CallExpression[callee.name=/^createMachine$|^Machine$/] Property[key.name="states"] > ObjectExpression > Property > ObjectExpression'

const rootStateDeclaration =
'CallExpression[callee.name=/^createMachine$|^Machine$/] > ObjectExpression:first-child'

module.exports = {
meta: {
type: 'problem',
docs: {
description: 'forbid invalid properties in state node declarations',
category: 'Possible Errors',
url: getDocsUrl('no-invalid-state-props'),
recommended: true,
},
schema: [],
messages: {
invalidStateProperty:
'"{{propName}}" is not a valid property for a state declaration.',
invalidRootStateProperty:
'"{{propName}}" is not a valid property for the root state node.',
propAllowedOnHistoryStateOnly:
'Property "{{propName}}" is valid only on a "history" type state node.',
invalidTypeValue:
'Type "{{value}}" is invalid. Use one of: "atomic", "compound", "parallel", "history", "final".',
invalidHistoryValue:
'The history type of "{{value}}" is invalid. Use one of: "shallow", "deep".',
contextAllowedOnlyOnRootNodes:
'The "context" property cannot be declared on non-root state nodes.',
},
},

create: function (context) {
return {
[stateDeclaration]: function (node) {
const isHistoryNode = hasHistoryTypeProperty(node)
node.properties.forEach((prop) => {
if (!validateHistorySpecificProperty(prop, context, isHistoryNode)) {
return
}

if (prop.key.name === 'context') {
context.report({
node: prop,
messageId: 'contextAllowedOnlyOnRootNodes',
})
return
}

if (!isValidStateProperty(prop)) {
context.report({
node: prop,
messageId: 'invalidStateProperty',
data: { propName: prop.key.name },
})
return
}

if (!validateTypePropertyValue(prop, context)) {
return
}

validateHistoryPropertyValue(prop, context)
})
},

[rootStateDeclaration]: function (node) {
const isHistoryNode = hasHistoryTypeProperty(node)
node.properties.forEach((prop) => {
if (!validateHistorySpecificProperty(prop, context, isHistoryNode)) {
return
}

if (!isValidRootStateProperty(prop)) {
context.report({
node: prop,
messageId: 'invalidRootStateProperty',
data: { propName: prop.key.name },
})
return
}

if (!validateTypePropertyValue(prop, context)) {
return
}

validateHistoryPropertyValue(prop, context)
})
},
}
},
}
5 changes: 5 additions & 0 deletions lib/utils/predicates.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ function isFirstArrayItem(node) {
}

const propertyHasName = (propName) => (node) => node.key.name === propName
// matches only literal values (string, number, boolean, null)
const propertyHasValue = (value) => (node) =>
node.value.type === 'Literal' && node.value.value === value

const propertyValueIsNil = (node) =>
(node.type === 'Literal' && node.value.value === undefined) ||
(node.type === 'Identifier' && node.name === 'undefined')
Expand Down Expand Up @@ -130,6 +134,7 @@ function isReservedXStateWord(string) {
module.exports = {
isFirstArrayItem,
propertyHasName,
propertyHasValue,
hasProperty,
isFunctionExpression,
isIIFE,
Expand Down

0 comments on commit d3cd6c2

Please sign in to comment.