@sap/eslint-plugin-cds 3.2.0 → 4.0.2

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.
@@ -0,0 +1,56 @@
1
+ 'use strict'
2
+ const allowList = new Set([
3
+ // exception: not part of the facade and should be available to users this way
4
+ '@sap/cds/eslint.config.mjs'
5
+ ])
6
+
7
+ /**
8
+ * @param {string} importPath
9
+ * @param {RuleContext} context
10
+ * @param {Node} node
11
+ */
12
+ function checkImport (importPath, context, node) {
13
+ if (!importPath.startsWith('@sap/cds/')) return
14
+ if (allowList.has(importPath)) return
15
+ context.report({
16
+ node,
17
+ messageId: 'noDeepSapCdsImports',
18
+ data: { import: importPath }
19
+ })
20
+ }
21
+
22
+ module.exports = {
23
+ meta: {
24
+ type: 'problem',
25
+ docs: {
26
+ recommended: true,
27
+ category: 'JavaScript Validation',
28
+ description: 'Warn about deep imports from @sap/cds.'
29
+ },
30
+ schema: [],
31
+ messages: {
32
+ noDeepSapCdsImports: `"{{import}}" is a deep import. The API of @sap/cds is available only from its facade via 'require("@sap/cds")' or 'import ... from "@sap/cds"'.`,
33
+ },
34
+ hasSuggestions: false
35
+ },
36
+ create: context => ({
37
+ CallExpression(node) {
38
+ // look for: require('@sap/cds/...')
39
+ if (node.callee.type !== 'Identifier') return
40
+ if (node.callee.name !== 'require') return
41
+ if (node.arguments.length !== 1) return
42
+ if (node.arguments[0].type !== 'Literal') return
43
+ checkImport(node.arguments[0].value, context, node)
44
+ },
45
+
46
+ ImportDeclaration(node) {
47
+ // import ... from '@sap/cds/...'
48
+ checkImport(node.source.value, context, node)
49
+ },
50
+
51
+ ImportExpression(node) {
52
+ // await import('@sap/cds/...')
53
+ checkImport(node.source.value, context, node)
54
+ }
55
+ })
56
+ }
@@ -0,0 +1,73 @@
1
+ /*
2
+ Use cases not yet covered:
3
+
4
+ //---------
5
+ INLINE EXTENSION
6
+ class FooService extends require('@sap/cds').ApplicationService { ... }
7
+
8
+ //---------
9
+ REFERENCED FUNCTION
10
+ function bad() { ... }
11
+
12
+ class ... {
13
+ this.on('', bad)
14
+ }
15
+
16
+ //---------
17
+ METHOD
18
+ class ... {
19
+ bad () {}
20
+
21
+ this.on('', this.bad)
22
+ }
23
+
24
+ //---------
25
+ IMPORTED FUNCTION
26
+ const { bad } = require('./bad')
27
+
28
+ class ... {
29
+ this.on('', bad)
30
+ }
31
+
32
+ //---------
33
+ NON-CLASS-BASED CDS SERVICE
34
+ cds.services['myService'].on('READ', 'Books', () => {})
35
+ */
36
+
37
+ 'use strict'
38
+
39
+ const { RULE_CATEGORIES } = require('../../constants')
40
+ const { CdsHandlerRule } = require('./CdsHandlerRule')
41
+
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
+ }
53
+ })
54
+ }
55
+ }
56
+ }
57
+
58
+ module.exports = {
59
+ meta: {
60
+ type: 'problem',
61
+ docs: {
62
+ recommended: true,
63
+ category: RULE_CATEGORIES.javascript,
64
+ description: 'Enforce that variables can not be used to share state between handlers.'
65
+ },
66
+ schema: [],
67
+ messages: {
68
+ noSharedHandlerVariable: 'Assignment to a non-local variable inside a CDS event handler (was declared in scope "{{definitionScope}}").'
69
+ },
70
+ hasSuggestions: true
71
+ },
72
+ create: context => new NoSharedVariable(context).asESLintVisitor()
73
+ }
@@ -0,0 +1,15 @@
1
+ export declare namespace CdsContextTracker {
2
+ type VariableType = 'let' | 'const' | 'var' | 'import'
3
+
4
+ type Variable = {
5
+ name: string,
6
+ original: string
7
+ type: VariableType,
8
+ isCdsVariable: boolean
9
+ }
10
+
11
+ type Scope = {
12
+ name: string,
13
+ variables: Variable[]
14
+ }
15
+ }
@@ -0,0 +1,35 @@
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
+ }
@@ -2,11 +2,13 @@
2
2
 
