You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
442 lines
12 KiB
442 lines
12 KiB
3 years ago
|
/**
|
||
|
* @fileoverview disallow mutation component props
|
||
|
* @author 2018 Armano
|
||
|
*/
|
||
|
'use strict'
|
||
|
|
||
|
const utils = require('../utils')
|
||
|
const { findVariable } = require('eslint-utils')
|
||
|
|
||
|
// ------------------------------------------------------------------------------
|
||
|
// Rule Definition
|
||
|
// ------------------------------------------------------------------------------
|
||
|
|
||
|
// https://github.com/vuejs/vue-next/blob/7c11c58faf8840ab97b6449c98da0296a60dddd8/packages/shared/src/globalsWhitelist.ts
|
||
|
const GLOBALS_WHITE_LISTED = new Set([
|
||
|
'Infinity',
|
||
|
'undefined',
|
||
|
'NaN',
|
||
|
'isFinite',
|
||
|
'isNaN',
|
||
|
'parseFloat',
|
||
|
'parseInt',
|
||
|
'decodeURI',
|
||
|
'decodeURIComponent',
|
||
|
'encodeURI',
|
||
|
'encodeURIComponent',
|
||
|
'Math',
|
||
|
'Number',
|
||
|
'Date',
|
||
|
'Array',
|
||
|
'Object',
|
||
|
'Boolean',
|
||
|
'String',
|
||
|
'RegExp',
|
||
|
'Map',
|
||
|
'Set',
|
||
|
'JSON',
|
||
|
'Intl',
|
||
|
'BigInt'
|
||
|
])
|
||
|
|
||
|
module.exports = {
|
||
|
meta: {
|
||
|
type: 'suggestion',
|
||
|
docs: {
|
||
|
description: 'disallow mutation of component props',
|
||
|
categories: ['vue3-essential', 'essential'],
|
||
|
url: 'https://eslint.vuejs.org/rules/no-mutating-props.html'
|
||
|
},
|
||
|
fixable: null, // or "code" or "whitespace"
|
||
|
schema: [
|
||
|
// fill in your schema
|
||
|
]
|
||
|
},
|
||
|
/** @param {RuleContext} context */
|
||
|
create(context) {
|
||
|
/** @type {Map<ObjectExpression|CallExpression, Set<string>>} */
|
||
|
const propsMap = new Map()
|
||
|
/** @type { { type: 'export' | 'mark' | 'definition', object: ObjectExpression } | { type: 'setup', object: CallExpression } | null } */
|
||
|
let vueObjectData = null
|
||
|
|
||
|
/**
|
||
|
* @param {ASTNode} node
|
||
|
* @param {string} name
|
||
|
*/
|
||
|
function report(node, name) {
|
||
|
context.report({
|
||
|
node,
|
||
|
message: 'Unexpected mutation of "{{key}}" prop.',
|
||
|
data: {
|
||
|
key: name
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {ASTNode} node
|
||
|
* @returns {VExpressionContainer}
|
||
|
*/
|
||
|
function getVExpressionContainer(node) {
|
||
|
let n = node
|
||
|
while (n.type !== 'VExpressionContainer') {
|
||
|
n = /** @type {ASTNode} */ (n.parent)
|
||
|
}
|
||
|
return n
|
||
|
}
|
||
|
/**
|
||
|
* @param {MemberExpression|AssignmentProperty} node
|
||
|
* @returns {string}
|
||
|
*/
|
||
|
function getPropertyNameText(node) {
|
||
|
const name = utils.getStaticPropertyName(node)
|
||
|
if (name) {
|
||
|
return name
|
||
|
}
|
||
|
if (node.computed) {
|
||
|
const expr = node.type === 'Property' ? node.key : node.property
|
||
|
const str = context.getSourceCode().getText(expr)
|
||
|
return `[${str}]`
|
||
|
}
|
||
|
return '?unknown?'
|
||
|
}
|
||
|
/**
|
||
|
* @param {ASTNode} node
|
||
|
* @returns {node is Identifier}
|
||
|
*/
|
||
|
function isVmReference(node) {
|
||
|
if (node.type !== 'Identifier') {
|
||
|
return false
|
||
|
}
|
||
|
const parent = node.parent
|
||
|
if (parent.type === 'MemberExpression') {
|
||
|
if (parent.property === node) {
|
||
|
// foo.id
|
||
|
return false
|
||
|
}
|
||
|
} else if (parent.type === 'Property') {
|
||
|
// {id: foo}
|
||
|
if (parent.key === node && !parent.computed) {
|
||
|
return false
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const exprContainer = getVExpressionContainer(node)
|
||
|
|
||
|
for (const reference of exprContainer.references) {
|
||
|
if (reference.variable != null) {
|
||
|
// Not vm reference
|
||
|
continue
|
||
|
}
|
||
|
if (reference.id === node) {
|
||
|
return true
|
||
|
}
|
||
|
}
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {MemberExpression|Identifier} props
|
||
|
* @param {string} name
|
||
|
*/
|
||
|
function verifyMutating(props, name) {
|
||
|
const invalid = utils.findMutating(props)
|
||
|
if (invalid) {
|
||
|
report(invalid.node, name)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {Pattern} param
|
||
|
* @param {string[]} path
|
||
|
* @returns {Generator<{ node: Identifier, path: string[] }>}
|
||
|
*/
|
||
|
function* iteratePatternProperties(param, path) {
|
||
|
if (!param) {
|
||
|
return
|
||
|
}
|
||
|
if (param.type === 'Identifier') {
|
||
|
yield {
|
||
|
node: param,
|
||
|
path
|
||
|
}
|
||
|
} else if (param.type === 'RestElement') {
|
||
|
yield* iteratePatternProperties(param.argument, path)
|
||
|
} else if (param.type === 'AssignmentPattern') {
|
||
|
yield* iteratePatternProperties(param.left, path)
|
||
|
} else if (param.type === 'ObjectPattern') {
|
||
|
for (const prop of param.properties) {
|
||
|
if (prop.type === 'Property') {
|
||
|
const name = getPropertyNameText(prop)
|
||
|
yield* iteratePatternProperties(prop.value, [...path, name])
|
||
|
} else if (prop.type === 'RestElement') {
|
||
|
yield* iteratePatternProperties(prop.argument, path)
|
||
|
}
|
||
|
}
|
||
|
} else if (param.type === 'ArrayPattern') {
|
||
|
for (let index = 0; index < param.elements.length; index++) {
|
||
|
const element = param.elements[index]
|
||
|
yield* iteratePatternProperties(element, [...path, `${index}`])
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {Identifier} prop
|
||
|
* @param {string[]} path
|
||
|
*/
|
||
|
function verifyPropVariable(prop, path) {
|
||
|
const variable = findVariable(context.getScope(), prop)
|
||
|
if (!variable) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
for (const reference of variable.references) {
|
||
|
if (!reference.isRead()) {
|
||
|
continue
|
||
|
}
|
||
|
const id = reference.identifier
|
||
|
|
||
|
const invalid = utils.findMutating(id)
|
||
|
if (!invalid) {
|
||
|
continue
|
||
|
}
|
||
|
let name
|
||
|
if (path.length === 0) {
|
||
|
if (invalid.pathNodes.length === 0) {
|
||
|
continue
|
||
|
}
|
||
|
const mem = invalid.pathNodes[0]
|
||
|
name = getPropertyNameText(mem)
|
||
|
} else {
|
||
|
if (invalid.pathNodes.length === 0 && invalid.kind !== 'call') {
|
||
|
continue
|
||
|
}
|
||
|
name = path[0]
|
||
|
}
|
||
|
|
||
|
report(invalid.node, name)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function* extractDefineVariableNames() {
|
||
|
const globalScope = context.getSourceCode().scopeManager.globalScope
|
||
|
if (globalScope) {
|
||
|
for (const variable of globalScope.variables) {
|
||
|
if (variable.defs.length) {
|
||
|
yield variable.name
|
||
|
}
|
||
|
}
|
||
|
const moduleScope = globalScope.childScopes.find(
|
||
|
(scope) => scope.type === 'module'
|
||
|
)
|
||
|
for (const variable of (moduleScope && moduleScope.variables) || []) {
|
||
|
if (variable.defs.length) {
|
||
|
yield variable.name
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return utils.compositingVisitors(
|
||
|
{},
|
||
|
utils.defineScriptSetupVisitor(context, {
|
||
|
onDefinePropsEnter(node, props) {
|
||
|
const defineVariableNames = new Set(extractDefineVariableNames())
|
||
|
|
||
|
const propsSet = new Set(
|
||
|
props
|
||
|
.map((p) => p.propName)
|
||
|
.filter(
|
||
|
/**
|
||
|
* @returns {propName is string}
|
||
|
*/
|
||
|
(propName) =>
|
||
|
utils.isDef(propName) &&
|
||
|
!GLOBALS_WHITE_LISTED.has(propName) &&
|
||
|
!defineVariableNames.has(propName)
|
||
|
)
|
||
|
)
|
||
|
propsMap.set(node, propsSet)
|
||
|
vueObjectData = {
|
||
|
type: 'setup',
|
||
|
object: node
|
||
|
}
|
||
|
|
||
|
let target = node
|
||
|
if (
|
||
|
target.parent &&
|
||
|
target.parent.type === 'CallExpression' &&
|
||
|
target.parent.arguments[0] === target &&
|
||
|
target.parent.callee.type === 'Identifier' &&
|
||
|
target.parent.callee.name === 'withDefaults'
|
||
|
) {
|
||
|
target = target.parent
|
||
|
}
|
||
|
|
||
|
if (
|
||
|
!target.parent ||
|
||
|
target.parent.type !== 'VariableDeclarator' ||
|
||
|
target.parent.init !== target
|
||
|
) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
for (const { node: prop, path } of iteratePatternProperties(
|
||
|
target.parent.id,
|
||
|
[]
|
||
|
)) {
|
||
|
verifyPropVariable(prop, path)
|
||
|
propsSet.add(prop.name)
|
||
|
}
|
||
|
}
|
||
|
}),
|
||
|
utils.defineVueVisitor(context, {
|
||
|
onVueObjectEnter(node) {
|
||
|
propsMap.set(
|
||
|
node,
|
||
|
new Set(
|
||
|
utils
|
||
|
.getComponentPropsFromOptions(node)
|
||
|
.map((p) => p.propName)
|
||
|
.filter(utils.isDef)
|
||
|
)
|
||
|
)
|
||
|
},
|
||
|
onVueObjectExit(node, { type }) {
|
||
|
if (
|
||
|
(!vueObjectData ||
|
||
|
(vueObjectData.type !== 'export' &&
|
||
|
vueObjectData.type !== 'setup')) &&
|
||
|
type !== 'instance'
|
||
|
) {
|
||
|
vueObjectData = {
|
||
|
type,
|
||
|
object: node
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
onSetupFunctionEnter(node) {
|
||
|
const propsParam = node.params[0]
|
||
|
if (!propsParam) {
|
||
|
// no arguments
|
||
|
return
|
||
|
}
|
||
|
if (
|
||
|
propsParam.type === 'RestElement' ||
|
||
|
propsParam.type === 'ArrayPattern'
|
||
|
) {
|
||
|
// cannot check
|
||
|
return
|
||
|
}
|
||
|
for (const { node: prop, path } of iteratePatternProperties(
|
||
|
propsParam,
|
||
|
[]
|
||
|
)) {
|
||
|
verifyPropVariable(prop, path)
|
||
|
}
|
||
|
},
|
||
|
/** @param {(Identifier | ThisExpression) & { parent: MemberExpression } } node */
|
||
|
'MemberExpression > :matches(Identifier, ThisExpression)'(
|
||
|
node,
|
||
|
{ node: vueNode }
|
||
|
) {
|
||
|
if (!utils.isThis(node, context)) {
|
||
|
return
|
||
|
}
|
||
|
const mem = node.parent
|
||
|
if (mem.object !== node) {
|
||
|
return
|
||
|
}
|
||
|
const name = utils.getStaticPropertyName(mem)
|
||
|
if (
|
||
|
name &&
|
||
|
/** @type {Set<string>} */ (propsMap.get(vueNode)).has(name)
|
||
|
) {
|
||
|
verifyMutating(mem, name)
|
||
|
}
|
||
|
}
|
||
|
}),
|
||
|
utils.defineTemplateBodyVisitor(context, {
|
||
|
/** @param {ThisExpression & { parent: MemberExpression } } node */
|
||
|
'VExpressionContainer MemberExpression > ThisExpression'(node) {
|
||
|
if (!vueObjectData) {
|
||
|
return
|
||
|
}
|
||
|
const mem = node.parent
|
||
|
if (mem.object !== node) {
|
||
|
return
|
||
|
}
|
||
|
const name = utils.getStaticPropertyName(mem)
|
||
|
if (
|
||
|
name &&
|
||
|
/** @type {Set<string>} */ (propsMap.get(vueObjectData.object)).has(
|
||
|
name
|
||
|
)
|
||
|
) {
|
||
|
verifyMutating(mem, name)
|
||
|
}
|
||
|
},
|
||
|
/** @param {Identifier } node */
|
||
|
'VExpressionContainer Identifier'(node) {
|
||
|
if (!vueObjectData) {
|
||
|
return
|
||
|
}
|
||
|
if (!isVmReference(node)) {
|
||
|
return
|
||
|
}
|
||
|
const name = node.name
|
||
|
if (
|
||
|
name &&
|
||
|
/** @type {Set<string>} */ (propsMap.get(vueObjectData.object)).has(
|
||
|
name
|
||
|
)
|
||
|
) {
|
||
|
verifyMutating(node, name)
|
||
|
}
|
||
|
},
|
||
|
/** @param {ESNode} node */
|
||
|
"VAttribute[directive=true]:matches([key.name.name='model'], [key.name.name='bind']) VExpressionContainer > *"(
|
||
|
node
|
||
|
) {
|
||
|
if (!vueObjectData) {
|
||
|
return
|
||
|
}
|
||
|
let attr = node.parent
|
||
|
while (attr && attr.type !== 'VAttribute') {
|
||
|
attr = attr.parent
|
||
|
}
|
||
|
if (attr && attr.directive && attr.key.name.name === 'bind') {
|
||
|
if (!attr.key.modifiers.some((mod) => mod.name === 'sync')) {
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const nodes = utils.getMemberChaining(node)
|
||
|
const first = nodes[0]
|
||
|
let name
|
||
|
if (isVmReference(first)) {
|
||
|
name = first.name
|
||
|
} else if (first.type === 'ThisExpression') {
|
||
|
const mem = nodes[1]
|
||
|
if (!mem) {
|
||
|
return
|
||
|
}
|
||
|
name = utils.getStaticPropertyName(mem)
|
||
|
} else {
|
||
|
return
|
||
|
}
|
||
|
if (
|
||
|
name &&
|
||
|
/** @type {Set<string>} */ (propsMap.get(vueObjectData.object)).has(
|
||
|
name
|
||
|
)
|
||
|
) {
|
||
|
report(node, name)
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
)
|
||
|
}
|
||
|
}
|