@sap/eslint-plugin-cds 3.0.5 → 3.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/lib/api/index.js +4 -4
  4. package/lib/conf/all.js +17 -17
  5. package/lib/conf/experimental.js +12 -0
  6. package/lib/conf/index.js +12 -3
  7. package/lib/conf/recommended.js +13 -14
  8. package/lib/constants.js +2 -0
  9. package/lib/index.js +2 -1
  10. package/lib/parser.js +10 -1
  11. package/lib/rules/assoc2many-ambiguous-key.js +40 -11
  12. package/lib/rules/auth-no-empty-restrictions.js +38 -10
  13. package/lib/rules/auth-restrict-grant-service.js +29 -29
  14. package/lib/rules/auth-use-requires.js +27 -15
  15. package/lib/rules/auth-valid-restrict-grant.js +138 -82
  16. package/lib/rules/auth-valid-restrict-keys.js +34 -18
  17. package/lib/rules/auth-valid-restrict-to.js +57 -106
  18. package/lib/rules/auth-valid-restrict-where.js +44 -43
  19. package/lib/rules/extension-restrictions.js +11 -3
  20. package/lib/rules/index.js +5 -1
  21. package/lib/rules/latest-cds-version.js +5 -4
  22. package/lib/rules/no-db-keywords.js +14 -5
  23. package/lib/rules/no-dollar-prefixed-names.js +9 -2
  24. package/lib/rules/no-java-keywords.js +181 -0
  25. package/lib/rules/no-join-on-draft.js +9 -3
  26. package/lib/rules/sql-cast-suggestion.js +19 -15
  27. package/lib/rules/sql-null-comparison.js +60 -0
  28. package/lib/rules/start-elements-lowercase.js +6 -2
  29. package/lib/rules/start-entities-uppercase.js +12 -5
  30. package/lib/rules/valid-csv-header.js +33 -13
  31. package/lib/types.d.ts +4 -4
  32. package/lib/utils/Cache.js +4 -2
  33. package/lib/utils/Colors.js +2 -0
  34. package/lib/utils/LintError.js +17 -0
  35. package/lib/utils/createRule.js +160 -134
  36. package/lib/utils/csnTraversal.js +163 -0
  37. package/lib/utils/findFuzzy.js +21 -12
  38. package/lib/utils/getConfigPath.js +4 -2
  39. package/lib/utils/getConfiguredFileTypes.js +2 -0
  40. package/lib/utils/getFileExtensions.js +2 -0
  41. package/lib/utils/getProjectRootPath.js +53 -15
  42. package/lib/utils/isConfiguredFileType.js +8 -3
  43. package/lib/utils/rules.js +15 -9
  44. package/lib/utils/runRuleTester.js +69 -36
  45. package/package.json +1 -1
  46. package/lib/utils/genDocs.js +0 -346
@@ -1,15 +1,23 @@
1
+ 'use strict'
2
+
3
+ const cache = new Map()
4
+
1
5
  /**
2
6
  * Levenshtein distance algorithm using recursive calls and cache
3
7
  *
4
8
  * @param input search the list for a best match for this string
5
9
  * @param list a list of strings to match input against
6
- * @param log logging method to use, might be null if no logging is wanted
7
- * @returns array with best matches, is never null but might be empty in case no search was possible
10
+ * @param {Function} [log] logging method to use, might be null if no logging is wanted
11
+ * @param {boolean} [keepCase]
12
+ * @param {Number} [threshold]
13
+ * @returns {string[]} array with best matches, is never null but might be empty in case no search was possible
14
+ *
15
+ * @todo: Describe in which range the threshold should be.
8
16
  */
