@sap/eslint-plugin-cds 4.0.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,7 +6,18 @@ This project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  The format is based on [Keep a Changelog](https://keepachangelog.com/).
8
8
 
9
- ## [4.1.0] - 2025-05-27
9
+ ## [4.1.0] - 2025-08-18
10
+
11
+ ### Added
12
+ - Add new rule `case-sensitive-well-known-events` to detect when a well known event is not cased correctly.
13
+
14
+ ### Changed
15
+ - Adjust `no-shared-handler-variables` to also detect shared states when handler refers to a locally defined function, rather than an inline declaration.
16
+ - Rule `use-cql-select-template-strings` now also catches offending template strings in other query parts than just `SELECT`.
17
+ - Rule `no-shared-handler-variables` now also checks functions that are not part of a class extending `cds.ApplicationService`, if the function has an explicit type annotation `@type {import('@sap/cds').CRUDEventHandler.Before}`, `@type {import('@sap/cds').CRUDEventHandler.On}`, or `@type {import('@sap/cds').CRUDEventHandler.After}`.
18
+
19
+ ### Fixed
20
+ - `no-cross-service-import` rule no longer crashes when some file has an unexpected name.
10
21
 
11
22
 
12
23
  ## [4.0.2] - 2025-05-27
@@ -5,4 +5,5 @@ module.exports = {
5
5
  '@sap/cds/use-cql-select-template-strings': 'error',
6
6
  '@sap/cds/no-cross-service-import': 'warn',
7
7
  '@sap/cds/no-deep-sap-cds-import': 'warn',
8
+ '@sap/cds/case-sensitive-well-known-events': 'warn',
8
9
  }
@@ -5,4 +5,5 @@ module.exports = {
5
5
  '@sap/cds/use-cql-select-template-strings': 'error',
6
6
  '@sap/cds/no-cross-service-import': 'warn',
7
7
  '@sap/cds/no-deep-sap-cds-import': 'warn',
8
+ '@sap/cds/case-sensitive-well-known-events': 'warn',
8
9
  }
package/lib/index.js CHANGED
@@ -35,9 +35,12 @@ const plugin = {
35
35
  ...api
36
36
  }
37
37
 
38
- // Assign configs here so we can reference `plugin`
39
- Object.assign(plugin.configs, getConfigs(plugin))
40
-
41
38
  // Use commonJS entry point to ensure backwards compatibility (<eslint@v9):
42
39
  // https://eslint.org/docs/latest/extend/plugin-migration-flat-config#backwards-compatibility
43
- module.exports = plugin
40
+ // spread and mix object to be able to reference plugin in getConfigs
41
+ module.exports = {
42
+ ...plugin,
43
+ ...{
44
+ configs: getConfigs(plugin)
45
+ }
46
+ }
@@ -1,9 +1,9 @@
1
1
  // TODO:
2
2
  // - class extends require('@sap/cds').ApplicationService
3
3
  // - class extends await import('@sap/cds').ApplicationService
4
- /** @typedef {import('@sap/eslint-plugin-cds/lib/rules/js/types').CdsContextTracker.Scope} Scope */
5
- /** @typedef {import('@sap/eslint-plugin-cds/lib/rules/js/types').CdsContextTracker.Variable} Variable */
6
- /** @typedef {import('@sap/eslint-plugin-cds/lib/rules/js/types').CdsContextTracker.VariableType} VariableType */
4
+ /** @typedef {import('./types').CdsContextTracker.Scope} Scope */
5
+ /** @typedef {import('./types').CdsContextTracker.Variable} Variable */
6
+ /** @typedef {import('./types^').CdsContextTracker.VariableType} VariableType */
7
7
 
8
8
  'use strict'
9
9
 
@@ -42,6 +42,7 @@ class CdsHandlerRule {
42
42
  get isInsideCapHandlerRegistration() { return this.capHandlerRegistrationStack.length > 0 }
43
43
 
44
44
  constructor (context) {
45
+ /** @type {import('eslint').Rule.RuleContext} */
45
46
  this.context = context
46
47
  /** @type {Scope[]} */
47
48
  this.functionScopes = [ produceScope('<global>') ]
@@ -89,6 +90,17 @@ class CdsHandlerRule {
89
90
  return info?.variable.isCdsVariable
90
91
  }
91
92
 
93
+ /**
94
+ * @param {ReturnType<typeof produceHandlerRegistration>} registration - the handler registration to add
95
+ */
96
+ addCapHandlerRegistration (registration) {
97
+ this.capHandlerRegistrationStack.push(registration)
98
+ }
99
+
100
+ removeCapHandlerRegistration () {
101
+ this.capHandlerRegistrationStack.pop()
102
+ }
103
+
92
104
  /**
93
105
  * @param {Variable} variable
94
106
  */
@@ -96,6 +108,17 @@ class CdsHandlerRule {
96
108
  this.functionScopes.at(-1).variables.push(variable)
97
109
  }
98
110
 
111
+ /**
112
+ * @param {Scope} scope - the scope to add
113
+ */
114
+ enterFunctionScope (scope) {
115
+ this.functionScopes.push(scope)
116
+ }
117
+
118
+ leaveFunctionScope () {
119
+ this.functionScopes.pop()
120
+ }
121
+
99
122
  /**
100
123
  * @abstract
101
124
  */
@@ -124,7 +147,7 @@ class CdsHandlerRule {
124
147
  // like: this.on('submitOrder', bar)
125
148
  if (['before', 'on', 'after'].some(method => method === property.name)) {
126
149
  const handler = node.arguments.at(-1)
127
- this.capHandlerRegistrationStack.push(produceHandlerRegistration({
150
+ this.addCapHandlerRegistration(produceHandlerRegistration({
128
151
  call: node,
129
152
  handler
130
153
  }))
@@ -136,23 +159,27 @@ class CdsHandlerRule {
136
159
 
137
160
  'CallExpression:exit'(node) {
138
161
  if (this.capHandlerRegistrationStack.at(-1)?.call === node) {
139
- this.capHandlerRegistrationStack.pop()
162
+ this.removeCapHandlerRegistration()
140
163
  }
141
164
  }
142
165
 
143
166
  BlockStatement(node) {
144
167
  if(isFunctionBody(node)) {
145
- this.functionScopes.push(produceScope(
168
+ this.enterFunctionScope(produceScope(
146
169
  node.parent?.key?.name
147
170
  ?? node.parent?.id?.name
148
171
  ?? node.parent?.parent?.key?.name
172
+ // const f = function() { ... }
173
+ ?? (node.parent?.parent?.type === 'VariableDeclarator'
174
+ ? node.parent.parent.id.name
175
+ : undefined)
149
176
  ?? '<anonymous>'))
150
177
  }
151
178
  }
152
179
 
153
180
  'BlockStatement:exit'(node) {
154
181
  if(isFunctionBody(node)) {
155
- this.functionScopes.pop()
182
+ this.leaveFunctionScope()
156
183
  }
157
184
  }
158
185
 
@@ -161,10 +188,10 @@ class CdsHandlerRule {
161
188
  // determine when we are inside the body ArrowFunctionExpression from looking at the body,
162
189
  // as we'd have to add a visitor for every expression type and check if it's a child of an ArrowFunctionExpression.
163
190
  'ArrowFunctionExpression > :not(BlockStatement)'() {
164
- this.functionScopes.push(produceScope('<anonymous>'))
191
+ this.enterFunctionScope(produceScope('<anonymous>'))
165
192
  }
166
193
  'ArrowFunctionExpression > :not(BlockStatement):exit'() {
167
- this.functionScopes.pop()
194
+ this.leaveFunctionScope()
168
195
  }
169
196
 
170
197
  ImportDeclaration(node) {
@@ -0,0 +1,53 @@
1
+ 'use strict'
2
+
3
+ const { RULE_CATEGORIES } = require('../../constants')
4
+ const { CdsHandlerRule } = require('./CdsHandlerRule')
5
+
6
+ const WELL_KNOWN_EVENTS = [
7
+ 'CREATE', 'READ', 'UPDATE', 'UPSERT','DELETE',
8
+ 'INSERT','SELECT',
9
+ 'POST','GET','PUT','PATCH',
10
+ 'NEW', 'CANCEL', 'EDIT', 'SAVE' // draft related
11
+ ]
12
+
13
+ class CaseSensitiveWellKnownEvents extends CdsHandlerRule {
14
+ CAPHandlerRegistration(node) {
15
+ const first = node.parent?.arguments?.[0]
16
+ if (!first) return
17
+ const currentEventName = first.value ?? ''
18
+ const currentEventNameUpper = currentEventName.toUpperCase()
19
+ if (WELL_KNOWN_EVENTS.includes(currentEventNameUpper) && currentEventName !== currentEventNameUpper) {
20
+ const quoteType = ['"', '\'', '`'].includes(first.raw?.[0]) ? first.raw?.[0] : '"'
21
+ this.context.report({
22
+ node: first,
23
+ messageId: 'incorrectEventNameCase',
24
+ data: {
25
+ currentEventName: currentEventName,
26
+ properEventName: currentEventNameUpper
27
+ },
28
+ suggest: [{
29
+ desc: 'Change event casing to upper case',
30
+ fix: fixer => fixer.replaceText(first, `${quoteType}${currentEventNameUpper}${quoteType}`)
31
+ }]
32
+ })
33
+ }
34
+ }
35
+ }
36
+
37
+ module.exports = {
38
+ meta: {
39
+ type: 'problem',
40
+ docs: {
41
+ recommended: true,
42
+ category: RULE_CATEGORIES.javascript,
43
+ description: 'Make sure well-known events are used with proper casing.'
44
+ },
45
+ fixable: 'code',
46
+ schema: [],
47
+ messages: {
48
+ incorrectEventNameCase: 'Found an event registration for event "{{currentEventName}}", which is likely supposed to be "{{properEventName}}".'
49
+ },
50
+ hasSuggestions: true
51
+ },
52
+ create: context => new CaseSensitiveWellKnownEvents(context).asESLintVisitor()
53
+ }
@@ -18,7 +18,7 @@ function compareImportAndFilename (importPath, context, node) {
18
18
  // ignore excessively long strings
19
19
  if (importPath.length > MAX_INPUT_STRING_LENGTH || currentFile.length > MAX_INPUT_STRING_LENGTH) return
20
20
  const [, typerModuleFq, typerModule] = /^#cds-models\/.*?((\w+)Service)$/.exec(importPath) ?? []
21
- const [, fileNameFq, fileName] = /((\w+)-?[sS]ervice\.m?[jt]s)$/.exec(currentFile)
21
+ const [, fileNameFq, fileName] = /((\w+)-?[sS]ervice\.m?[jt]s)$/.exec(currentFile) ?? []
22
22
  // typerModule === undefined -> not a service import (probably db-level-entity import)
23
23
  if (typerModule && fileName && typerModule !== fileName) {
24
24
  context.report({
@@ -5,14 +5,6 @@ Use cases not yet covered:
5
5
  INLINE EXTENSION
6
6
  class FooService extends require('@sap/cds').ApplicationService { ... }
7
7
 
8
- //---------
9
- REFERENCED FUNCTION
10
- function bad() { ... }
11
-
12
- class ... {
13
- this.on('', bad)
14
- }
15
-
16
8
  //---------
17
9
  METHOD
18
10
  class ... {
@@ -39,18 +31,164 @@ cds.services['myService'].on('READ', 'Books', () => {})
39
31
  const { RULE_CATEGORIES } = require('../../constants')
40
32
  const { CdsHandlerRule } = require('./CdsHandlerRule')
41
33
 
34
+ /**
35
+ * @param {string | undefined} t
36
+ */
37
+ function isHandlerType(t) {
38
+ // match "import('@sap/cds').CRUDEventHandler.Before" etc.
39
+ return ['Before', 'On', 'After'].some(handlerType => t?.match(new RegExp(`import\\s?\\(.@sap\\/cds.\\)\\.CRUDEventHandler.${handlerType}`)))
40
+ }
41
+
42
42
  class NoSharedVariable extends CdsHandlerRule {
43
- AssignmentExpression(node) {
44
- if (!this.isInsideCapHandlerRegistration) return
45
- const declaringScope = this.findDefinitionScope(node.left.name)
46
- if (declaringScope?.isLocal === false) {
47
- this.context.report({
48
- node,
49
- messageId: 'noSharedHandlerVariable',
50
- data: {
51
- definitionScope: declaringScope.scope.name
52
- }
43
+ /**
44
+ * Functions that modify variables that are not locally declared.
45
+ * They are stored by name.
46
+ * Note: this is not fully fail proof, as the functions are stored in a flat fashion,
47
+ * rather than maintaining the scope they are declared in.
48
+ * This could lead to false positives if a function with the same name is declared in multiple scopes.
49
+ * @type {Record<string, Scope>}
50
+ */
51
+ #suspiciousFunctions = {}
52
+ /**
53
+ * nodes of handler registrations like:
54
+ * ```js
55
+ * this.on('READ', 'Books', handler)
56
+ * // ^^^^^^^
57
+ * ```
58
+ * as they reference the handler by name, its definition may come later in the code
59
+ * due to hoisting.
60
+ * We check them later in `Program:exit()`.
61
+ */
62
+ #pendingInspections = []
63
+
64
+ /**
65
+ * Typedef JSDoc to look up local aliases.
66
+ * ```js
67
+ * @typedef {Bar} Foo
68
+ * ```
69
+ * becomes
70
+ * ```js
71
+ * {Foo: 'Bar'}
72
+ * ```
73
+ * @type {Record<string, import('estree').Comment & { type: string }>}
74
+ */
75
+ #typeDefinitions = {}
76
+
77
+ /**
78
+ * Type JSDoc that matches the estree definition of a Comment,
79
+ * plus an additional `type` property that contains the type of the variable.
80
+ * Local type aliases are resolved to the actual type using #typeDefinitions.
81
+ *
82
+ * @type {Array<import('estree').Comment & { type: string }>}
83
+ */
84
+ #typeDeclarations = []
85
+
86
+ #handlerDefinitionDepth = 0
87
+
88
+ /**
89
+ * When we are inside a function that has explicitly been annotated as
90
+ * a handler function via a @type JSDoc annotation.
91
+ */
92
+ get isInsideExplicitCapHandlerDefinition() {
93
+ return this.#handlerDefinitionDepth > 0
94
+ }
95
+
96
+ addCapHandlerRegistration(registration) {
97
+ super.addCapHandlerRegistration(registration)
98
+
99
+ if (registration.handler.type === 'Identifier') {
100
+ this.#pendingInspections.push(registration)
101
+ }
102
+ }
103
+
104
+ Program() {
105
+ const comments = this.context.sourceCode.getAllComments()
106
+ this.#typeDefinitions = Object.fromEntries(comments
107
+ .map(comment => {
108
+ const [, type, name] = comment.value.match(/^\*\s?@typedef\s?\{(.*)\}\s?(\w*)/) ?? []
109
+ return type && name ? [ name, {...comment, type} ] : null
110
+ })
111
+ .filter(Boolean))
112
+ this.#typeDeclarations = comments
113
+ .map(comment => {
114
+ const match = comment.value.match(/^\*\s?@type\s?\{(.*)\}/)?.[1]
115
+ if (!match) return null
116
+ const type = this.#typeDefinitions[match]?.type ?? match
117
+ return type ? { ...comment, type } : null
53
118
  })
119
+ .filter(Boolean)
120
+ }
121
+
122
+ 'Program:exit'() {
123
+ for (const node of this.#pendingInspections) {
124
+ const { scope } = this.#suspiciousFunctions[node.handler.name] ?? {}
125
+ if (scope) {
126
+ this.context.report({
127
+ node: node.handler,
128
+ messageId: 'noSharedHandlerVariable',
129
+ data: {
130
+ definitionScope: scope.name
131
+ }
132
+ })
133
+ }
134
+ }
135
+ }
136
+
137
+ #enterFunctionDefinition(node) {
138
+ // find a JDoc type comment, that is either on the line before, or in the same line,
139
+ // but up to three columns to the left. The latter condition covers three cases:
140
+ // 1. TYPEDEFFUNC -- (no space between the comment and start of function) -> distance = 0
141
+ // 2. TYPEDEF FUNC-- (one space between the comment and start of function) -> distance = 1
142
+ // 3. TYPEDEF(FUNC) -- (no space after typedef, followed by an opening parenthesis) -> distance = 1
143
+ // 3. TYPEDEF (FUNC) -- (one space after typedef and an opening parenthesis) -> distance = 2
144
+ // Note: this will fail if we have empty lines between the function declaration and the JSDoc.
145
+ // Also when users have more spaces or other outlandish formatting styles.
146
+ const type = this.#typeDeclarations.find(({loc}) =>
147
+ loc.end.line === node.loc.start.line - 1
148
+ || loc.end.line === node.loc.start.line && [0,1,2].includes(node.loc.start.column - loc.end.column)
149
+ )
150
+ // if the function is explicitly declared as handler, we check it.
151
+ // If the function is not explicitly declared as handler, but a surrounding function is (this.handlerDefinitionDepth > 0),
152
+ // we check it too.
153
+ if (isHandlerType(type?.type) || this.isInsideExplicitCapHandlerDefinition) this.#handlerDefinitionDepth++
154
+ }
155
+
156
+ #exitFunctionDefinition() {
157
+ this.#handlerDefinitionDepth = Math.max(0, this.#handlerDefinitionDepth - 1)
158
+ }
159
+
160
+ // () => ...
161
+ ArrowFunctionExpression(node) { this.#enterFunctionDefinition(node) }
162
+ 'ArrowFunctionExpression:exit'() { this.#exitFunctionDefinition() }
163
+ // function() { ... }
164
+ FunctionExpression(node) { this.#enterFunctionDefinition(node) }
165
+ 'FunctionExpression:exit'() { this.#exitFunctionDefinition() }
166
+ // function f () { ... }
167
+ FunctionDeclaration(node) { this.#enterFunctionDefinition(node) }
168
+ 'FunctionDeclaration:exit'() { this.#exitFunctionDefinition()}
169
+
170
+ AssignmentExpression(node) {
171
+ if (this.isInsideCapHandlerRegistration || this.isInsideExplicitCapHandlerDefinition) {
172
+ // like: this.on('READ', 'Books', () => { variable = 42 })
173
+ const declaringScope = this.findDefinitionScope(node.left.name)
174
+ if (declaringScope?.isLocal === false) {
175
+ this.context.report({
176
+ node,
177
+ messageId: 'noSharedHandlerVariable',
178
+ data: {
179
+ definitionScope: declaringScope.scope.name
180
+ }
181
+ })
182
+ }
183
+ } else if (this.functionScopes.length > 0) {
184
+ // not inside a handler registration, but in a function that may be referenced in a handler registration
185
+ // like: this.on('READ', 'Books', handler)
186
+ // as functions are hoisted and can be referenced before their definition, we just collect the names of suspicious functions
187
+ // and check them in Program:exit when we have inspected all functions.
188
+ const declaringScope = this.findDefinitionScope(node.left.name)
189
+ if (declaringScope?.isLocal === false) {
190
+ this.#suspiciousFunctions[this.functionScopes.at(-1).name] = declaringScope
191
+ }
54
192
  }
55
193
  }
56
194
  }
@@ -3,19 +3,48 @@
3
3
  const { RULE_CATEGORIES } = require('../../constants')
4
4
  const { CdsHandlerRule } = require('./CdsHandlerRule')
5
5
 
6
+ const isCqlClauseStart = node => ['SELECT', 'UPDATE', 'INSERT', 'DELETE', 'UPSERT'].includes(node.name)
7
+
8
+ /**
9
+ * ESLint goes through member functions in reverse order:
10
+ * x.y.z -> z, y, x
11
+ * so we can not track when we
12
+ * enter a CQL clause, but instead have to check the chain of ancestors for each member
13
+ * recursively. If we find the topmost function call is a CQL clause (SELECT, UPDATE, etc.),
14
+ * none of the member functions in the chain are allowed to use untagged template strings.
15
+ */
16
+ const isInCqlClause = node => {
17
+ if (!node) return false
18
+ if (node.type === 'CallExpression' && isCqlClauseStart(node.callee)) return true
19
+ if (node.type === 'TaggedTemplateExpression' && isCqlClauseStart(node.tag)) return true
20
+ // f(...) and f`...` have slightly different structure, the former has .callee, the latter has .tag
21
+ // -> use the first that is available to ascend through the call chain
22
+ return isInCqlClause(node.callee?.object ?? node.tag?.object)
23
+ }
24
+
6
25
  class CqlSelectUseTemplateStrings extends CdsHandlerRule {
7
26
  CallExpression(node) {
8
27
  super.CallExpression(node)
9
- if (node.callee?.name === 'SELECT' && node.arguments[0].type === 'TemplateLiteral') {
10
- this.context.report({
11
- node,
12
- message: 'Do not use SELECT(`...`), which is prone to SQL injections.',
13
- suggest: [{
14
- desc: 'Use SELECT`...` instead',
15
- fix: fixer => fixer.replaceText(node, `SELECT${this.context.getSourceCode().getText(node.arguments[0])}`)
16
- }]
17
- })
18
- }
28
+ const [arg] = node.arguments ?? []
29
+ if (arg?.type !== 'TemplateLiteral') return
30
+ if (arg.expressions.length === 0) return // no expressions in the template string, so no SQL injection risk
31
+ if (!isInCqlClause(node)) return
32
+
33
+ const [functionName, prefix] = node.callee.type === 'MemberExpression'
34
+ // for ….where`...` we need to use the full preceding expression in the following replacement
35
+ ? [node.callee.property?.name, this.context.getSourceCode().getText(node.callee)]
36
+ // for SELECT`...` we can use the function name directly
37
+ : [node.callee.name, node.callee.name]
38
+ this.context.report({
39
+ node,
40
+ message: 'Do not use {{functionName}}(`...`) inside CQL statements, which is prone to SQL injections.',
41
+ data: { functionName },
42
+ suggest: [{
43
+ desc: 'Use {{functionName}}`...` instead of {{functionName}}(`...`)',
44
+ data: { functionName },
45
+ fix: fixer => fixer.replaceText(node, `${prefix}${this.context.getSourceCode().getText(arg)}`)
46
+ }]
47
+ })
19
48
  }
20
49
  }
21
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/eslint-plugin-cds",
3
- "version": "4.0.2",
3
+ "version": "4.1.0",
4
4
  "description": "ESLint plugin including recommended SAP Cloud Application Programming model and environment rules",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [