@sap/eslint-plugin-cds
Version:
ESLint plugin including recommended SAP Cloud Application Programming model and environment rules
212 lines (188 loc) • 7.29 kB
JavaScript
/*
Use cases not yet covered:
//---------
INLINE EXTENSION
class FooService extends require('@sap/cds').ApplicationService { ... }
//---------
METHOD
class ... {
bad () {}
this.on('', this.bad)
}
//---------
IMPORTED FUNCTION
const { bad } = require('./bad')
class ... {
this.on('', bad)
}
//---------
NON-CLASS-BASED CDS SERVICE
cds.services['myService'].on('READ', 'Books', () => {})
*/
'use strict'
const { RULE_CATEGORIES } = require('../../constants')
const { CdsHandlerRule } = require('./CdsHandlerRule')
/**
* @param {string | undefined} t
*/
function isHandlerType(t) {
// match "import('@sap/cds').CRUDEventHandler.Before" etc.
return ['Before', 'On', 'After'].some(handlerType => t?.match(new RegExp(`import\\s?\\(. \\/cds.\\)\\.CRUDEventHandler.${handlerType}`)))
}
class NoSharedVariable extends CdsHandlerRule {
/**
* Functions that modify variables that are not locally declared.
* They are stored by name.
* Note: this is not fully fail proof, as the functions are stored in a flat fashion,
* rather than maintaining the scope they are declared in.
* This could lead to false positives if a function with the same name is declared in multiple scopes.
* @type {Record<string, Scope>}
*/
#suspiciousFunctions = {}
/**
* nodes of handler registrations like:
* ```js
* this.on('READ', 'Books', handler)
* // ^^^^^^^
* ```
* as they reference the handler by name, its definition may come later in the code
* due to hoisting.
* We check them later in `Program:exit()`.
*/
#pendingInspections = []
/**
* Typedef JSDoc to look up local aliases.
* ```js
* @typedef {Bar} Foo
* ```
* becomes
* ```js
* {Foo: 'Bar'}
* ```
* @type {Record<string, import('estree').Comment & { type: string }>}
*/
#typeDefinitions = {}
/**
* Type JSDoc that matches the estree definition of a Comment,
* plus an additional `type` property that contains the type of the variable.
* Local type aliases are resolved to the actual type using #typeDefinitions.
*
* @type {Array<import('estree').Comment & { type: string }>}
*/
#typeDeclarations = []
#handlerDefinitionDepth = 0
/**
* When we are inside a function that has explicitly been annotated as
* a handler function via a @type JSDoc annotation.
*/
get isInsideExplicitCapHandlerDefinition() {
return this.#handlerDefinitionDepth > 0
}
addCapHandlerRegistration(registration) {
super.addCapHandlerRegistration(registration)
if (registration.handler.type === 'Identifier') {
this.#pendingInspections.push(registration)
}
}
Program() {
const comments = this.context.sourceCode.getAllComments()
this.#typeDefinitions = Object.fromEntries(comments
.map(comment => {
const [, type, name] = comment.value.match(/^\*\s? \s?\{(.*)\}\s?(\w*)/) ?? []
return type && name ? [ name, {...comment, type} ] : null
})
.filter(Boolean))
this.#typeDeclarations = comments
.map(comment => {
const match = comment.value.match(/^\*\s? \s?\{(.*)\}/)?.[1]
if (!match) return null
const type = this.#typeDefinitions[match]?.type ?? match
return type ? { ...comment, type } : null
})
.filter(Boolean)
}
'Program:exit'() {
for (const node of this.#pendingInspections) {
const { scope } = this.#suspiciousFunctions[node.handler.name] ?? {}
if (scope) {
this.context.report({
node: node.handler,
messageId: 'noSharedHandlerVariable',
data: {
definitionScope: scope.name
}
})
}
}
}
#enterFunctionDefinition(node) {
// find a JDoc type comment, that is either on the line before, or in the same line,
// but up to three columns to the left. The latter condition covers three cases:
// 1. TYPEDEFFUNC -- (no space between the comment and start of function) -> distance = 0
// 2. TYPEDEF FUNC-- (one space between the comment and start of function) -> distance = 1
// 3. TYPEDEF(FUNC) -- (no space after typedef, followed by an opening parenthesis) -> distance = 1
// 3. TYPEDEF (FUNC) -- (one space after typedef and an opening parenthesis) -> distance = 2
// Note: this will fail if we have empty lines between the function declaration and the JSDoc.
// Also when users have more spaces or other outlandish formatting styles.
const type = this.#typeDeclarations.find(({loc}) =>
loc.end.line === node.loc.start.line - 1
|| loc.end.line === node.loc.start.line && [0,1,2].includes(node.loc.start.column - loc.end.column)
)
// if the function is explicitly declared as handler, we check it.
// If the function is not explicitly declared as handler, but a surrounding function is (this.handlerDefinitionDepth > 0),
// we check it too.
if (isHandlerType(type?.type) || this.isInsideExplicitCapHandlerDefinition) this.#handlerDefinitionDepth++
}
#exitFunctionDefinition() {
this.#handlerDefinitionDepth = Math.max(0, this.#handlerDefinitionDepth - 1)
}
// () => ...
ArrowFunctionExpression(node) { this.#enterFunctionDefinition(node) }
'ArrowFunctionExpression:exit'() { this.#exitFunctionDefinition() }
// function() { ... }
FunctionExpression(node) { this.#enterFunctionDefinition(node) }
'FunctionExpression:exit'() { this.#exitFunctionDefinition() }
// function f () { ... }
FunctionDeclaration(node) { this.#enterFunctionDefinition(node) }
'FunctionDeclaration:exit'() { this.#exitFunctionDefinition()}
AssignmentExpression(node) {
if (this.isInsideCapHandlerRegistration || this.isInsideExplicitCapHandlerDefinition) {
// like: this.on('READ', 'Books', () => { variable = 42 })
const declaringScope = this.findDefinitionScope(node.left.name)
if (declaringScope?.isLocal === false) {
this.context.report({
node,
messageId: 'noSharedHandlerVariable',
data: {
definitionScope: declaringScope.scope.name
}
})
}
} else if (this.functionScopes.length > 0) {
// not inside a handler registration, but in a function that may be referenced in a handler registration
// like: this.on('READ', 'Books', handler)
// as functions are hoisted and can be referenced before their definition, we just collect the names of suspicious functions
// and check them in Program:exit when we have inspected all functions.
const declaringScope = this.findDefinitionScope(node.left.name)
if (declaringScope?.isLocal === false) {
this.#suspiciousFunctions[this.functionScopes.at(-1).name] = declaringScope
}
}
}
}
module.exports = {
meta: {
type: 'problem',
docs: {
recommended: true,
category: RULE_CATEGORIES.javascript,
description: 'Enforce that variables can not be used to share state between handlers.'
},
schema: [],
messages: {
noSharedHandlerVariable: 'Assignment to a non-local variable inside a CDS event handler (was declared in scope "{{definitionScope}}").'
},
hasSuggestions: true
},
create: context => new NoSharedVariable(context).asESLintVisitor()
}