17
+ module.exports = function findFuzzy(input, list, log = null, keepCase = false, threshold = Number.MAX_SAFE_INTEGER) {
18
+ if (typeof input !== 'string')
19
+ return []
9
20
 
10
- const cache = {}
11
-
12
- module.exports = (input, list, log, keepCase = false, threshold = Number.MAX_SAFE_INTEGER) => {
13
21
  let minDistWords = []
14
22
 
15
23
  if (input.length > 50 || list.length > 50) {
@@ -56,10 +64,10 @@ module.exports = (input, list, log, keepCase = false, threshold = Number.MAX_SAF
56
64
  return minDistWords.sort()
57
65
  }
58
66
 
59
- const levDistance = (a, b) => {
60
- if (cache[a] && cache[a][b]) {
61
- return cache[a][b]
62
- }
67
+ function levDistance(a, b) {
68
+ const cachedObj = cache.get(a)?.get(b)
69
+ if (cachedObj)
70
+ return cachedObj
63
71
 
64
72
  if (a.length === 0) {
65
73
  return addToCache(a, b, b.length)
@@ -84,8 +92,9 @@ const levDistance = (a, b) => {
84
92
  return addToCache(a, b, levDist)
85
93
  }
86
94
 
87
- const addToCache = (a, b, value) => {
88
- cache[a] = cache[a] || {}
89
- cache[a][b] = value
95
+ function addToCache(a, b, value) {
96
+ if (!cache.has(a))
97
+ cache.set(a, new Map())
98
+ cache.get(a).set(b, value)
90
99
  return value
91
100
  }
@@ -1,3 +1,5 @@
1
+ 'use strict'
2
+
1
3
  /**
2
4
  * Searches for ESLint config file types (in order or precedence)
3
5
  * and returns corresponding directory (usually project's root dir)
@@ -6,8 +8,8 @@
6
8
  * @returns {string} dir containing ESLint config file (empty if not exists)
7
9
  */
8
10
 
9
- const fs = require('fs')
10
- const path = require('path')
11
+ const fs = require('node:fs')
12
+ const path = require('node:path')
11
13
 
12
14
  module.exports = (currentDir = '.', legacy=false) => {
13
15
  let configFiles = [
@@ -1,3 +1,5 @@
1
+ 'use strict'
2
+
1
3
  const { FILES } = require('../constants')
2
4
 
3
5
  /**
@@ -1,3 +1,5 @@
1
+ 'use strict'
2
+
1
3
  const constants = require('../constants')
2
4
 
3
5
  /**
@@ -1,25 +1,63 @@
1
+ 'use strict'
2
+
3
+ const path = require('node:path')
4
+ const cds = require('@sap/cds')
5
+ const Cache = require('./Cache')
6
+ const fs = require('node:fs')
7
+
8
+ const commonCapProjectFiles = ['build.gradle', '.git', 'srv', 'db', 'app']
9
+
1
10
  /**
2
11
  * Searches for directory containing cds roots
12
+ *
13
+ * As of today, there is no unified way to find the root directory for a CDS project.
14
+ * ("The root is wherever the user typed `cds init`")
15
+ * We are therefore trying to resolve that path heuristically by ascending through the
16
+ * directory structure, looking for certain files.
17
+ *
18
+ * FIXME: Revisit and use a unified way once available, i.e. from @sap/cds
19
+ *
3
20
  * @param {string} currentDir start here and search until root dir
4
21
  * @returns {string} dir containing cds roots (empty if not exists)
5
22
  */
6
-
7
- const path = require('path')
8
- const cds = require('@sap/cds')
9
- const Cache = require('./Cache')
10
-
11
- module.exports = (currentDir = '.') => {
23
+ module.exports = function getProjectRootPath(currentDir = '.') {
12
24
  let dir = path.resolve(currentDir)
13
- while (dir !== path.resolve(dir, '..')) {
14
- // @ts-ignore
15
- cds.resolve.cache = {}
16
- // @ts-ignore
17
- const roots = cds.resolve('*', { root: dir })
18
- if (roots && roots.length > 0) {
19
- Cache.set(`roots:${dir}`, roots)
20
- return dir
21
- }
25
+
26
+ while (!couldBeProjectRoot(dir)) {
27
+ if (dir === path.resolve(dir, '..'))
28
+ return '' // we reached the file system root -> abort
22
29
  dir = path.join(dir, '..')
23
30
  }
31
+
32
+ cds.resolve.cache = {}
33
+ const roots = cds.resolve('*', { root: dir })
34
+ if (roots?.length > 0) {
35
+ Cache.set(`roots:${dir}`, roots)
36
+ return dir
37
+ }
24
38
  return ''
25
39
  }
40
+
41
+ /**
42
+ * Checks whether the given directory could be a CDS project root.
43
+ *
44
+ * @param {string} dir
45
+ * @returns {boolean}
46
+ */
47
+ function couldBeProjectRoot(dir) {
48
+ return isRootPackageJson(path.join(dir, 'package.json')) ||
49
+ commonCapProjectFiles.some(entry => fs.existsSync(path.join(dir, entry)))
50
+ }
51
+
52
+ function isRootPackageJson(filepath) {
53
+ const filename = path.basename(filepath)
54
+ if (filename !== 'package.json')
55
+ return false
56
+
57
+ try {
58
+ const config = JSON.parse(fs.readFileSync(filepath, 'utf8'))
59
+ return Object.keys(config?.dependencies ?? {}).some(dep => dep === '@sap/cds')
60
+ } catch {
61
+ return false
62
+ }
63
+ }
@@ -1,3 +1,5 @@
1
+ 'use strict'
2
+
1
3
  const { FILES, MODEL_FILES } = require('../constants')
2
4
 
3
5
  const regexByFileType = {
@@ -8,13 +10,16 @@ const regexByFileType = {
8
10
  /**
9
11
  * Checks whether the given filePath matches a regex `files`
10
12
  * @param {string} filePath
13
+ * @param {string} fileType
11
14
  * @returns boolean
12
15
  */
13
16
  module.exports = (filePath, fileType) => {
14
- const isValid = regexByFileType[fileType].test(filePath)
15
- return isValid
17
+ return regexByFileType[fileType].test(filePath)
16
18
  }
17
19
 
20
+ /**
21
+ * @param arr
22
+ */
18
23
  function globArrayToRegex (arr) {
19
- return new RegExp(`${arr.map((e) => e.replace('*', '')).join('$|')}$`)
24
+ return new RegExp(`${arr.map(e => e.replace('*', '')).join('$|')}$`)
20
25
  }
@@ -1,3 +1,5 @@
1
+ 'use strict'
2
+
1
3
  const SEP = '[,;\t]'
2
4
  const EOL = '\\r?\\n'
3
5
 
@@ -9,7 +11,7 @@ module.exports = {
9
11
  *
10
12
  * @param {*} e
11
13
  */
12
- splitDefName: function (e) {
14
+ splitDefName(e) {
13
15
  // Entity names from CSN are of the form:
14
16
  // <namespace>.<service>.<def>.<'texts'|'localized'>|<composition value>
15
17
  let prefix = ''
@@ -22,7 +24,7 @@ module.exports = {
22
24
  // Managed composition get compiler tag `_up`
23
25
  let isManagedComposition = false
24
26
  if (e.elements) {
25
- isManagedComposition = Object.keys(e.elements).some((k) => k === 'up_')
27
+ isManagedComposition = Object.keys(e.elements).some(k => k === 'up_')
26
28
  }
27
29
  // Check for compiler tags
28
30
  const compilerTagsToExclude = ['texts', 'localized']
@@ -51,11 +53,15 @@ module.exports = {
51
53
  return code.indexOf(miss)
52
54
  },
53
55
 
54
- isEmptyString: function (value) {
55
- if (typeof value !== 'string' || (typeof value === 'string' && value && value.length > 0)) {
56
- return false
57
- }
58
- return true
56
+ /**
57
+ * Returns true if the given string is non-empty. The string is trimmed, i.e. leading/trailing
58
+ * whitespace is removed prior to checking.
59
+ *
60
+ * @param {string} value
61
+ * @returns {boolean}
62
+ */
63
+ isEmptyString(value) {
64
+ return value?.trim() === ''
59
65
  },
60
66
 
61
67
  isEmptyObject: function (value) {
@@ -65,7 +71,7 @@ module.exports = {
65
71
  }
66
72
  return true
67
73
  }
68
- if (typeof value !== 'object' || (typeof value === 'object' && !isEmpty(value)) ||
74
+ if (!value || typeof value !== 'object' || (typeof value === 'object' && !isEmpty(value)) ||
69
75
  (typeof value === 'object' && value && value.length > 0)) {
70
76
  return false
71
77
  }
@@ -96,7 +102,7 @@ module.exports = {
96
102
  const suggest = {
97
103
  messageId: 'ReplaceItemWith',
98
104
  data: { invalid, candidates },
99
- fix: (fixer) => fixer.replaceTextRange([startIndex, startIndex + invalid.length] + 1, candidates)
105
+ fix: fixer => fixer.replaceTextRange([startIndex, startIndex + invalid.length] + 1, candidates)
100
106
  }
101
107
  return ({
102
108
  messageId: 'InvalidItem',
@@ -1,11 +1,40 @@
1
- const fs = require('fs')
2
- const path = require('path')
1
+ 'use strict'
2
+
3
+ /** @typedef {import('eslint').Rule.RuleModule} RuleModule */
4
+
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
3
7
 
4
8
  const { Linter, RuleTester } = require('eslint')
5
9
  const Cache = require('./Cache')
6
- const createRule = require('./createRule')
7
10
  const isConfiguredFileType = require('./isConfiguredFileType')
8
11
  const { compileModelFromDict } = require('../parser')
12
+ const rules = require('../rules')
13
+
14
+ /**
15
+ * A wrapper around the return value of `createRule()` that initializes the global
16
+ * cache only when the rule is actually executed. This allows tests to be run
17
+ * with test runners that don't set up a new environment for each test, such as
18
+ * mocha or the Node test runner.
19
+ *
20
+ * @param {RuleModule} rule
21
+ * @returns {RuleModule}
22
+ */
23
+ function testRuleWrapper(rule) {
24
+ return { ...rule, create: prepareAndRunRule }
25
+ function prepareAndRunRule(context) {
26
+ return {
27
+ Program: node => {
28
+ const filePath = context.getFilename()
29
+ _initModelRuleTester(filePath, rule.meta.model)
30
+ const createValue = rule.create(context)
31
+ const result = createValue.Program(node)
32
+ Cache.clear()
33
+ return result
34
+ }
35
+ }
36
+ }
37
+ }
9
38
 
10
39
  /**
11
40
  * ESLint RuleTester (used by custom rule creator api)
@@ -13,51 +42,50 @@ const { compileModelFromDict } = require('../parser')
13
42
  * valid/invalid checks:
14
43
  * Model checks require input 'code' entries
15
44
  * Env checks require input 'options' with selected parameters
45
+ *
16
46
  * @param { CDSRuleTestOpts } options RuleTester input options
17
- * @returns RuleTester results
18
47
  */
19
- module.exports = (options) => {
48
+ module.exports = function runRuleTester(options) {
49
+ const pluginRootPath = path.resolve(__dirname, '../..')
20
50
  let parserPath
21
51
  let rule = {}
22
- Cache.set('rules', require(path.join(__dirname, '../rules')))
23
52
  const rulename = path.basename(options.root)
24
- if (options.root.startsWith(path.resolve(__dirname, '../..'))) {
53
+ if (options.root.startsWith(pluginRootPath)) {
25
54
  // For plugin's internal tests, resolve parser from here
26
55
  parserPath = require.resolve('../parser')
27
- const pluginPath = path.join(path.dirname(options.root), '../..')
28
- rule = createRule(require(`../rules/${path.basename(options.root)}`))
29
- Cache.set('pluginpath', pluginPath)
56
+ rule = testRuleWrapper(rules[path.basename(options.root)]())
30
57
  } else {
31
58
  // Otherwise from project root
59
+ // eslint-disable-next-line
32
60
  const resolvedPlugin = require.resolve('@sap/eslint-plugin-cds', {
33
61
  paths: [options.root]
34
62
  })
35
63
  parserPath = path.join(path.dirname(resolvedPlugin), 'parser')
36
- rule = require(path.join(options.root, `../../rules/${path.basename(options.root)}`))
37
- const pluginPath = path.join(path.dirname(options.root), '../../..')
38
- Cache.set('pluginpath', pluginPath)
64
+ rule = testRuleWrapper(require(path.join(options.root, `../../rules/${path.basename(options.root)}`)))
39
65
  }
40
- let tester = new RuleTester({})
66
+
67
+ let tester
41
68
  if (parserPath) {
42
- let options
43
- if (Number(Linter.version.split('.')[0]) >= 9) {
44
- options = { languageOptions: { parser: require(parserPath) } }
45
- } else {
46
- options = { parser: parserPath }
47
- }
69
+ const options = _isEslint9OrLater()
70
+ ? { languageOptions: { parser: require(parserPath) } }
71
+ : { parser: parserPath }
48
72
  tester = new RuleTester(options)
73
+ } else {
74
+ tester = new RuleTester()
49
75
  }
50
76
 
51
77
  const testerCases = {};
52
- ['valid', 'invalid'].forEach((type) => {
78
+ ['valid', 'invalid'].forEach(type => {
53
79
  const filePath = path.join(options.root, `${type}/${options.filename}`)
54
- Cache.set('rootpath', path.dirname(filePath))
55
- _initModelRuleTester(filePath, rule.meta?.model)
56
80
  testerCases[type] = [
57
81
  {
58
- filename: filePath
82
+ filename: filePath,
59
83
  }
60
84
  ]
85
+ if (_isEslint9OrLater()) {
86
+ // property not supported for ESLint 8
87
+ testerCases[type][0].name = `${path.basename(options.root)}/${type}/${options.filename}`
88
+ }
61
89
  if (!isConfiguredFileType(options.filename, 'FILES')) {
62
90
  const fileContents = JSON.parse(fs.readFileSync(filePath, 'utf8'))
63
91
  testerCases[type][0].code = ''
@@ -77,22 +105,22 @@ module.exports = (options) => {
77
105
  }
78
106
  }
79
107
  })
80
- if (Cache.get('testerCases')) {
81
- Cache.set(`testerCases:${rulename}`, testerCases)
82
- }
83
108
  return tester.run(rulename, rule, testerCases)
84
109
  }
85
110
 
86
111
  /**
87
112
  * Creates a model for ESLint unit tests
113
+ * @param {string} filePath
114
+ * @param {string} flavor
88
115
  */
89
116
  function _initModelRuleTester(filePath, flavor) {
117
+ Cache.set('rules', rules)
90
118
  Cache.set('test', true)
91
119
  const rootPath = path.dirname(filePath)
92
120
  Cache.set('rootpath', rootPath)
93
121
  if (flavor !== 'none') { // not for env rules
94
122
  const files = fs.readdirSync(rootPath)
95
- const modelfiles = files.map((f) => path.join(rootPath, f)).filter((fp) => isConfiguredFileType(fp, 'MODEL_FILES'))
123
+ const modelfiles = files.map(f => path.join(rootPath, f)).filter(fp => isConfiguredFileType(fp, 'MODEL_FILES'))
96
124
  Cache.set(`modelfiles:${rootPath}`, modelfiles)
97
125
  const dictFiles = _getDictFiles(rootPath, modelfiles)
98
126
  Cache.set(`dictfiles:${rootPath}`, dictFiles)
@@ -102,18 +130,19 @@ function _initModelRuleTester(filePath, flavor) {
102
130
  }
103
131
 
104
132
  /**
105
- * Creates or updates a dictionary of files/file contents for a given
106
- * project path.
107
- * @param input
108
- * @param files
109
- * @returns dictFiles
110
- */
111
- function _getDictFiles(input, files) {
133
+ * Creates or updates a dictionary of files/file contents for a given
134
+ * project path.
135
+ *
136
+ * @param {string} input
137
+ * @param {string[]} filenames
138
+ * @returns {Record<string, string>} dictFiles
139
+ */
140
+ function _getDictFiles(input, filenames) {
112
141
  let dictFiles = {}
113
142
  if (Cache.has(`dictfiles:${input}`)) {
114
143
  dictFiles = Cache.get(`dictfiles:${input}`)
115
144
  } else {
116
- files.forEach((file) => {
145
+ filenames.forEach(file => {
117
146
  dictFiles[file] = Cache.has(`file:${file}`)
118
147
  ? Cache.get(`file:${file}`)
119
148
  : fs.readFileSync(file, 'utf8')
@@ -121,3 +150,7 @@ function _getDictFiles(input, files) {
121
150
  }
122
151
  return dictFiles
123
152
  }
153
+
154
+ function _isEslint9OrLater() {
155
+ return Number(Linter.version.split('.')[0]) >= 9
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/eslint-plugin-cds",
3
- "version": "3.0.5",
3
+ "version": "3.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": [