@sap/eslint-plugin-cds 2.4.1 → 2.6.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +31 -2
  2. package/README.md +2 -1
  3. package/lib/api/index.js +13 -12
  4. package/lib/conf/all.js +22 -0
  5. package/lib/conf/index.js +22 -0
  6. package/lib/conf/recommended.js +19 -0
  7. package/lib/constants.js +19 -16
  8. package/lib/index.js +11 -33
  9. package/lib/parser.js +169 -10
  10. package/lib/rules/assoc2many-ambiguous-key.js +80 -96
  11. package/lib/rules/auth-no-empty-restrictions.js +23 -30
  12. package/lib/rules/auth-use-requires.js +25 -24
  13. package/lib/rules/auth-valid-restrict-grant.js +87 -49
  14. package/lib/rules/auth-valid-restrict-keys.js +30 -23
  15. package/lib/rules/auth-valid-restrict-to.js +97 -52
  16. package/lib/rules/auth-valid-restrict-where.js +52 -42
  17. package/lib/rules/extension-restrictions.js +69 -0
  18. package/lib/rules/index.js +27 -0
  19. package/lib/rules/latest-cds-version.js +23 -21
  20. package/lib/rules/min-node-version.js +25 -24
  21. package/lib/rules/no-db-keywords.js +23 -31
  22. package/lib/rules/no-dollar-prefixed-names.js +17 -14
  23. package/lib/rules/no-join-on-draft.js +27 -0
  24. package/lib/rules/require-2many-oncond.js +11 -16
  25. package/lib/rules/sql-cast-suggestion.js +16 -29
  26. package/lib/rules/start-elements-lowercase.js +42 -44
  27. package/lib/rules/start-entities-uppercase.js +29 -31
  28. package/lib/rules/valid-csv-header.js +65 -64
  29. package/lib/{api/lint.d.ts → types.d.ts} +5 -7
  30. package/lib/utils/Cache.js +33 -0
  31. package/lib/utils/Colors.js +9 -0
  32. package/lib/utils/createRule.js +317 -0
  33. package/lib/utils/findFuzzy.js +87 -0
  34. package/lib/utils/genDocs.js +345 -0
  35. package/lib/utils/getConfigPath.js +33 -0
  36. package/lib/utils/getConfiguredFileTypes.js +10 -0
  37. package/lib/utils/getFileExtensions.js +8 -0
  38. package/lib/utils/getProjectRootPath.js +25 -0
  39. package/lib/utils/isConfiguredFileType.js +20 -0
  40. package/lib/utils/rules.js +112 -1041
  41. package/lib/utils/runRuleTester.js +116 -0
  42. package/package.json +10 -4
  43. package/lib/processor.js +0 -50
  44. package/lib/rules/no-join-on-draft-enabled-entities.js +0 -40
  45. package/lib/utils/fuzzySearch.js +0 -94
  46. package/lib/utils/helpers.js +0 -94
  47. package/lib/utils/jsonc.js +0 -1
  48. package/lib/utils/model.js +0 -393
  49. package/lib/utils/ruleHelpers.js +0 -199
  50. package/lib/utils/ruleTester.js +0 -78
  51. package/lib/utils/validate.js +0 -36
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Wrapper for ESLint's Rule creator:
3
+ * https://eslint.org/docs/developer-guide/working-with-rules
4
+ * - Must follow the ESLint prescribed convention for all rule exports
5
+ * - ESLint uses 'create' function to traverse its AST nodes
6
+ * - Since we do not work with an AST for cds models, a dummy 'Programm' node is used as an entry point
7
+ * - More eslint-like API
8
+ * - More convenience for error reports
9
+ * @param {CDSRuleSpec} spec
10
+ * @returns {RuleModule}
11
+ */
12
+
13
+ const { SourceCode } = require('eslint')
14
+ const fs = require('fs')
15
+ const path = require('path')
16
+ const Cache = require('./Cache')
17
+ const constants = require('../constants')
18
+ const isConfiguredFileType = require('./isConfiguredFileType')
19
+ const getProjectRootPath = require('./getProjectRootPath')
20
+ const cds = require('@sap/cds')
21
+ const { exit } = require('process')
22
+ const LOG = cds.debug('lint:plugin')
23
+ let filePrev = ''
24
+
25
+ const REGEX_COMMENT_START = '(/\\*|(.+)?//)(\\s?)+eslint-'
26
+ const REGEX_COMMENTS = `${REGEX_COMMENT_START}(enable|disable)(-next)?(-line)?(.+)?`
27
+
28
+ module.exports = (spec) => {
29
+ let { meta, create } = spec
30
+ meta = setMetaDefaults(meta)
31
+
32
+ return {
33
+ meta,
34
+ create: (context) => ({
35
+ Program: (node) => {
36
+ try {
37
+ const file = context.getFilename()
38
+ if (file !== filePrev) {
39
+ LOG && LOG(`File: ${context.getFilename()}`)
40
+ }
41
+ const cdscontext = extendContext(node, context, meta)
42
+ Cache.set('context', cdscontext)
43
+ const { isTest, isValidFile, doEnvironmentChecks, doRootModelChecks } = checkEntryCriteria(meta, cdscontext)
44
+ switch (meta.model) {
45
+ case 'none':
46
+ if (doEnvironmentChecks) {
47
+ if (isTest || !Cache.has(`rule:${cdscontext.id}`)) {
48
+ LOG && LOG(` Model: "${meta.model}" Rule: ${context.id}`)
49
+ Cache.set(`rule:${cdscontext.id}:${Cache.get('rootpath')}`, 'done')
50
+ createReport(node, cdscontext, meta, create)
51
+ }
52
+ }
53
+ break
54
+
55
+ case 'inferred':
56
+ if (isValidFile && doRootModelChecks) {
57
+ if (isTest || !Cache.has(`rule:${cdscontext.id}:${Cache.get('rootpath')}`)) {
58
+ LOG && LOG(` Model: "${meta.model}" Rule: ${context.id}`)
59
+ Cache.set(`rule:${cdscontext.id}:${Cache.get('rootpath')}`, 'done')
60
+ createReport(node, cdscontext, meta, create)
61
+ } else {
62
+ if (Cache.has(`report:${context.getFilename()}:${context.id}`)) {
63
+ const reports = Cache.get(`report:${context.getFilename()}:${context.id}`)
64
+ for (const r of Array.from(reports)) {
65
+ context.report(JSON.parse(r))
66
+ }
67
+ Cache.remove(`report:${context.getFilename()}:${context.id}`)
68
+ Cache.set(`rule:${cdscontext.id}:${Cache.get('rootpath')}`, 'done')
69
+ }
70
+ }
71
+ }
72
+ break
73
+
74
+ default:
75
+ if (isValidFile) {
76
+ LOG && LOG(` Model: "${meta.model}" Rule: ${context.id}`)
77
+ createReport(node, cdscontext, meta, create)
78
+ }
79
+ break
80
+ }
81
+ filePrev = file
82
+ } catch (err) {
83
+ console.error(err)
84
+ }
85
+ }
86
+ })
87
+ }
88
+ }
89
+
90
+ function isRunningWithCDSLint () {
91
+ return process.argv[0].endsWith('node') && process.argv[1].endsWith('cds') && process.argv[2] === 'lint'
92
+ }
93
+
94
+ function checkEntryCriteria (meta, cdscontext) {
95
+ const isTest = Cache.has('test')
96
+ const hasProjectRoots = Cache.has(`roots:${Cache.get('rootpath')}`)
97
+ const isValidFile = isConfiguredFileType(cdscontext.getFilename(), 'FILES')
98
+ const doRootModelChecks = isTest || (hasProjectRoots && (isRunningWithCDSLint() || cdscontext.options.includes('show')))
99
+ // Also lint empty folders (i.e. lintText "" API)
100
+ const doEnvironmentChecks =
101
+ isTest || (hasProjectRoots && isRunningWithCDSLint() && cdscontext.getSourceCode().lines[0] === '')
102
+ return { isTest, isValidFile, doRootModelChecks, doEnvironmentChecks }
103
+ }
104
+
105
+ function setMetaDefaults (meta) {
106
+ if (!meta.severity) meta.severity = constants.DEFAULT_RULE_SEVERITY
107
+ if (meta.docs && !meta.docs.category) meta.docs.category = constants.DEFAULT_RULE_CATEGORY
108
+ if (!meta.model) meta.model = 'parsed'
109
+ return meta
110
+ }
111
+
112
+ /**
113
+ * Get report descriptors from created rules. These can take varios forms,
114
+ * from minimal return, up to fully defined ESLint report descriptors values,
115
+ * with or without visitor keys:
116
+ * - String is interpreted as the 'message' property
117
+ * - Object with known CDS Visitor keys and ESLint report descriptor values
118
+ * - Object with ESLint report dedscriptor keys/ values
119
+ * @param {*} cdscontext
120
+ * @param {*} create
121
+ * @returns
122
+ */
123
+ function createReport (node, cdscontext, meta, create) {
124
+ const handlers = create(cdscontext)
125
+ /**
126
+ * TODO: Can these be rewritten to have a visitor? Note, that so far,
127
+ * rules without a visitor cannot use eslint disable comments
128
+ * A rule have no visitors and just return a single check:
129
+ * - Model Validation rules which have no well-defined CSN entry point
130
+ * - Environment rules
131
+ */
132
+ switch (typeof handlers) {
133
+ case 'function':
134
+ handlers()
135
+ break
136
+
137
+ case 'object': {
138
+ if (meta.model !== 'none') {
139
+ const model = cdscontext.getModel()
140
+
141
+ if (model) {
142
+ model.forall((d) => {
143
+ Object.entries(handlers)
144
+ .filter(([type, lazy]) => d.is(type))
145
+ .forEach(([lazy, handler]) => {
146
+ try {
147
+ handler(d)
148
+ } catch (err) {
149
+ console.log(`Error in rule "${cdscontext.id}" at ${d.name}`, err)
150
+ }
151
+ })
152
+ })
153
+ }
154
+ }
155
+ break
156
+ }
157
+ }
158
+ }
159
+
160
+ function extendContext (node, context, meta) {
161
+ if (!Cache.has('test')) {
162
+ const filePath = context.getFilename()
163
+ const rootPath = filePath && fs.existsSync(filePath) ? getProjectRootPath(filePath) : ''
164
+ if (rootPath) {
165
+ Cache.set('rootpath', rootPath)
166
+ }
167
+ }
168
+
169
+ const reportWrapper = (r) => {
170
+ const line = r.loc ? r.loc.start.line : r.node.loc.start.line
171
+ if (!isRuleDisabled(line, context)) {
172
+ if (meta.model === 'inferred') {
173
+ if (!r.file) {
174
+ console.error(`Rule ${context.id} must return a "file" property in the rule report!`)
175
+ exit(1)
176
+ }
177
+ const file = Cache.get('rootpath') ? resolveFilePath(r.file) : r.file
178
+ if (cdscontext.getFilename() === file) {
179
+ delete r.file
180
+ context.report(r)
181
+ }
182
+ if (r.file) {
183
+ cacheReport(r, file, context, meta)
184
+ }
185
+ } else {
186
+ context.report(r)
187
+ }
188
+ }
189
+ }
190
+
191
+ const descriptors = Object.getOwnPropertyDescriptors(context)
192
+ descriptors.report = {
193
+ value: reportWrapper,
194
+ writable: false,
195
+ enumerable: true,
196
+ configurable: false
197
+ }
198
+
199
+ const cdscontext = Object.create(Object.getPrototypeOf(context), descriptors)
200
+ cdscontext.getModel =
201
+ meta.model === 'inferred' ? context.parserServices.getInferredCsn : context.parserServices.getParsedCsn
202
+ cdscontext.getEnvironment = () => {
203
+ const options = context.options
204
+ return options && options[0] && options[0].environment ? options[0].environment : undefined
205
+ }
206
+ cdscontext.getLocation = context.parserServices.getLocation
207
+ cdscontext.getNode = Object.keys(context.parserServices).length > 0 ? context.parserServices.getNode : () => node
208
+ return cdscontext
209
+ }
210
+
211
+ function isRuleDisabled (line, cdscontext) {
212
+ let isDisabled = false
213
+ if (cdscontext) {
214
+ const sourcecode = cdscontext.getSourceCode()
215
+ const rulesDisabled = getDisabled(sourcecode.getText(), sourcecode, line)
216
+ const id = cdscontext.id
217
+ isDisabled = line && id in rulesDisabled && rulesDisabled[id] === 'off'
218
+ }
219
+ return isDisabled
220
+ }
221
+
222
+ function cacheReport (r, file, context, meta) {
223
+ delete r.file
224
+ r.node.range = []
225
+ if (r.messageId) {
226
+ r.message = meta.messages[r.messageId]
227
+ delete r.message
228
+ }
229
+ if (r) {
230
+ let reports = new Set()
231
+ if (Cache.has(`report:${file}:${context.id}`)) {
232
+ reports = Cache.get(`report:${file}:${context.id}`)
233
+ }
234
+ reports.add(JSON.stringify(r))
235
+ Cache.set(`report:${file}:${context.id}`, reports)
236
+ }
237
+ }
238
+
239
+ function getDisabled (code, sourcecode, line) {
240
+ const listDisabled = []
241
+ const rules = Cache.get('rules')
242
+ const rulesDisabled = Object.keys(rules).reduce((o, key) => ({ ...o, [key]: 'on' }), {})
243
+ let matches = []
244
+ if (code) {
245
+ matches = [...code.matchAll(REGEX_COMMENTS)]
246
+ if (matches.length > 0) {
247
+ matches.forEach((match) => {
248
+ if (match) {
249
+ const index = match.index
250
+ match = match[0]
251
+ if (match.includes('*/')) {
252
+ match = match.split('*/')[0].replace('/*', '')
253
+ } else if (match.includes('//')) {
254
+ match = match.split('//')[1]
255
+ }
256
+ if (match) {
257
+ match = match.trim()
258
+ }
259
+ ['disable', 'enable'].forEach((keyword) => {
260
+ const loc = sourcecode.getLocFromIndex(index)
261
+ const disableType = match.split(' ')[0]
262
+ let disableRules = match.split(`${disableType} `)[1]
263
+ disableRules = disableRules
264
+ ? disableRules.split(',').map((rule) => rule.trim())
265
+ : Object.keys(rules).map((rule) => `@sap/cds/${rule}`)
266
+ let comment = {}
267
+ if ([`eslint-${keyword}`, `eslint-${keyword}-line`, `eslint-${keyword}-next-line`].includes(disableType)) {
268
+ comment = disableType.includes('-next-line')
269
+ ? {
270
+ lineComment: loc.line,
271
+ lineDisabled: loc.line + 1,
272
+ rules: disableRules,
273
+ type: keyword
274
+ }
275
+ : {
276
+ lineComment: loc.line,
277
+ lineDisabled: loc.line,
278
+ rules: disableRules,
279
+ type: keyword
280
+ }
281
+ if (!disableType.includes('-line')) {
282
+ comment.lineDisabled = 'EOF'
283
+ }
284
+ }
285
+ listDisabled.push(comment)
286
+ })
287
+ }
288
+ })
289
+ for (const el of listDisabled.filter(
290
+ (d) => d.lineComment > line && (d.lineDisabled === 'EOF' || d.lineDisabled === line)
291
+ )) {
292
+ if (el.lineDisabled === 'EOF') {
293
+ el.lineDisabled = getLastLine(code)
294
+ }
295
+ if (el.rules) {
296
+ el.rules.forEach((rule) => {
297
+ if (el.type === 'disable') {
298
+ rulesDisabled[rule] = 'off'
299
+ } else if (el.type === 'enable') {
300
+ rulesDisabled[rule] = 'on'
301
+ }
302
+ })
303
+ }
304
+ }
305
+ }
306
+ }
307
+ return rulesDisabled
308
+ }
309
+
310
+ function getLastLine (code) {
311
+ const lines = typeof code === 'string' ? SourceCode.splitLines(code) : code
312
+ return lines.length - 1
313
+ }
314
+
315
+ function resolveFilePath (file) {
316
+ return path.isAbsolute(file) ? file : path.join(Cache.get('rootpath'), file)
317
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Levenshtein distance algorithm using recursive calls and cache
3
+ *
4
+ * @param input search the list for a best match for this string
5
+ * @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
8
+ */
9
+
10
+ const cache = {}
11
+
12
+ module.exports = (input, list, log, keepCase = false) => {
13
+ let minDistWords = []
14
+
15
+ if (input.length > 50 || list.length > 50) {
16
+ return minDistWords
17
+ }
18
+
19
+ let minDist = Number.MAX_SAFE_INTEGER
20
+
21
+ log && log('\nword\t\tlevDist\t\ttime(ms)')
22
+
23
+ let runtime = 0
24
+
25
+ for (const word of list) {
26
+ const start = log && Date.now()
27
+ let levDist
28
+ if (word === word.toUpperCase() && !keepCase) {
29
+ levDist = levDistance(input.toUpperCase(), word)
30
+ } else {
31
+ levDist = levDistance(input, word)
32
+ }
33
+
34
+ if (log) {
35
+ const duration = Date.now() - start
36
+ runtime = runtime + duration
37
+ log(`${word}\t\t${levDist}\t\t${duration}`)
38
+ }
39
+
40
+ if (levDist === minDist) {
41
+ minDistWords.push(word)
42
+ }
43
+
44
+ if (levDist < minDist) {
45
+ minDist = levDist
46
+ minDistWords = [word]
47
+ }
48
+ }
49
+
50
+ log && log(`runtime: ${runtime}ms`)
51
+
52
+ return minDistWords.sort()
53
+ }
54
+
55
+ const levDistance = (a, b) => {
56
+ if (cache[a] && cache[a][b]) {
57
+ return cache[a][b]
58
+ }
59
+
60
+ if (a.length === 0) {
61
+ return addToCache(a, b, b.length)
62
+ }
63
+
64
+ if (b.length === 0) {
65
+ return addToCache(a, b, a.length)
66
+ }
67
+
68
+ const tailA = a.substring(1)
69
+ const tailB = b.substring(1)
70
+
71
+ if (a[0] === b[0]) {
72
+ return levDistance(tailA, tailB)
73
+ }
74
+
75
+ const lev1 = levDistance(tailA, b)
76
+ const lev2 = levDistance(a, tailB)
77
+ const lev3 = levDistance(tailA, tailB)
78
+
79
+ const levDist = Math.min(lev1, lev2, lev3) + 1
80
+ return addToCache(a, b, levDist)
81
+ }
82
+
83
+ const addToCache = (a, b, value) => {
84
+ cache[a] = cache[a] || {}
85
+ cache[a][b] = value
86
+ return value
87
+ }