@sap/eslint-plugin-cds 4.0.2 → 4.1.1

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,22 @@ 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.1] - 2025-12-04
10
+ ### Added
11
+ - Top level type definitions for package export
12
+
13
+
14
+ ## [4.1.0] - 2025-08-18
15
+ ### Added
16
+ - Add new rule `case-sensitive-well-known-events` to detect when a well known event is not cased correctly.
17
+
18
+ ### Changed
19
+ - Adjust `no-shared-handler-variables` to also detect shared states when handler refers to a locally defined function, rather than an inline declaration.
20
+ - Rule `use-cql-select-template-strings` now also catches offending template strings in other query parts than just `SELECT`.
21
+ - 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}`.
22
+
23
+ ### Fixed
24
+ - `no-cross-service-import` rule no longer crashes when some file has an unexpected name.
10
25
 
11
26
 
12
27
  ## [4.0.2] - 2025-05-27
package/lib/conf/index.js CHANGED
@@ -4,6 +4,18 @@ const path = require('node:path')
4
4
  const { FILES, GLOBALS } = require('../constants')
5
5
  const { parserPath } = require('../api')
6
6
 
7
+ function _createJavaConfig (plugin, configName) {
8
+ return {
9
+ name: '@sap/cds/java',
10
+ plugins: {
11
+ '@sap/cds': plugin,
12
+ },
13
+ files: ['**/*.java'],
14
+ language: '@sap/cds/java',
15
+ rules: require(path.join(__dirname, 'java', configName)),
16
+ }
17
+ }
18
+
7
19
  function _createJsConfig (plugin, configName) {
8
20
  return {
9
21
  name: '@sap/cds/js',
@@ -43,6 +55,11 @@ module.exports = function (plugin) {
43
55
  js: {
44
56
  all: _createJsConfig(plugin, 'all'),
45
57
  recommended: _createJsConfig(plugin, 'recommended')
58
+ },
59
+ java: {
60
+ all: _createJavaConfig(plugin, 'all'),
61
+ recommended: _createJavaConfig(plugin, 'recommended'),
62
+ experimental: _createJavaConfig(plugin, 'experimental')
46
63
  }
47
64
  }
48
65
  }
@@ -0,0 +1,5 @@
1
+ 'use strict'
2
+
3
+ module.exports = {
4
+ '@sap/cds/cql-class-targets': 'warn',
5
+ }
@@ -0,0 +1,5 @@
1
+ 'use strict'
2
+
3
+ module.exports = {
4
+ '@sap/cds/cql-class-targets': 'warn',
5
+ }
@@ -0,0 +1,3 @@
1
+ 'use strict'
2
+
3
+ module.exports = {}
@@ -2,7 +2,8 @@
2
2
 
3
3
  module.exports = {
4
4
  '@sap/cds/no-shared-handler-variable': 'error',
5
- '@sap/cds/use-cql-select-template-strings': 'error',
5
+ '@sap/cds/cql-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
  }
@@ -2,7 +2,8 @@
2
2
 
3
3
  module.exports = {
4
4
  '@sap/cds/no-shared-handler-variable': 'error',
5
- '@sap/cds/use-cql-select-template-strings': 'error',
5
+ '@sap/cds/cql-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/constants.js CHANGED
@@ -21,6 +21,7 @@ const RULE_CATEGORIES = {
21
21
  javascript: 'JavaScript Validation',
22
22
  environment: 'Environment Validation',
23
23
  csv: 'CSV Validation',
24
+ java: 'Java Validation'
24
25
  }
25
26
  const DEFAULT_RULE_CATEGORY = 'Model Validation'
26
27
  const DEFAULT_RULE_FLAVOR = RULE_FLAVORS[0]
package/lib/index.js CHANGED
@@ -18,26 +18,31 @@
18
18
 
19
19
  const api = require('./api')
20
20
  const getConfigs = require('./conf')
21
- const rules = Object.assign(
22
- {},
23
- ...Object.entries(require('./rules')).map(([k, v]) => ({ [k]: v() }))
24
- )
25
-
21
+ const { allRules, initialiseRules } = require('./rules')
22
+ const { javaLanguage } = require('./languages/java/java-language')
26
23
  const packageJson = require('../package.json')
27
24
 
25
+ const rules = initialiseRules(allRules)
26
+
28
27
  const plugin = {
29
28
  meta: {
30
29
  name: packageJson.name,
31
30
  version: packageJson.version
32
31
  },
32
+ languages: {
33
+ java: javaLanguage
34
+ },
33
35
  configs: {},
34
36
  rules,
35
37
  ...api
36
38
  }
37
39
 
38
- // Assign configs here so we can reference `plugin`
39
- Object.assign(plugin.configs, getConfigs(plugin))
40
-
41
40
  // Use commonJS entry point to ensure backwards compatibility (<eslint@v9):
42
41
  // https://eslint.org/docs/latest/extend/plugin-migration-flat-config#backwards-compatibility
43
- module.exports = plugin
42
+ // spread and mix object to be able to reference plugin in getConfigs
43
+ module.exports = {
44
+ ...plugin,
45
+ ...{
46
+ configs: getConfigs(plugin)
47
+ }
48
+ }
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+
3
+ const TreeSitterParser = require('tree-sitter')
4
+ const Java = require('tree-sitter-java')
5
+ const { JavaSourceCode } = require('./java-source-code')
6
+
7
+ /**
8
+ * @typedef {object} JavaLanguageOptions
9
+ * @typedef {{languageOptions: JavaLanguageOptions}} JavaLanguageContext
10
+ */
11
+
12
+ const jp = new TreeSitterParser()
13
+ jp.setLanguage(Java)
14
+
15
+ /**
16
+ * @param {import('@eslint/core').File} file
17
+ * @param {JavaLanguageContext} context
18
+ */
19
+ function parseJavaCode (file /*, context*/) {
20
+ const code = file.body
21
+ const treeSitterAst = jp.parse(code)
22
+
23
+ if (!treeSitterAst) {
24
+ return { ast: null, ok: false }
25
+ }
26
+ if (!treeSitterAst.rootNode) {
27
+ return { ast: treeSitterAst, ok: false }
28
+ }
29
+ return { ast: treeSitterAst, ok: true }
30
+ }
31
+
32
+ /**
33
+ * @param {import('@eslint/core').File} file
34
+ * @param {ReturnType<typeof parseJavaCode>} parseResult
35
+ * @param {JavaLanguageContext} context
36
+ */
37
+ function createJavaSourceCode(file, parseResult /*, context*/) {
38
+ if (!parseResult.ok || !parseResult.ast) {
39
+ throw new Error('createJavaSourceCode: parseResult is not ok or has no AST', {
40
+ ok: parseResult.ok,
41
+ hasAst: !!parseResult.ast
42
+ })
43
+ }
44
+ return new JavaSourceCode({ text: file.body, ast: parseResult.ast })
45
+ }
46
+
47
+ /**
48
+ * @param {JavaLanguageOptions} languageOptions
49
+ */
50
+ function validateJavaLanguageOptions(/*languageOptions*/) {
51
+ // No specific options to validate for Java currently
52
+ }
53
+
54
+ /**
55
+ * @link https://eslint.org/docs/latest/extend/languages#the-language-object
56
+ */
57
+ const javaLanguage = /** @type {const}*/({
58
+ fileType: 'text',
59
+ lineStart: 0,
60
+ columnStart: 0,
61
+ nodeTypeKey: 'type',
62
+ validateLanguageOptions: validateJavaLanguageOptions,
63
+ parse: parseJavaCode,
64
+ createSourceCode: createJavaSourceCode
65
+ })
66
+
67
+ module.exports = {
68
+ javaLanguage,
69
+ }
@@ -0,0 +1,179 @@
1
+ 'use strict'
2
+
3
+ // not 100% accurate, as it matches _any_ comment containing 'eslint-disable' etc.
4
+ // but in Java sources, users will hopefully only mention eslint-* when they
5
+ // actually mean to configure ESLint.
6
+ const INLINE_CONFIG = /eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|$)/u
7
+
8
+ /**
9
+ * @link see https://github.com/eslint/json/blob/main/src/languages/json-source-code.js
10
+ */
11
+ const {
12
+ VisitNodeStep,
13
+ TextSourceCodeBase,
14
+ Directive
15
+ } = require('@eslint/plugin-kit')
16
+
17
+ /** @typedef {'disable' | 'enable' | 'disable-next-line' | 'disable-line'} DirectiveType */
18
+
19
+
20
+ /**
21
+ * @param {import('tree-sitter').SyntaxNode} comment
22
+ * @returns {{
23
+ * label: 'eslint-disable' | 'eslint-enable' | 'eslint-disable-next-line' | 'eslint-disable-line',
24
+ * value: string,
25
+ * justification: string | undefined
26
+ * }}
27
+ */
28
+ function parseDirective(comment) {
29
+ // label, value, justification
30
+ // * but before the "--" that indicates the justification.
31
+ const [, label, value, justification] = comment.text.match(/^\W*(eslint[\w-]*) (\S+)(?: -- (.+))?/)
32
+ return { label, value, justification}
33
+ }
34
+
35
+ class JavaTraversalStep extends VisitNodeStep {
36
+ /** @param {{ target: unknown, phase: 1|2, args: Array<any> }} arg0 */
37
+ constructor({ target, phase, args }) {
38
+ super({ target, phase, args })
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {import('tree-sitter').SyntaxNode} node
44
+ * @param {(node: import('tree-sitter').SyntaxNode) => void} enterAction
45
+ * @param {undefined | (node: import('tree-sitter').SyntaxNode) => void} exitAction
46
+ */
47
+ function traverse(node, enterAction, exitAction) {
48
+ enterAction?.(node)
49
+ //steps.push(new JavaTraversalStep({ target: node, phase: 1, args: [] }))
50
+ for (const child of node.children) {
51
+ traverse(child, enterAction, exitAction)
52
+ }
53
+ exitAction?.(node)
54
+ }
55
+
56
+
57
+ /** @param {import('tree-sitter').SyntaxNode} node */
58
+ const getAncestors = node => !node
59
+ ? []
60
+ : [...getAncestors(node.parent), node]
61
+
62
+ /**
63
+ * @link https://eslint.org/docs/latest/extend/languages#the-sourcecode-object
64
+ */
65
+ class JavaSourceCode extends TextSourceCodeBase {
66
+ /** @type {undefined | import('tree-sitter').SyntaxNode[]} */
67
+ #comments
68
+ /** @type {undefined | import('tree-sitter').SyntaxNode[]} */
69
+ #inlineConfigComments
70
+
71
+ get comments() {
72
+ if (!this.#comments) {
73
+ this.#comments = []
74
+ traverse(this.ast.rootNode, node => {
75
+ if (['line_comment', 'block_comment'].includes(node.type)) {
76
+ this.#comments.push(node)
77
+ }
78
+ })
79
+ }
80
+ return this.#comments
81
+ }
82
+
83
+ get inlineConfigNodes() {
84
+ if (!this.#inlineConfigComments) {
85
+ this.#inlineConfigComments = this
86
+ .comments
87
+ .filter(comment =>
88
+ INLINE_CONFIG.test(comment.text),
89
+ ) ?? []
90
+ }
91
+
92
+ return this.#inlineConfigComments
93
+ }
94
+
95
+ get lines () { return this.text.split(/\r\n|\r|\n/) }
96
+
97
+ /** @param {import('tree-sitter').SyntaxNode} node */
98
+ getLoc(node) {
99
+ return {
100
+ start: { line: node.startPosition.row, column: node.startPosition.column },
101
+ end: { line: node.endPosition.row, column: node.endPosition.column }
102
+ }
103
+ }
104
+
105
+ /** @param {import('tree-sitter').SyntaxNode} node */
106
+ getRange(node) {
107
+ return [node.startIndex, node.endIndex]
108
+ }
109
+
110
+ /** @param {import('tree-sitter').SyntaxNode} node */
111
+ getParent(node) {
112
+ return node.parent ?? undefined // .parent is null if root, we need undefined
113
+ }
114
+
115
+ /** @param {import('tree-sitter').SyntaxNode} node */
116
+ getAncestors(node) {
117
+ // ESLint wants ancestors excluding the node itself
118
+ return getAncestors(node.parent)
119
+ }
120
+
121
+ /** @param {import('tree-sitter').SyntaxNode} node */
122
+ getText(node) {
123
+ return node.text
124
+ }
125
+
126
+ getDisableDirectives() {
127
+ /** @type {FileProblem[]} */
128
+ const problems = []
129
+ /** @type {Directive[]} */
130
+ const directives = []
131
+
132
+ for (const comment of this.inlineConfigNodes) {
133
+ const { label, value, justification } = parseDirective(comment)
134
+
135
+ // `eslint-disable-line` directives are not allowed to span multiple lines as it would be confusing to which lines they apply
136
+ if (label === 'eslint-disable-line' && comment.loc.start.line !== comment.loc.end.line) {
137
+ problems.push({
138
+ ruleId: null,
139
+ message: `${label} comment should not span multiple lines.`,
140
+ loc: comment.loc,
141
+ })
142
+ } else if (['eslint-disable', 'eslint-enable', 'eslint-disable-next-line', 'eslint-disable-line'].includes(label)) {
143
+ directives.push(
144
+ new Directive({
145
+ type: /** @type {DirectiveType} */ (label.slice('eslint-'.length)),
146
+ node: comment,
147
+ value,
148
+ justification,
149
+ }),
150
+ )
151
+ }
152
+ }
153
+ return { problems, directives }
154
+ }
155
+
156
+ traverse() {
157
+ const steps = []
158
+ if (!this.ast || !this.ast.rootNode) {
159
+ return steps
160
+ }
161
+ traverse(this.ast.rootNode, node => {
162
+ steps.push(new JavaTraversalStep({ target: node, phase: 1 }))
163
+ }, node => {
164
+ steps.push(new JavaTraversalStep({ target: node, phase: 2 }))
165
+ })
166
+ return steps
167
+ }
168
+
169
+ /** @param {{text: string, ast: import('tree-sitter').Tree}} param0 */
170
+ constructor({text, ast}) {
171
+ super({text, ast})
172
+ this.text = text
173
+ this.ast = ast
174
+ }
175
+ }
176
+
177
+ module.exports = {
178
+ JavaSourceCode
179
+ }
@@ -17,10 +17,28 @@ const readRulesFromDir = (dir, post) => Object.fromEntries(fs.readdirSync(path.j
17
17
  .filter(([,module]) => Object.hasOwn(module, 'create')) // create() is required to exist on top level by eslint -> good check to find actual rules
18
18
  .map(([file, module]) => [file, () => post(module)]))
19
19
 
20
+ /**
21
+ * Calls the initialisation function for each rule in the given rule set.
22
+ * @param {Record<string, () => unknown>} ruleSet
23
+ * @returns {Record<string, unknown>}
24
+ */
25
+ const initialiseRules = ruleSet => Object.fromEntries(
26
+ Object.entries(ruleSet)
27
+ .map(([k, v]) => [[k], v()]))
28
+
20
29
  const cdsRules = readRulesFromDir('.', createRule)
21
30
  const jsRules = readRulesFromDir('js', module => module)
22
- const rules = {...cdsRules, ...jsRules}
31
+ // backwards compat, remove in next minor
32
+ jsRules['use-cql-select-template-strings'] = jsRules['cql-template-strings']
33
+ const javaRules = readRulesFromDir('java', module => module)
34
+ const allRules = {...cdsRules, ...jsRules, ...javaRules}
23
35
 
24
- globalCache.set('rules', rules)
36
+ globalCache.set('rules', allRules)
25
37
 
26
- module.exports = rules
38
+ module.exports = {
39
+ allRules,
40
+ jsRules,
41
+ javaRules,
42
+ cdsRules,
43
+ initialiseRules
44
+ }
@@ -0,0 +1,59 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * In Select.from(x), x should be a class literal.
5
+ */
6
+
7
+ const { RULE_CATEGORIES } = require('../../constants')
8
+
9
+ module.exports = {
10
+ meta: {
11
+ type: 'problem',
12
+ docs: {
13
+ recommended: true,
14
+ category: RULE_CATEGORIES.java,
15
+ description: 'java.'
16
+ },
17
+ fixable: 'code',
18
+ schema: [],
19
+ messages: {
20
+ selectOnNonClass: `'{{target}}' is not a class. Prefer to pass class literals to Select.from!`,
21
+ },
22
+ hasSuggestions: true
23
+ },
24
+ create: context => {
25
+ const knownClasses = new Set()
26
+
27
+ /** @param {Node} node */
28
+ const refersToClass = node =>
29
+ node.type === 'class_literal'
30
+ // or part of explicit named imports
31
+ || knownClasses.has(node.text)
32
+ // or first letter of last part of text is uppercase: a.b.C -> true
33
+ // https://unicode.org/reports/tr18/#General_Category_Property
34
+ || /^\p{Lu}/u.test(node.text.split('.').at(-1))
35
+
36
+ return {
37
+ 'import_declaration > scoped_identifier': function(node) {
38
+ if (!node) return
39
+ if (node.nameNode) {
40
+ knownClasses.add(node.nameNode.text)
41
+ }
42
+ },
43
+
44
+ "method_invocation > identifier[text='from']": function(node) {
45
+ if (!node?.parent?.argumentsNode) return
46
+ const args = node.parent.argumentsNode.children
47
+ .filter(c => c.constructor.name !== 'SyntaxNode') // filter out commas, parens, etc.
48
+ ?? []
49
+ if (!refersToClass(args[0])) {
50
+ context.report({
51
+ node: args[0],
52
+ messageId: 'selectOnNonClass',
53
+ data: { target: args[0].text }
54
+ })
55
+ }
56
+ },
57
+ }
58
+ }
59
+ }
@@ -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
+ }
@@ -0,0 +1,64 @@
1
+ 'use strict'
2
+
3
+ const { RULE_CATEGORIES } = require('../../constants')
4
+ const { CdsHandlerRule } = require('./CdsHandlerRule')
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
+
25
+ class CqlSelectUseTemplateStrings extends CdsHandlerRule {
26
+ CallExpression(node) {
27
+ super.CallExpression(node)
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
+ })
48
+ }
49
+ }
50
+
51
+ module.exports = {
52
+ meta: {
53
+ type: 'problem',
54
+ docs: {
55
+ recommended: true,
56
+ category: RULE_CATEGORIES.javascript,
57
+ description: 'Discourage use of SELECT(...), which allows SQL injections, in favour of SELECT`...`.'
58
+ },
59
+ fixable: 'code',
60
+ schema: [],
61
+ hasSuggestions: true
62
+ },
63
+ create: context => new CqlSelectUseTemplateStrings(context).asESLintVisitor()
64
+ }
@@ -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
  }
@@ -9,7 +9,7 @@ const { RuleTester } = require('eslint')
9
9
  const { globalCache } = require('./Cache')
10
10
  const isConfiguredFileType = require('./isConfiguredFileType')
11
11
  const { compileModelFromDict } = require('../parser')
12
- const rules = require('../rules')
12
+ const { allRules: rules } = require('../rules')
13
13
 
14
14
  /**
15
15
  * A wrapper around the return value of `createRule()` that initializes the global
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.1",
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": [
@@ -20,7 +20,10 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "semver": "^7.7.1"
23
+ "@eslint/plugin-kit": "^0.4.1",
24
+ "semver": "^7.7.1",
25
+ "tree-sitter": "^0.21.1",
26
+ "tree-sitter-java": "^0.23.5"
24
27
  },
25
28
  "peerDependencies": {
26
29
  "@sap/cds": ">=9",
@@ -1,35 +0,0 @@
1
- 'use strict'
2
-
3
- const { RULE_CATEGORIES } = require('../../constants')
4
- const { CdsHandlerRule } = require('./CdsHandlerRule')
5
-
6
- class CqlSelectUseTemplateStrings extends CdsHandlerRule {
7
- CallExpression(node) {
8
- 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
- }
19
- }
20
- }
21
-
22
- module.exports = {
23
- meta: {
24
- type: 'problem',
25
- docs: {
26
- recommended: true,
27
- category: RULE_CATEGORIES.javascript,
28
- description: 'Discourage use of SELECT(...), which allows SQL injections, in favour of SELECT`...`.'
29
- },
30
- fixable: 'code',
31
- schema: [],
32
- hasSuggestions: true
33
- },
34
- create: context => new CqlSelectUseTemplateStrings(context).asESLintVisitor()
35
- }