@sap/eslint-plugin-cds 3.1.1 → 3.2.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
@@ -2,16 +2,35 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- This project adheres to [Semantic Versioning](http://semver.org/).
5
+ This project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ The format is based on [Keep a Changelog](https://keepachangelog.com/).
8
+
9
+ ## [3.2.0] - 2025-03-03
10
+
11
+ ### Changed
12
+
13
+ - Rules `@sap/cds/sql-null-comparison` and `@sap/cds/no-java-keywords` are moved from the `experimental` rule set to `all`.
14
+
15
+ ### Fixed
16
+
17
+ - Rules `@sap/cds/sql-null-comparison` will not warn about `!= null`, as it may be supported by future CDS compiler versions.
18
+ - Some rules had `docs` meta property `recommended: true`, but were not part of the recommended rules list.
19
+ - When determining a CDS project's root directory, we now consider package.json's with `@sap/cds` as `devDependency` or `peerDependency`
20
+
21
+ ## [3.1.2] - 2024-10-31
22
+
23
+ ### Fixed
24
+
25
+ - run inferred rules correctly on Microsoft Windows
6
26
 
7
- The format is based on [Keep a Changelog](http://keepachangelog.com/).
8
27
 
9
28
  ## [3.1.1] - 2024-10-08
10
29
 
11
30
  ### Changed
12
31
 
13
32
  - `no-db-keywords` is no longer part of the 'recommended' rules,
14
- as the cds-compiler takes care of quoting SQL keywords, if they are used as identifiers.
33
+ as the cds-compiler takes care of quoting SQL keywords, if they are used as identifiers.
15
34
 
16
35
  ### Fixed
17
36
 
package/lib/conf/all.js CHANGED
@@ -18,4 +18,6 @@ module.exports = {
18
18
  '@sap/cds/start-entities-uppercase': 'error',
19
19
  '@sap/cds/valid-csv-header': 'error',
20
20
  '@sap/cds/extension-restrictions': 'error',
21
+ '@sap/cds/sql-null-comparison': 'warn',
22
+ '@sap/cds/no-java-keywords': 'warn',
21
23
  }
@@ -7,6 +7,5 @@
7
7
  // to "all" or even "recommended".
8
8
 
9
9
  module.exports = {
10
- '@sap/cds/sql-null-comparison': 'warn',
11
- '@sap/cds/no-java-keywords': 'error',
10
+ // '@sap/cds/my-rule': 'warn',
12
11
  }
package/lib/index.js CHANGED
@@ -16,8 +16,6 @@
16
16
  * - Expose any 'rules' for use in ESLint
17
17
  */
18
18
 
19
- const path = require('node:path')
20
-
21
19
  const api = require('./api')
22
20
  const getConfigs = require('./conf')
23
21
  const rules = Object.assign(
@@ -25,7 +23,7 @@ const rules = Object.assign(
25
23
  ...Object.entries(require('./rules')).map(([k, v]) => ({ [k]: v() }))
26
24
  )
27
25
 
28
- const packageJson = require(path.join(__dirname, '../package.json'))
26
+ const packageJson = require('../package.json')
29
27
 
30
28
  const plugin = {
31
29
  meta: {
package/lib/parser.js CHANGED
@@ -11,53 +11,65 @@
11
11
  * (parserOptions).
12
12
  */
13
13
  const cds = require('@sap/cds')
14
- const Cache = require('./utils/Cache')
14
+ const { globalCache } = require('./utils/Cache')
15
15
  const LOG = cds.debug('lint:plugin')
16
16
  const colors = require('./utils/Colors')
17
17
  const { splitDefName } = require('./utils/rules')
18
+ const packageJson = require('../package.json')
19
+
20
+ const newLineRegEx = /\r\n?|\n/g
18
21
 
19
22
  module.exports = {
20
- parse: function (code, options) {
21
- return module.exports.parseForESLint(code, options).ast
23
+ meta: {
24
+ name: packageJson.name,
25
+ version: packageJson.version
26
+ },
27
+ parse(code, parserOptions) {
28
+ return module.exports.parseForESLint(code, parserOptions).ast
22
29
  },
23
- parseForESLint: function (code) {
30
+ // See https://eslint.org/docs/latest/extend/custom-parsers#parseforeslint-return-object
31
+ // eslint-disable-next-line no-unused-vars
32
+ parseForESLint(code, parserOptions) {
24
33
  return {
25
34
  ast: createProgramAST(code),
26
35
  services: {
27
36
  getParsedCsn: function () {
37
+ const compileOptions = {
38
+ messages: [],
39
+ }
28
40
  let compiledModel
29
41
  let reflectedModel
30
- const messages = []
31
42
  try {
32
- compiledModel = cds.parse(code)
43
+ compiledModel = cds.parse(code, compileOptions)
33
44
  } catch {
34
45
  // Do nothing
35
46
  }
36
47
  if (compiledModel) {
37
48
  try {
38
49
  reflectedModel = cds.linked(compiledModel)
39
- if (messages) {
40
- reflectedModel.messages = messages
50
+ if (compileOptions.messages) {
51
+ reflectedModel.messages = compileOptions.messages
41
52
  }
42
53
  } catch (err) {
43
- LOG && LOG(colors.red + 'ERROR:' + colors.reset, err)
44
- LOG && LOG('COMPILED', compiledModel)
45
- LOG && LOG('REFLECTED', reflectedModel)
54
+ LOG?.(colors.red + 'ERROR:' + colors.reset, err)
55
+ LOG?.('COMPILED', compiledModel)
56
+ LOG?.('REFLECTED', reflectedModel)
46
57
  }
47
58
  }
48
59
  return reflectedModel
49
60
  },
61
+
50
62
  getInferredCsn: function () {
51
- const rootPath = Cache.get('rootpath')
52
- if (Cache.has('test')) {
53
- return Cache.get(`model:${rootPath}`)
63
+ const rootPath = globalCache.get('rootpath')
64
+ if (globalCache.has('test')) {
65
+ return globalCache.get(`model:${rootPath}`)
54
66
  }
55
67
  let compiledModel
56
68
  let reflectedModel
57
69
  cds.resolve.cache = {}
58
70
 
59
- if (!Cache.has(`model:${rootPath}`) && rootPath) {
60
- const roots = Cache.get(`roots:${rootPath}`)
71
+ if (!globalCache.has(`model:${rootPath}`) && rootPath) {
72
+ const roots = globalCache.get(`roots:${rootPath}`)
61
73
  const messages = []
62
74
  if (roots) {
63
75
  try {
@@ -67,27 +79,28 @@ module.exports = {
67
79
  locations: true,
68
80
  messages
69
81
  })
70
- Cache.remove('errRootModel')
82
+ globalCache.remove('errRootModel')
71
83
  } catch (err) {
72
- Cache.set('errRootModel', err)
84
+ // TODO: Only catch Compile Errors?
85
+ globalCache.set('errRootModel', err)
73
86
  }
74
87
  if (compiledModel) {
75
88
  reflectedModel = cds.linked(compiledModel)
76
- Cache.set(`model:${Cache.get('rootpath')}`, reflectedModel)
89
+ globalCache.set(`model:${globalCache.get('rootpath')}`, reflectedModel)
77
90
  if (messages) {
78
91
  reflectedModel.messages = messages
79
92
  }
80
93
  }
81
94
  }
82
95
  } else {
83
- reflectedModel = Cache.get(`model:${rootPath}`)
96
+ reflectedModel = globalCache.get(`model:${rootPath}`)
84
97
  }
85
98
  return reflectedModel
86
99
  },
87
100
  updateInferredCsn: compileModelFromDict,
88
101
  getEnvironment: function () {
89
- const options = Cache.get('options')
90
- return (options && options[0] && options[0].environment) ? options[0].environment : undefined
102
+ const options = globalCache.get('options')
103
+ return options?.[0]?.environment
91
104
  },
92
105
  getLocation: function (name, obj, model) {
93
106
  let loc
@@ -141,27 +154,37 @@ module.exports = {
141
154
  }
142
155
  },
143
156
  createProgramAST,
144
- compileModelFromDict
157
+ compileModelFromDict,
145
158
  }
146
159
 
147
160
  /**
148
- * Generates dummy AST with just single Program node
161
+ * Generates dummy AST with just single Program node.
149
162
  *
150
163
  * @param code Parse file contents
151
164
  * @param {object} [loc]
152
- * @returns AST
165
+ * @returns ESLint AST
153
166
  */
154
167
  function createProgramAST (code, loc) {
155
- loc = loc || {
156
- start: {
157
- line: 1,
158
- column: 0
159
- },
160
- end: {
161
- line: 1,
162
- column: 0
168
+ if (!loc && code.length) {
169
+ const newLines = [...code.matchAll(newLineRegEx)]
170
+ const endColumn = newLines.length ? (code.length - newLines.at(-1).index) : code.length
171
+ loc = {
172
+ start: {
173
+ line: 1,
174
+ column: 1
175
+ },
176
+ end: {
177
+ line: newLines.length + 1,
178
+ column: endColumn
179
+ },
180
+ }
181
+ } else if (!loc) {
182
+ loc = {
183
+ start: { line: 0, column: 0 },
184
+ end: { line: 0, column: 0 },
163
185
  }
164
186
  }
187
+
165
188
  return {
166
189
  type: 'Program',
167
190
  body: [],
@@ -12,7 +12,7 @@ module.exports = {
12
12
  'Ambiguous key with a `TO MANY` relationship since entries could appear multiple times with the same key.',
13
13
  category: 'Model Validation',
14
14
  recommended: true,
15
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/assoc2many-ambiguous-key',
15
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/assoc2many-ambiguous-key',
16
16
  },
17
17
  messages: {
18
18
  ambiguous: `Ambiguous key in '{{name}}'. Element '{{column-name}}' leads to multiple entries so that key '{{key-name}}' is not unique.`,
@@ -9,7 +9,7 @@ module.exports = {
9
9
  description: '`@restrict` and `@requires` must not be empty.',
10
10
  category: 'Model Validation',
11
11
  recommended: true,
12
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/auth-no-empty-restrictions',
12
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/auth-no-empty-restrictions',
13
13
  },
14
14
  messages: {
15
15
  missingRestriction: 'No explicit restrictions provided on {{kind}} `{{name}}` at `{{label}}`.',
@@ -7,7 +7,7 @@ module.exports = {
7
7
  description: '`@restrict.grant` on service level and for bound/unbound actions and functions is limited to grant: \'*\'',
8
8
  category: 'Model Validation',
9
9
  recommended: true,
10
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/auth-restrict-grant-service',
10
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/auth-restrict-grant-service',
11
11
  },
12
12
  messages: {
13
13
  limitedGrant: `The grant value provided in @restrict is limited to '*' for {{kind}} '{{name}}'`,
@@ -8,7 +8,7 @@ module.exports = {
8
8
  category: 'Model Validation',
9
9
  recommended: true,
10
10
  version: '2.4.1',
11
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/auth-use-requires',
11
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/auth-use-requires',
12
12
  },
13
13
  messages: {
14
14
  useRequires: 'Use `@requires` instead of `@restrict.to` at {{kind}} `{{name}}`.'
@@ -20,7 +20,7 @@ module.exports = {
20
20
  description: '`@restrict.grant` must have valid values.',
21
21
  category: 'Model Validation',
22
22
  recommended: true,
23
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/auth-valid-restrict-grant',
23
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/auth-valid-restrict-grant',
24
24
  },
25
25
  messages: {
26
26
  invalidType: 'Invalid type for grant value. Must either be string or array of strings.',
@@ -13,7 +13,7 @@ module.exports = {
13
13
  description: '`@restrict` must not have properties besides `to`, `grant`, and `where`.',
14
14
  category: 'Model Validation',
15
15
  recommended: true,
16
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/auth-valid-restrict-keys',
16
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/auth-valid-restrict-keys',
17
17
  },
18
18
  messages: {
19
19
  misspelledProperty: "Misspelled or unknown property '{{invalid}}'. Did you mean '{{candidates}}'?",
@@ -9,7 +9,7 @@ module.exports = {
9
9
  description: '`@restrict.to` must have valid values.',
10
10
  category: 'Model Validation',
11
11
  recommended: true,
12
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/auth-valid-restrict-to',
12
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/auth-valid-restrict-to',
13
13
  },
14
14
  messages: {
15
15
  invalidType: 'Invalid type for value of `@restrict.to`. Must either be string or array of strings.',
@@ -9,7 +9,7 @@ module.exports = {
9
9
  description: '`@restrict.where` must have valid values.',
10
10
  category: 'Model Validation',
11
11
  recommended: true,
12
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/auth-valid-restrict-where',
12
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/auth-valid-restrict-where',
13
13
  },
14
14
  severity: 'error',
15
15
  messages: {
@@ -10,7 +10,7 @@ const rule = module.exports = {
10
10
  description: 'Extensions must not violate restrictions set by the extended SaaS app.',
11
11
  category: 'Model Validation',
12
12
  recommended: true,
13
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/extension-restrictions',
13
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/extension-restrictions',
14
14
  },
15
15
  hasSuggestions: false,
16
16
  type: 'problem',
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const Cache = require('../utils/Cache')
3
+ const { globalCache } = require('../utils/Cache')
4
4
  const createRule = require('../utils/createRule')
5
5
 
6
6
  const rules = {
@@ -25,6 +25,6 @@ const rules = {
25
25
  'extension-restrictions': () => createRule(require('./extension-restrictions'))
26
26
  }
27
27
 
28
- Cache.set('rules', rules)
28
+ globalCache.set('rules', rules)
29
29
 
30
30
  module.exports = rules
@@ -12,8 +12,7 @@ module.exports = {
12
12
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
13
13
  docs: {
14
14
  description: 'Avoid using reserved SQL keywords.',
15
- recommended: true,
16
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/no-db-keywords',
15
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/no-db-keywords',
17
16
  },
18
17
  messages: {
19
18
  reservedKeyword: `'{{name}}' is a reserved keyword in SQLite`,
@@ -6,7 +6,7 @@ module.exports = {
6
6
  docs: {
7
7
  description: 'Names must not start with $ to avoid possible shadowing of reserved variables.',
8
8
  recommended: true,
9
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/no-dollar-prefixed-names',
9
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/no-dollar-prefixed-names',
10
10
  },
11
11
  messages: {
12
12
  dollarPrefix: `'{{name}}' is prefixed with a dollar sign ($)`,
@@ -27,7 +27,7 @@ module.exports = {
27
27
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
28
28
  docs: {
29
29
  description: 'Reject reserved Java keywords as CDS identifiers.',
30
- recommended: true
30
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/no-java-keywords',
31
31
  },
32
32
  type: 'problem',
33
33
  model: 'inferred',
@@ -6,7 +6,7 @@ module.exports = {
6
6
  docs: {
7
7
  description: 'Draft-enabled entities shall not be used in views that make use of `JOIN`.',
8
8
  recommended: true,
9
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/no-join-on-draft',
9
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/no-join-on-draft',
10
10
  },
11
11
  messages: {
12
12
  draftJoin: 'Do not use draft-enabled entities in views that make use of `JOIN`.',
@@ -6,7 +6,7 @@ module.exports = {
6
6
  docs: {
7
7
  description: 'Should make suggestions for possible missing SQL casts.',
8
8
  recommended: true,
9
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/sql-cast-suggestion',
9
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/sql-cast-suggestion',
10
10
  },
11
11
  type: 'suggestion',
12
12
  messages: {
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { forEachXprInDefinition } = require('../utils/csnTraversal')
4
4
 
5
- const invalidComparisonOperators = [ '=', '!=', '<>' ]
5
+ const invalidComparisonOperators = [ '=', '<>' ]
6
6
 
7
7
  module.exports = {
8
8
  meta: {
@@ -12,12 +12,12 @@ module.exports = {
12
12
  category: 'Model Validation',
13
13
  recommended: false,
14
14
  // TODO: Add documentation
15
- // url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/sql-null-comparison',
15
+ // url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/sql-null-comparison',
16
16
  },
17
17
  type: 'problem',
18
18
  model: 'parsed',
19
19
  messages: {
20
- nullComparison: `Comparisons against 'null' are always null. Did you mean 'is not null'?`,
20
+ nullComparison: `Comparisons against 'null' using '=' and '<>' are always null. Did you mean 'is null'/'is not null'?`,
21
21
  }
22
22
  },
23
23
  create(context) {
@@ -1,63 +1,64 @@
1
1
  'use strict'
2
2
 
3
+ const allowedUpperCaseElements = ['ID']
4
+
3
5
  module.exports = {
4
6
  meta: {
5
7
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
6
8
  docs: {
7
9
  description: 'Regular element names should start with lowercase letters.',
8
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/start-elements-lowercase',
10
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/start-elements-lowercase',
9
11
  },
10
12
  type: 'suggestion',
11
- hasSuggestions: true,
12
13
  messages: {
13
- startLowercase: "Element name '{{entityName}}.{{elementName}}' should start with a lowercase letter.",
14
- fixLowercase: 'Start element name with a lowercase letter.'
14
+ startLowercase: "Element name '{{defName}}:{{elementName}}' should start with a lowercase letter.",
15
15
  },
16
16
  fixable: 'code',
17
17
  model: 'parsed',
18
18
  },
19
19
  create: function (context) {
20
- const sourcecode = context.getSourceCode()
20
+ const model = context.getModel()
21
+ if (!model?.definitions)
22
+ return
21
23
 
22
- return {
23
- element: checkStartLowercase
24
+ return function checkAllElementsStartWithLowercase() {
25
+ for (const defName in model.definitions)
26
+ checkDefinition(defName, model.definitions[defName])
24
27
  }
25
28
 
26
- function checkStartLowercase (e) {
27
- const elementName = e.name
28
- const entityName = e.parent.name
29
- if (elementName && !(entityName.startsWith('localized') || entityName.endsWith('texts'))) {
30
- if (elementName.charAt(0) !== elementName.charAt(0).toLowerCase() && !['ID'].includes(elementName)) {
31
- if (e.$location && e.$location.file) {
32
- const file = e.$location.file
33
- const loc = context.getLocation(elementName, e)
34
- const fix = (fixer, source = sourcecode) => {
35
- const elementNameSanitized = elementName.charAt(0).toLowerCase() + elementName.slice(1)
36
- const rangeEnd = source.getIndexFromLoc({
37
- line: loc.end.line,
38
- column: loc.end.column
39
- })
40
- const rangeBeg = rangeEnd ? rangeEnd - elementNameSanitized.length : 0
41
- return fixer.replaceTextRange([rangeBeg, rangeEnd], elementNameSanitized)
42
- }
43
- context.report({
44
- messageId: 'startLowercase',
45
- loc,
46
- file,
47
- data: {
48
- entityName,
49
- elementName
50
- },
51
- suggest: [
52
- {
53
- messageId: 'fixLowercase',
54
- fix
55
- }
56
- ]
57
- })
29
+ function checkDefinition(defName, def) {
30
+ if (defName.startsWith('localized') || defName.endsWith('texts'))
31
+ return
32
+
33
+ checkElements(def)
34
+
35
+ function checkElements(art) {
36
+ if (art.elements) {
37
+ for (const elementName in art.elements) {
38
+ const element = art.elements[elementName]
39
+ checkStartLowercase(element, elementName)
40
+ checkElements(element)
58
41
  }
59
42
  }
60
43
  }
44
+
45
+ function checkStartLowercase (element, elementName) {
46
+ if (!element.$location?.file)
47
+ return // without location, we can't report anything properly
48
+
49
+ if (elementName.charAt(0) !== elementName.charAt(0).toLowerCase()
50
+ && !allowedUpperCaseElements.includes(elementName)) {
51
+ context.report({
52
+ messageId: 'startLowercase',
53
+ loc: context.getLocation(elementName, element),
54
+ file: element.$location.file,
55
+ data: {
56
+ defName,
57
+ elementName
58
+ }
59
+ })
60
+ }
61
+ }
61
62
  }
62
63
  }
63
64
  }
@@ -7,53 +7,41 @@ module.exports = {
7
7
  schema: [{/* to avoid deprecation warning for ESLint 9 */}],
8
8
  docs: {
9
9
  description: 'Regular entity names should start with uppercase letters.',
10
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/start-entities-uppercase',
10
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/start-entities-uppercase',
11
11
  },
12
12
  type: 'suggestion',
13
- hasSuggestions: true,
14
13
  messages: {
15
14
  startUppercase: "Entity name '{{entityName}}' should start with an uppercase letter.",
16
- fixUppercase: 'Start entity name with an uppercase letter.'
17
15
  },
18
16
  fixable: 'code',
19
17
  model: 'parsed',
20
18
  },
21
19
  create(context) {
22
- const sourcecode = context.getSourceCode()
20
+ const model = context.getModel()
21
+ if (!model?.definitions)
22
+ return
23
23
 
24
- return { entity: checkStartsUppercase }
24
+ return function checkAllEntitiesStartWithUppercase() {
25
+ for (const defName in model.definitions) {
26
+ const def = model.definitions[defName]
27
+ if (def.kind === 'entity') {
28
+ checkEntityStartsUppercase(defName, def)
29
+ }
30
+ }
31
+ }
25
32
 
26
- function checkStartsUppercase (e) {
27
- if (e.kind !== 'entity')
28
- return // workaround for #424
33
+ function checkEntityStartsUppercase(name, entity) {
34
+ if (!entity.$location?.file)
35
+ return // without location, we can't report anything properly
29
36
 
30
- const entityName = splitDefName(e).name
37
+ const entityName = splitDefName(entity, name).name
31
38
  if (entityName.charAt(0) !== entityName.charAt(0).toUpperCase()) {
32
- if (e.$location?.file) {
33
- const file = e.$location.file
34
- const loc = context.getLocation(entityName, e)
35
- const fix = fixer => {
36
- const entityNameSanitized = entityName.charAt(0).toUpperCase() + entityName.slice(1)
37
- const rangeEnd = sourcecode.getIndexFromLoc({
38
- line: loc.end.line,
39
- column: loc.end.column
40
- })
41
- const rangeBeg = rangeEnd ? rangeEnd - entityNameSanitized.length : 0
42
- return fixer.replaceTextRange([rangeBeg, rangeEnd], entityNameSanitized)
43
- }
44
- context.report({
45
- messageId: 'startUppercase',
46
- loc,
47
- file,
48
- data: { entityName },
49
- suggest: [
50
- {
51
- messageId: 'fixUppercase',
52
- fix
53
- }
54
- ]
55
- })
56
- }
39
+ context.report({
40
+ messageId: 'startUppercase',
41
+ loc: context.getLocation(entityName, entity),
42
+ file: entity.$location.file,
43
+ data: { entityName },
44
+ })
57
45
  }
58
46
  }
59
47
  }
@@ -13,7 +13,7 @@ module.exports = {
13
13
  description: 'CSV files for entities must refer to valid element names.',
14
14
  category: 'Model Validation',
15
15
  recommended: true,
16
- url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/valid-csv-header',
16
+ url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/valid-csv-header',
17
17
  },
18
18
  severity: 'warn',
19
19
  type: 'problem',
@@ -1,35 +1,38 @@
1
1
  'use strict'
2
2
 
3
- /**
4
- * Simple cache to store model and any cds calls made in the rule creation
5
- * api to modify the model
6
- */
7
- const cache = new Map()
8
3
 
9
- module.exports = {
4
+ class Cache {
5
+ #entries = new Map()
6
+
10
7
  has (key) {
11
- return cache.has(key)
12
- },
8
+ return this.#entries.has(key)
9
+ }
13
10
  set (key, value) {
14
- return cache.set(key, [value, Date.now()])
15
- },
11
+ return this.#entries.set(key, [value, Date.now()])
12
+ }
16
13
  get (key) {
17
- return cache.get(key) ? cache.get(key)[0] : undefined
18
- },
14
+ return this.#entries.has(key) ? this.#entries.get(key)[0] : undefined
15
+ }
19
16
  dump () {
20
17
  const dump = {}
21
- for (const [key, value] of cache.entries()) {
18
+ for (const [key, value] of this.#entries.entries()) {
22
19
  const timestamp = new Date(value[1])
23
20
  dump[key] = { key, value: value[0], timestamp }
24
21
  }
25
22
  return JSON.stringify(dump, null, 2)
26
- },
23
+ }
27
24
  remove (key) {
28
- if (cache.has(key)) {
29
- cache.delete(key)
25
+ if (this.#entries.has(key)) {
26
+ this.#entries.delete(key)
30
27
  }
31
- },
28
+ }
32
29
  clear () {
33
- cache.clear()
30
+ this.#entries.clear()
34
31
  }
35
32
  }
33
+
34
+
35
+ module.exports = {
36
+ Cache,
37
+ globalCache: new Cache(),
38
+ }
@@ -17,10 +17,10 @@ const fs = require('fs')
17
17
  const path = require('path')
18
18
  const cds = require('@sap/cds')
19
19
 
20
- const Cache = require('./Cache')
20
+ const { globalCache } = require('./Cache')
21
21
  const constants = require('../constants')
22
22
  const isConfiguredFileType = require('./isConfiguredFileType')
23
- const getProjectRootPath = require('./getProjectRootPath')
23
+ const { getProjectRootPath, hasProjectRoots } = require('./projectRootPath')
24
24
  const { CdsLintAssertionError } = require('./LintError')
25
25
 
26
26
  const LOG = cds.debug('lint:plugin')
@@ -48,18 +48,18 @@ module.exports = function createRule(spec) {
48
48
  Program: node => {
49
49
  const file = context.getFilename()
50
50
  if (file !== filePrev) {
51
- LOG && LOG(`File: ${context.getFilename()}`)
51
+ LOG?.(`File: ${context.getFilename()}`)
52
52
  }
53
- const cdscontext = extendContext(node, context, meta)
54
- Cache.set('context', cdscontext)
55
- const { isTest, isValidFile, doEnvironmentChecks, doRootModelChecks, showInEditor } = checkEntryCriteria(meta, cdscontext)
53
+ const cdsContext = extendContext(node, context, meta)
54
+ globalCache.set('context', cdsContext)
55
+ const { isTest, isValidFile, doEnvironmentChecks, doRootModelChecks, showInEditor } = checkEntryCriteria(meta, cdsContext)
56
56
  switch (meta.model) {
57
57
  case 'none':
58
58
  if (doEnvironmentChecks) {
59
- if (isTest || !Cache.has(`rule:${cdscontext.id}`)) {
60
- LOG && LOG(` Model: "${meta.model}" Rule: ${context.id}`)
61
- Cache.set(`rule:${cdscontext.id}:${Cache.get('rootpath')}`, 'done')
62
- createReport(node, cdscontext, meta, create)
59
+ if (isTest || !globalCache.has(`rule:${cdsContext.id}`)) {
60
+ LOG?.(` Model: "${meta.model}" Rule: ${context.id}`)
61
+ globalCache.set(`rule:${cdsContext.id}:${globalCache.get('rootpath')}`, 'done')
62
+ createReport(node, cdsContext, meta, create)
63
63
  }
64
64
  }
65
65
  break
@@ -67,24 +67,24 @@ module.exports = function createRule(spec) {
67
67
  case 'inferred':
68
68
  if (isValidFile && doRootModelChecks) {
69
69
  if (showInEditor) {
70
- Cache.remove(`model:${Cache.get('rootpath')}`)
71
- Cache.remove(`rule:${cdscontext.id}:${Cache.get('rootpath')}`)
72
- Cache.remove(`report:${context.getFilename()}:${context.id}`)
70
+ globalCache.remove(`model:${globalCache.get('rootpath')}`)
71
+ globalCache.remove(`rule:${cdsContext.id}:${globalCache.get('rootpath')}`)
72
+ globalCache.remove(`report:${context.getFilename()}:${context.id}`)
73
73
  }
74
- if (isTest || showInEditor || !Cache.has(`rule:${cdscontext.id}:${Cache.get('rootpath')}`)) {
75
- LOG && LOG(` Model: "${meta.model}" Rule: ${context.id}`)
74
+ if (isTest || showInEditor || !globalCache.has(`rule:${cdsContext.id}:${globalCache.get('rootpath')}`)) {
75
+ LOG?.(` Model: "${meta.model}" Rule: ${context.id}`)
76
76
  if (!showInEditor) {
77
- Cache.set(`rule:${cdscontext.id}:${Cache.get('rootpath')}`, 'done')
77
+ globalCache.set(`rule:${cdsContext.id}:${globalCache.get('rootpath')}`, 'done')
78
78
  }
79
- createReport(node, cdscontext, meta, create)
79
+ createReport(node, cdsContext, meta, create)
80
80
  } else {
81
- if (Cache.has(`report:${context.getFilename()}:${context.id}`)) {
82
- const reports = Cache.get(`report:${context.getFilename()}:${context.id}`)
81
+ if (globalCache.has(`report:${context.getFilename()}:${context.id}`)) {
82
+ const reports = globalCache.get(`report:${context.getFilename()}:${context.id}`)
83
83
  for (const r of Array.from(reports)) {
84
84
  context.report(JSON.parse(r))
85
85
  }
86
- Cache.remove(`report:${context.getFilename()}:${context.id}`)
87
- Cache.set(`rule:${cdscontext.id}:${Cache.get('rootpath')}`, 'done')
86
+ globalCache.remove(`report:${context.getFilename()}:${context.id}`)
87
+ globalCache.set(`rule:${cdsContext.id}:${globalCache.get('rootpath')}`, 'done')
88
88
  }
89
89
  }
90
90
  }
@@ -92,8 +92,8 @@ module.exports = function createRule(spec) {
92
92
 
93
93
  default:
94
94
  if (isValidFile) {
95
- LOG && LOG(` Model: "${meta.model}" Rule: ${context.id}`)
96
- createReport(node, cdscontext, meta, create)
95
+ LOG?.(` Model: "${meta.model}" Rule: ${context.id}`)
96
+ createReport(node, cdsContext, meta, create)
97
97
  }
98
98
  break
99
99
  }
@@ -105,22 +105,21 @@ module.exports = function createRule(spec) {
105
105
  }
106
106
 
107
107
  function isRunningWithCDSLint () {
108
- return process.argv[0].endsWith('node') && process.argv[1].endsWith('cds') && process.argv[2] === 'lint'
108
+ return process.argv[1].match(/cds(\.js)?$/) && process.argv[2]?.toLowerCase() === 'lint'
109
109
  }
110
110
 
111
111
  function isRunningWithESLint () {
112
- return process.argv[0].endsWith('node') && process.argv[1].endsWith('eslint')
112
+ return process.argv[1].match(/eslint(\.js)?$/)
113
113
  }
114
114
 
115
- function checkEntryCriteria (meta, cdscontext) {
116
- const isTest = Cache.has('test')
117
- const showInEditor = cdscontext.options.includes('show')
118
- const hasProjectRoots = Cache.has(`roots:${Cache.get('rootpath')}`)
119
- const isValidFile = isConfiguredFileType(cdscontext.getFilename(), 'FILES')
120
- const doRootModelChecks = isTest || (hasProjectRoots && (isRunningWithCDSLint() || isRunningWithESLint()) || showInEditor)
115
+ function checkEntryCriteria (meta, cdsContext) {
116
+ const isTest = globalCache.has('test')
117
+ const showInEditor = cdsContext.options.includes('show')
118
+ const isValidFile = isConfiguredFileType(cdsContext.getFilename(), 'FILES')
119
+ const doRootModelChecks = isTest || (hasProjectRoots() && (isRunningWithCDSLint() || isRunningWithESLint()) || showInEditor)
121
120
  // Lint all env rules independent of any parsed file (i.e. 'cds lint' uses the lintText "" API)
122
121
  const doEnvironmentChecks =
123
- isTest || (isRunningWithCDSLint() && cdscontext.getFilename() === '<text>')
122
+ isTest || (isRunningWithCDSLint() && cdsContext.getFilename() === '<text>')
124
123
  return { isTest, isValidFile, doRootModelChecks, doEnvironmentChecks, showInEditor }
125
124
  }
126
125
 
@@ -196,11 +195,11 @@ function sanitizeFileLocation (d) {
196
195
  * @param meta
197
196
  */
198
197
  function extendContext (node, context, meta) {
199
- if (!Cache.has('test')) {
198
+ if (!globalCache.has('test')) {
200
199
  const filePath = context.getFilename()
201
200
  const rootPath = filePath && fs.existsSync(filePath) ? getProjectRootPath(filePath) : ''
202
201
  if (rootPath) {
203
- Cache.set('rootpath', rootPath)
202
+ globalCache.set('rootpath', rootPath)
204
203
  }
205
204
  }
206
205
 
@@ -222,7 +221,7 @@ function extendContext (node, context, meta) {
222
221
  }
223
222
  cdscontext.getLocation = parserServices.getLocation
224
223
  cdscontext.getNode = Object.keys(parserServices).length > 0 ? parserServices.getNode : () => node
225
- cdscontext.getRootPath = () => Cache.get('rootpath')
224
+ cdscontext.getRootPath = () => globalCache.get('rootpath')
226
225
  return cdscontext
227
226
 
228
227
  function reportWrapper(r) {
@@ -232,7 +231,7 @@ function extendContext (node, context, meta) {
232
231
  if (!r.file) {
233
232
  throw new CdsLintAssertionError(`Rule ${context.id} must return a "file" property in the rule report!`)
234
233
  }
235
- const file = Cache.get('rootpath') ? resolveFilePath(r.file) : r.file
234
+ const file = globalCache.get('rootpath') ? resolveFilePath(r.file) : r.file
236
235
  if (cdscontext.getFilename() === file) {
237
236
  delete r.file
238
237
  context.report(r)
@@ -280,11 +279,11 @@ function cacheReport (r, filepath, context, meta) {
280
279
  }
281
280
  if (r) {
282
281
  let reports = new Set()
283
- if (Cache.has(`report:${filepath}:${context.id}`)) {
284
- reports = Cache.get(`report:${filepath}:${context.id}`)
282
+ if (globalCache.has(`report:${filepath}:${context.id}`)) {
283
+ reports = globalCache.get(`report:${filepath}:${context.id}`)
285
284
  }
286
285
  reports.add(JSON.stringify(r))
287
- Cache.set(`report:${filepath}:${context.id}`, reports)
286
+ globalCache.set(`report:${filepath}:${context.id}`, reports)
288
287
  }
289
288
  }
290
289
 
@@ -295,7 +294,7 @@ function cacheReport (r, filepath, context, meta) {
295
294
  */
296
295
  function getDisabled (code, sourcecode, line) {
297
296
  const listDisabled = []
298
- const rules = Cache.get('rules')
297
+ const rules = globalCache.get('rules')
299
298
  const rulesDisabled = Object.keys(rules).reduce((o, key) => ({ ...o, [key]: 'on' }), {})
300
299
  if (code) {
301
300
  const matches = [...code.matchAll(REGEX_COMMENTS)]
@@ -375,5 +374,5 @@ function getLastLine (code) {
375
374
  * @param {string} file
376
375
  */
377
376
  function resolveFilePath (file) {
378
- return path.isAbsolute(file) ? file : path.join(Cache.get('rootpath'), file)
377
+ return path.isAbsolute(file) ? file : path.join(globalCache.get('rootpath'), file)
379
378
  }
@@ -2,13 +2,13 @@
2
2
 
3
3
  const path = require('node:path')
4
4
  const cds = require('@sap/cds')
5
- const Cache = require('./Cache')
5
+ const { globalCache } = require('./Cache')
6
6
  const fs = require('node:fs')
7
7
 
8
8
  const commonCapProjectFiles = ['build.gradle', '.git', 'srv', 'db', 'app']
9
9
 
10
10
  /**
11
- * Searches for directory containing cds roots
11
+ * Searches for a directory containing cds roots.
12
12
  *
13
13
  * As of today, there is no unified way to find the root directory for a CDS project.
14
14
  * ("The root is wherever the user typed `cds init`")
@@ -20,9 +20,8 @@ const commonCapProjectFiles = ['build.gradle', '.git', 'srv', 'db', 'app']
20
20
  * @param {string} currentDir start here and search until root dir
21
21
  * @returns {string} dir containing cds roots (empty if not exists)
22
22
  */
23
- module.exports = function getProjectRootPath(currentDir = '.') {
23
+ function getProjectRootPath(currentDir = '.') {
24
24
  let dir = path.resolve(currentDir)
25
-
26
25
  while (!couldBeProjectRoot(dir)) {
27
26
  if (dir === path.resolve(dir, '..'))
28
27
  return '' // we reached the file system root -> abort
@@ -32,7 +31,7 @@ module.exports = function getProjectRootPath(currentDir = '.') {
32
31
  cds.resolve.cache = {}
33
32
  const roots = cds.resolve('*', { root: dir })
34
33
  if (roots?.length > 0) {
35
- Cache.set(`roots:${dir}`, roots)
34
+ globalCache.set(`roots:${dir}`, roots)
36
35
  return dir
37
36
  }
38
37
  return ''
@@ -56,8 +55,21 @@ function isRootPackageJson(filepath) {
56
55
 
57
56
  try {
58
57
  const config = JSON.parse(fs.readFileSync(filepath, 'utf8'))
59
- return Object.keys(config?.dependencies ?? {}).some(dep => dep === '@sap/cds')
58
+ return config && (config.cds ||
59
+ hasCdsPackage(config.dependencies) ||
60
+ hasCdsPackage(config.peerDependencies) ||
61
+ hasCdsPackage(config.devDependencies))
62
+
60
63
  } catch {
61
64
  return false
62
65
  }
63
66
  }
67
+
68
+ function hasCdsPackage(deps) {
69
+ return deps && (('@sap/cds' in deps) || ('@sap/cds-dk' in deps))
70
+ }
71
+
72
+ module.exports = {
73
+ getProjectRootPath,
74
+ hasProjectRoots: () => globalCache.has(`roots:${globalCache.get('rootpath')}`),
75
+ }
@@ -6,7 +6,7 @@ const fs = require('node:fs')
6
6
  const path = require('node:path')
7
7
 
8
8
  const { Linter, RuleTester } = require('eslint')
9
- const Cache = require('./Cache')
9
+ const { globalCache } = require('./Cache')
10
10
  const isConfiguredFileType = require('./isConfiguredFileType')
11
11
  const { compileModelFromDict } = require('../parser')
12
12
  const rules = require('../rules')
@@ -29,7 +29,7 @@ function testRuleWrapper(rule) {
29
29
  _initModelRuleTester(filePath, rule.meta.model)
30
30
  const createValue = rule.create(context)
31
31
  const result = createValue.Program(node)
32
- Cache.clear()
32
+ globalCache.clear()
33
33
  return result
34
34
  }
35
35
  }
@@ -114,18 +114,18 @@ module.exports = function runRuleTester(options) {
114
114
  * @param {string} flavor
115
115
  */
116
116
  function _initModelRuleTester(filePath, flavor) {
117
- Cache.set('rules', rules)
118
- Cache.set('test', true)
117
+ globalCache.set('rules', rules)
118
+ globalCache.set('test', true)
119
119
  const rootPath = path.dirname(filePath)
120
- Cache.set('rootpath', rootPath)
120
+ globalCache.set('rootpath', rootPath)
121
121
  if (flavor !== 'none') { // not for env rules
122
122
  const files = fs.readdirSync(rootPath)
123
123
  const modelfiles = files.map(f => path.join(rootPath, f)).filter(fp => isConfiguredFileType(fp, 'MODEL_FILES'))
124
- Cache.set(`modelfiles:${rootPath}`, modelfiles)
124
+ globalCache.set(`modelfiles:${rootPath}`, modelfiles)
125
125
  const dictFiles = _getDictFiles(rootPath, modelfiles)
126
- Cache.set(`dictfiles:${rootPath}`, dictFiles)
126
+ globalCache.set(`dictfiles:${rootPath}`, dictFiles)
127
127
  const reflectedModel = compileModelFromDict(dictFiles, { flavor })
128
- Cache.set(`model:${rootPath}`, reflectedModel)
128
+ globalCache.set(`model:${rootPath}`, reflectedModel)
129
129
  }
130
130
  }
131
131
 
@@ -139,12 +139,12 @@ function _initModelRuleTester(filePath, flavor) {
139
139
  */
140
140
  function _getDictFiles(input, filenames) {
141
141
  let dictFiles = {}
142
- if (Cache.has(`dictfiles:${input}`)) {
143
- dictFiles = Cache.get(`dictfiles:${input}`)
142
+ if (globalCache.has(`dictfiles:${input}`)) {
143
+ dictFiles = globalCache.get(`dictfiles:${input}`)
144
144
  } else {
145
145
  filenames.forEach(file => {
146
- dictFiles[file] = Cache.has(`file:${file}`)
147
- ? Cache.get(`file:${file}`)
146
+ dictFiles[file] = globalCache.has(`file:${file}`)
147
+ ? globalCache.get(`file:${file}`)
148
148
  : fs.readFileSync(file, 'utf8')
149
149
  })
150
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/eslint-plugin-cds",
3
- "version": "3.1.1",
3
+ "version": "3.2.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": [
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "@sap/cds": ">=7",
24
- "semver": "^7.3.4"
24
+ "semver": "^7.7.1"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "eslint": ">=8"