3
3
  const cp = require('child_process')
4
4
  const semver = require('semver')
5
+ const { RULE_CATEGORIES } = require('../constants')
5
6
 
6
7
  module.exports = {
7
8
  meta: {
8
9
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
9
10
  docs: {
11
+ category: RULE_CATEGORIES.environment,
10
12
  description: 'Checks whether the latest `@sap/cds` version is being used.',
11
13
  },
12
14
  type: 'suggestion',
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const cds = require('@sap/cds')
4
+ const { RULE_CATEGORIES } = require('../constants')
4
5
 
5
6
  // REVISIT: Replace by compiler-provided check
6
7
  const RESERVED = cds.compile.to.sql.sqlite
@@ -11,6 +12,7 @@ module.exports = {
11
12
  meta: {
12
13
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
13
14
  docs: {
15
+ category: RULE_CATEGORIES.model,
14
16
  description: 'Avoid using reserved SQL keywords.',
15
17
  url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/no-db-keywords',
16
18
  },
@@ -1,9 +1,45 @@
1
1
  'use strict'
2
2
 
3
+ const { RULE_CATEGORIES } = require('../constants')
4
+
5
+ /**
6
+ * Util to check if an entity is part of an external service.
7
+ */
8
+ class ExternalServices {
9
+ hasExternalServices = false
10
+ externalServices = Object.create(null)
11
+
12
+ static create(model) {
13
+ return new ExternalServices(model)
14
+ }
15
+
16
+ constructor(model) {
17
+ for (const defName in model.definitions) {
18
+ const def = model.definitions[defName]
19
+ if (def?.kind === 'service' && def['@cds.external']) {
20
+ this.externalServices[defName] = true
21
+ this.hasExternalServices = true
22
+ }
23
+ }
24
+ }
25
+
26
+ isInExternalService(defName) {
27
+ if (!this.hasExternalServices)
28
+ return false // shortcut
29
+ const segments = defName.split('.')
30
+ for (let i = segments.length - 1; i >= 0; i--)
31
+ if (this.externalServices[segments.slice(0, i).join('.')])
32
+ return true
33
+ return false
34
+ }
35
+
36
+ }
37
+
3
38
  module.exports = {
4
39
  meta: {
5
40
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
6
41
  docs: {
42
+ category: RULE_CATEGORIES.model,
7
43
  description: 'Names must not start with $ to avoid possible shadowing of reserved variables.',
8
44
  recommended: true,
9
45
  url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/no-dollar-prefixed-names',
@@ -13,17 +49,39 @@ module.exports = {
13
49
  },
14
50
  type: 'problem'
15
51
  },
16
- create (context) {
17
- return { element: _check }
18
52
 
19
- function _check (d) {
20
- const srv = d._service || (d.parent && d.parent._service)
21
- if (srv && srv['@cds.external']) return
22
- if (d.name.startsWith('$')) {
53
+ create(context) {
54
+ const model = context.getModel()
55
+ if (!model?.definitions)
56
+ return
57
+
58
+ const externals = ExternalServices.create(model)
59
+
60
+ return function checkAllElementsForDollarPrefix() {
61
+ for (const defName in model.definitions) {
62
+ if (!externals.isInExternalService(defName))
63
+ checkElements(defName, model.definitions[defName])
64
+ }
65
+ }
66
+
67
+ function checkElements(defName, def) {
68
+ if (!Object.hasOwn(def,'elements') || !def.elements || typeof def.elements !== 'object')
69
+ return
70
+
71
+ for (const elementName in def.elements) {
72
+ const element = def.elements[elementName]
73
+ check(elementName, element)
74
+ if (element.elements)
75
+ checkElements(elementName, element)
76
+ }
77
+ }
78
+
79
+ function check(name, def) {
80
+ if (name.startsWith('$')) {
23
81
  context.report({
24
82
  messageId: 'dollarPrefix',
25
- data: { name: d.name },
26
- node: context.getNode(d)
83
+ data: { name },
84
+ loc: context.getLocation(name, def, model),
27
85
  })
28
86
  }
29
87
  }
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const { RULE_CATEGORIES } = require('../constants')
3
4
  // Check that Java keywords are not used as identifiers unless they have
4
5
  // a Java-specific annotation that renames/ignores them. This avoids issues
5
6
  // later on in code-generation of CAP Java classes.
@@ -26,6 +27,7 @@ module.exports = {
26
27
  meta: {
27
28
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
28
29
  docs: {
30
+ category: RULE_CATEGORIES.model,
29
31
  description: 'Reject reserved Java keywords as CDS identifiers.',
30
32
  url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/no-java-keywords',
31
33
  },
@@ -1,9 +1,12 @@
1
1
  'use strict'
2
2
 
3
+ const { RULE_CATEGORIES } = require('../constants')
4
+
3
5
  module.exports = {
4
6
  meta: {
5
7
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
6
8
  docs: {
9
+ category: RULE_CATEGORIES.model,
7
10
  description: 'Draft-enabled entities shall not be used in views that make use of `JOIN`.',
8
11
  recommended: true,
9
12
  url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/no-join-on-draft',
@@ -1,40 +1,64 @@
1
1
  'use strict'
2
2
 
3
+ const { RULE_CATEGORIES } = require('../constants')
4
+
3
5
  module.exports = {
4
6
  meta: {
5
7
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
6
8
  docs: {
9
+ category: RULE_CATEGORIES.model,
7
10
  description: 'Should make suggestions for possible missing SQL casts.',
8
11
  recommended: true,
9
12
  url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/sql-cast-suggestion',
10
13
  },
11
14
  type: 'suggestion',
12
15
  messages: {
13
- missingSQLCast: 'Potential issue - Missing SQL cast for column expression?'
14
- }
16
+ missingSqlCast: 'Potential issue - Missing SQL cast for column expression?'
17
+ },
18
+ model: 'parsed',
15
19
  },
16
20
  create: function (context) {
17
- return { view: checkSqlCast }
21
+ const model = context.getModel()
22
+ if (!model?.definitions)
23
+ return
24
+
25
+ return function checkAllElementsStartWithLowercase() {
26
+ for (const defName in model.definitions) {
27
+ const def = model.definitions[defName]
28
+ checkSqlCastsInView(defName, def)
29
+ }
30
+ }
18
31
 
19
- function checkSqlCast (v) {
32
+ function checkSqlCastsInView(defName, def) {
20
33
  // TODO: restructure and make more robust (#507)
21
- if (v?.query?.SET?.args) {
22
- for (const arg of v.query.SET.args) {
23
- if (arg?.SELECT) {
24
- // Only in UNION cases?
25
- for (const each of arg.SELECT.columns || []) {
26
- if (each) {
27
- const { xpr, cast } = each
28
- if (cast && xpr) {
29
- if (!(xpr[0]?.xpr && xpr[0]?.cast)) {
30
- context.report({
31
- messageId: 'missingSQLCast',
32
- node: context.getNode(v)
33
- })
34
- }
35
- }
36
- }
37
- }
34
+ if (!def?.query?.SET?.args?.length)
35
+ return
36
+
37
+ for (const arg of def.query.SET.args) {
38
+ if (arg?.SELECT?.columns?.length) {
39
+ // Only in UNION cases?
40
+ for (const col of arg.SELECT.columns) {
41
+ if (col)
42
+ checkColumn(col)
43
+ }
44
+ }
45
+ }
46
+
47
+ function checkColumn(col) {
48
+ const { xpr, cast } = col
49
+ if (cast && xpr) {
50
+ if (!(xpr[0]?.xpr && xpr[0]?.cast)) {
51
+ // we don't pass a name for the column's location, as it would be used to calculate
52
+ // endColumn, which is not correct for this expression
53
+ const loc = col.$location ?
54
+ context.getLocation('', col, model) :
55
+ context.getLocation(defName, def, model)
56
+
57
+ context.report({
58
+ messageId: 'missingSqlCast',
59
+ loc,
60
+ file: def.$location.file,
61
+ })
38
62
  }
39
63
  }
40
64
  }
@@ -11,8 +11,7 @@ module.exports = {
11
11
  description: 'Ensure SQL comparisons with \'null\' are valid',
12
12
  category: 'Model Validation',
13
13
  recommended: false,
14
- // TODO: Add documentation
15
- // url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/sql-null-comparison',
14
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/sql-null-comparison',
16
15
  },
17
16
  type: 'problem',
18
17
  model: 'parsed',
@@ -21,40 +20,45 @@ module.exports = {
21
20
  }
22
21
  },
23
22
  create(context) {
24
- return {
25
- view(v) {
26
- forEachXprInDefinition(v, checkExpression)
27
-
28
- function checkExpression(xpr, ctx) {
29
- if (!xpr || !Array.isArray(xpr))
30
- return
31
-
32
- for (let i = 0; i < xpr.length; i++) {
33
- if (typeof xpr[i] !== 'object')
34
- continue // scalar value, etc.
35
-
36
- if (xpr[i]?.val === null) {
37
- const prev = i > 0 && typeof xpr[i-1] === 'string' ? xpr[i-1] : null
38
- if (prev && invalidComparisonOperators.includes(prev)) {
39
- reportComparison(xpr, ctx)
40
- continue
41
- }
42
- const next = i+1 < xpr.length && typeof xpr[i+1] === 'string' ? xpr[i+1] : null
43
- if (next && invalidComparisonOperators.includes(next))
44
- reportComparison(xpr, ctx)
45
- }
46
- }
47
- }
23
+ const model = context.getModel()
24
+ if (!model?.definitions)
25
+ return
48
26
 
49
- function reportComparison(xpr, ctx) {
50
- context.report({
51
- messageId: 'nullComparison',
52
- loc: context.getLocation(null, ctx),
53
- file: ctx.$location?.file,
54
- })
55
- }
27
+ return function checkSqlNullComparisonsInModel() {
28
+ for (const defName in model.definitions) {
29
+ const def = model.definitions[defName]
30
+ if (def.query || def.projection)
31
+ forEachXprInDefinition(def, checkExpression)
32
+ }
33
+ }
34
+
35
+ function checkExpression(xpr, ctx) {
36
+ if (!xpr || !Array.isArray(xpr))
37
+ return
56
38
 
39
+ for (let i = 0; i < xpr.length; i++) {
40
+ if (typeof xpr[i] !== 'object')
41
+ continue // scalar value, etc.
42
+
43
+ if (xpr[i]?.val === null) {
44
+ const prev = i > 0 && typeof xpr[i-1] === 'string' ? xpr[i-1] : null
45
+ if (prev && invalidComparisonOperators.includes(prev)) {
46
+ reportComparison(xpr, ctx)
47
+ continue
48
+ }
49
+ const next = i+1 < xpr.length && typeof xpr[i+1] === 'string' ? xpr[i+1] : null
50
+ if (next && invalidComparisonOperators.includes(next))
51
+ reportComparison(xpr, ctx)
52
+ }
57
53
  }
58
54
  }
55
+
56
+ function reportComparison(xpr, ctx) {
57
+ context.report({
58
+ messageId: 'nullComparison',
59
+ loc: context.getLocation('', ctx),
60
+ file: ctx.$location?.file,
61
+ })
62
+ }
59
63
  }
60
64
  }
@@ -1,11 +1,14 @@
1
1
  'use strict'
2
2
 
3
+ const { RULE_CATEGORIES } = require('../constants')
4
+
3
5
  const allowedUpperCaseElements = ['ID']
4
6
 
5
7
  module.exports = {
6
8
  meta: {
7
9
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
8
10
  docs: {
11
+ category: RULE_CATEGORIES.model,
9
12
  description: 'Regular element names should start with lowercase letters.',
10
13
  url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/start-elements-lowercase',
11
14
  },
@@ -1,11 +1,13 @@
1
1
  'use strict'
2
2
 
3
+ const { RULE_CATEGORIES } = require('../constants')
3
4
  const { splitDefName } = require('../utils/rules')
4
5
 
5
6
  module.exports = {
6
7
  meta: {
7
8
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
8
9
  docs: {
10
+ category: RULE_CATEGORIES.model,
9
11
  description: 'Regular entity names should start with uppercase letters.',
10
12
  url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/start-entities-uppercase',
11
13
  },
@@ -3,6 +3,7 @@
3
3
  const cds = require('@sap/cds')
4
4
  const { basename, extname } = require('node:path')
5
5
  const findFuzzy = require('../utils/findFuzzy')
6
+ const { RULE_CATEGORIES } = require('../constants')
6
7
  const SEP = '[,;\t]'
7
8
  const EOL = '\\r?\\n'
8
9
 
@@ -11,7 +12,7 @@ module.exports = {
11
12
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
12
13
  docs: {
13
14
  description: 'CSV files for entities must refer to valid element names.',
14
- category: 'Model Validation',
15
+ category: RULE_CATEGORIES.csv,
15
16
  recommended: true,
16
17
  url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/valid-csv-header',
17
18
  },
@@ -127,7 +127,6 @@ function setMetaDefaults (meta) {
127
127
  meta ??= {}
128
128
  meta.severity ??= constants.DEFAULT_RULE_SEVERITY
129
129
  meta.docs ??= {}
130
- meta.docs.category ??= constants.DEFAULT_RULE_CATEGORY
131
130
  meta.model ??= 'parsed'
132
131
  return meta
133
132
  }
@@ -184,8 +183,10 @@ function createReport (node, cdsContext, meta, create) {
184
183
 
185
184
  function sanitizeFileLocation (d) {
186
185
  let parent = d
187
- while (!parent.$location && parent.parent && !parent.parent.definitions) parent = d.parent
188
- if (parent.$location) d.$location = parent.$location
186
+ while (!parent.$location && parent.parent && !parent.parent.definitions)
187
+ parent = d.parent
188
+ if (parent.$location)
189
+ d.$location = parent.$location
189
190
  return d
190
191
  }
191
192
 
@@ -213,8 +214,11 @@ function extendContext (node, context, meta) {
213
214
 
214
215
  const cdscontext = Object.create(Object.getPrototypeOf(context), descriptors)
215
216
  const { parserServices } = context.sourceCode || context
216
- cdscontext.getModel =
217
- meta.model === 'inferred' ? parserServices.getInferredCsn : parserServices.getParsedCsn
217
+
218
+ cdscontext.getModel = meta.model === 'inferred'
219
+ ? parserServices.getInferredCsn
220
+ : parserServices.getParsedCsn
221
+
218
222
  cdscontext.getEnvironment = () => {
219
223
  const options = context.options
220
224
  return options && options[0] && options[0].environment ? options[0].environment : undefined
@@ -11,23 +11,12 @@
11
11
  const fs = require('node:fs')
12
12
  const path = require('node:path')
13
13
 
14
- module.exports = (currentDir = '.', legacy=false) => {
14
+ module.exports = (currentDir = '.') => {
15
15
  let configFiles = [
16
16
  'eslint.config.js',
17
17
  'eslint.config.cjs',
18
18
  'eslint.config.mjs'
19
19
  ]
20
- if (legacy) {
21
- configFiles = [
22
- '.eslintrc.js',
23
- '.eslintrc.cjs',
24
- '.eslintrc.yaml',
25
- '.eslintrc.yml',
26
- '.eslintrc.json',
27
- '.eslintrc',
28
- 'package.json',
29
- ]
30
- }
31
20
  let configDir = path.resolve(currentDir)
32
21
  while (configDir !== path.resolve(configDir, '..')) {
33
22
  for (const configFile of configFiles) {
@@ -8,23 +8,24 @@ const findFuzzy = require('./findFuzzy')
8
8
  module.exports = {
9
9
  findFuzzy,
10
10
  /**
11
- *
12
- * @param {*} e
11
+ * @param {object} definition CSN definition object.
12
+ * @param {string} [name] The definition's name. Inferred for "linked CSN".
13
13
  */
14
- splitDefName(e) {
14
+ splitDefName(definition, name = definition.name) {
15
+ if (!name)
16
+ return null
15
17
  // Entity names from CSN are of the form:
16
18
  // <namespace>.<service>.<def>.<'texts'|'localized'>|<composition value>
17
19
  let prefix = ''
18
20
  let suffix = ''
19
- let defName = e.name
20
- const names = defName.split('.')
21
- defName = names[names.length - 1]
21
+ const names = name.split('.')
22
+ let defName = names[names.length - 1]
22
23
 
23
24
  if (defName) {
24
25
  // Managed composition get compiler tag `_up`
25
26
  let isManagedComposition = false
26
- if (e.elements) {
27
- isManagedComposition = Object.keys(e.elements).some(k => k === 'up_')
27
+ if (definition.elements) {
28
+ isManagedComposition = Object.keys(definition.elements).some(k => k === 'up_')
28
29
  }
29
30
  // Check for compiler tags
30
31
  const compilerTagsToExclude = ['texts', 'localized']
@@ -34,7 +35,7 @@ module.exports = {
34
35
  suffix = names[names.length - 1]
35
36
  defName = names[names.length - 2]
36
37
  }
37
- prefix = e.name.split(`.${defName}`)[0]
38
+ prefix = name.split(`.${defName}`)[0]
38
39
  }
39
40
  return { prefix, name: defName, suffix }
40
41
  },