UNPKG

@sap/eslint-plugin-cds

Version:

ESLint plugin including recommended SAP Cloud Application Programming model and environment rules

212 lines (188 loc) 7.29 kB
/* 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?\\(.@sap\\/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?@typedef\s?\{(.*)\}\s?(\w*)/) ?? [] return type && name ? [ name, {...comment, type} ] : null }) .filter(Boolean)) this.#typeDeclarations = comments .map(comment => { const match = comment.value.match(/^\*\s?@type\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() }