@sap/eslint-plugin-cds 4.1.0 → 4.1.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.
- package/CHANGELOG.md +11 -1
- package/lib/conf/index.js +17 -0
- package/lib/conf/java/all.js +5 -0
- package/lib/conf/java/experimental.js +5 -0
- package/lib/conf/java/recommended.js +3 -0
- package/lib/conf/js/all.js +1 -1
- package/lib/conf/js/recommended.js +1 -1
- package/lib/constants.js +1 -0
- package/lib/index.js +7 -5
- package/lib/languages/java/java-language.js +72 -0
- package/lib/languages/java/java-source-code.js +179 -0
- package/lib/rules/assoc2many-ambiguous-key.js +107 -130
- package/lib/rules/index.js +21 -3
- package/lib/rules/java/cql-class-targets.js +68 -0
- package/lib/types.d.ts +6 -0
- package/lib/utils/runRuleTester.js +1 -1
- package/package.json +5 -2
- /package/lib/rules/js/{use-cql-select-template-strings.js → cql-template-strings.js} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,8 +6,18 @@ 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.
|
|
9
|
+
## [4.1.2] - 2026-02-13
|
|
10
|
+
### Fixed
|
|
11
|
+
- No longer crash on .java files larger than 32 kB.
|
|
12
|
+
- Weaken `java/cql-class-targets` to only warn when a string literal is passed as parameter.
|
|
13
|
+
- `assoc2many-ambiguous-key` no longer falsely warns about 1-n joins when an infix filter reduces the joined relation to a single row.
|
|
14
|
+
|
|
15
|
+
## [4.1.1] - 2025-12-04
|
|
16
|
+
### Added
|
|
17
|
+
- Top level type definitions for package export
|
|
18
|
+
|
|
10
19
|
|
|
20
|
+
## [4.1.0] - 2025-08-18
|
|
11
21
|
### Added
|
|
12
22
|
- Add new rule `case-sensitive-well-known-events` to detect when a well known event is not cased correctly.
|
|
13
23
|
|
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
|
}
|
package/lib/conf/js/all.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module.exports = {
|
|
4
4
|
'@sap/cds/no-shared-handler-variable': 'error',
|
|
5
|
-
'@sap/cds/
|
|
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
8
|
'@sap/cds/case-sensitive-well-known-events': 'warn',
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module.exports = {
|
|
4
4
|
'@sap/cds/no-shared-handler-variable': 'error',
|
|
5
|
-
'@sap/cds/
|
|
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
8
|
'@sap/cds/case-sensitive-well-known-events': 'warn',
|
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,18 +18,20 @@
|
|
|
18
18
|
|
|
19
19
|
const api = require('./api')
|
|
20
20
|
const getConfigs = require('./conf')
|
|
21
|
-
const
|
|
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
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { TextEncoder } = require('node:util')
|
|
4
|
+
const TreeSitterParser = require('tree-sitter')
|
|
5
|
+
const Java = require('tree-sitter-java')
|
|
6
|
+
const { JavaSourceCode } = require('./java-source-code')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} JavaLanguageOptions
|
|
10
|
+
* @typedef {{languageOptions: JavaLanguageOptions}} JavaLanguageContext
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const jp = new TreeSitterParser()
|
|
14
|
+
jp.setLanguage(Java)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {import('@eslint/core').File} file
|
|
18
|
+
* @param {JavaLanguageContext} context
|
|
19
|
+
*/
|
|
20
|
+
function parseJavaCode (file /*, context*/) {
|
|
21
|
+
const code = file.body
|
|
22
|
+
try {
|
|
23
|
+
// https://stackoverflow.com/a/79771252
|
|
24
|
+
const codeByteSize = new TextEncoder().encode(code).length
|
|
25
|
+
const treeSitterAst = jp.parse(code, undefined, { bufferSize: codeByteSize + 32 }) // adding some extra buffer space
|
|
26
|
+
if (!treeSitterAst) return { ast: null, ok: false, errors: [] }
|
|
27
|
+
if (!treeSitterAst.rootNode) return { ast: treeSitterAst, ok: false, errors: [] }
|
|
28
|
+
return { ast: treeSitterAst, ok: true, errors: [] }
|
|
29
|
+
} catch (e) {
|
|
30
|
+
// most likely a parsing error. This could mean we have to update the parser for newer Java versions
|
|
31
|
+
return { ast: null, ok: false, errors: [e] }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import('@eslint/core').File} file
|
|
37
|
+
* @param {ReturnType<typeof parseJavaCode>} parseResult
|
|
38
|
+
* @param {JavaLanguageContext} context
|
|
39
|
+
*/
|
|
40
|
+
function createJavaSourceCode(file, parseResult /*, context*/) {
|
|
41
|
+
if (!parseResult.ok || !parseResult.ast) {
|
|
42
|
+
throw new Error('createJavaSourceCode: parseResult is not ok or has no AST', {
|
|
43
|
+
ok: parseResult.ok,
|
|
44
|
+
hasAst: !!parseResult.ast
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
return new JavaSourceCode({ text: file.body, ast: parseResult.ast })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {JavaLanguageOptions} languageOptions
|
|
52
|
+
*/
|
|
53
|
+
function validateJavaLanguageOptions(/*languageOptions*/) {
|
|
54
|
+
// No specific options to validate for Java currently
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @link https://eslint.org/docs/latest/extend/languages#the-language-object
|
|
59
|
+
*/
|
|
60
|
+
const javaLanguage = /** @type {const}*/({
|
|
61
|
+
fileType: 'text',
|
|
62
|
+
lineStart: 0,
|
|
63
|
+
columnStart: 0,
|
|
64
|
+
nodeTypeKey: 'type',
|
|
65
|
+
validateLanguageOptions: validateJavaLanguageOptions,
|
|
66
|
+
parse: parseJavaCode,
|
|
67
|
+
createSourceCode: createJavaSourceCode
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
javaLanguage,
|
|
72
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -4,12 +4,47 @@ const cds = require('@sap/cds')
|
|
|
4
4
|
|
|
5
5
|
/** @type {import('../types').Rule} */
|
|
6
6
|
|
|
7
|
+
const isAssociationOrComposition = element => ['cds.Association', 'cds.Composition'].includes(element?.type)
|
|
8
|
+
const isEntity = element => element?.kind === 'entity'
|
|
9
|
+
const isStarCardinality = element => element?.cardinality?.max === '*'
|
|
10
|
+
/**
|
|
11
|
+
* Using infix filters, one-to-many associations can be reduced to one-to-one.
|
|
12
|
+
* @example
|
|
13
|
+
* ```cds
|
|
14
|
+
* SELECT name, books[1: favorite=true].title from Authors
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
const isScalarInfixFilter = element => element?.ref?.[0]?.cardinality?.max === 1
|
|
18
|
+
|
|
19
|
+
function resolveSourceAlias (csn, definition) {
|
|
20
|
+
const from = definition.query.SELECT.from
|
|
21
|
+
let sourceEntity
|
|
22
|
+
const sourceAlias = []
|
|
23
|
+
if (from?.ref) {
|
|
24
|
+
// From
|
|
25
|
+
sourceEntity = csn.definitions[from.ref.join('_')]
|
|
26
|
+
sourceAlias.push({
|
|
27
|
+
from: sourceEntity.name,
|
|
28
|
+
as: from.as ?? from.ref.at(-1).split('.').pop()
|
|
29
|
+
})
|
|
30
|
+
} else if (from?.args?.[0].ref) {
|
|
31
|
+
// Join
|
|
32
|
+
sourceEntity = csn.definitions[from.args[0].ref.join('_')]
|
|
33
|
+
for (const arg of from.args) {
|
|
34
|
+
sourceAlias.push({
|
|
35
|
+
from: arg.ref.join('_'),
|
|
36
|
+
as: arg.as ?? arg.ref.at(-1).split('.').pop()
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { sourceEntity, sourceAlias }
|
|
41
|
+
}
|
|
42
|
+
|
|
7
43
|
module.exports = {
|
|
8
44
|
meta: {
|
|
9
45
|
schema: [{/* to avoid deprecation warning for ESLint 9 */}],
|
|
10
46
|
docs: {
|
|
11
|
-
description:
|
|
12
|
-
'Ambiguous key with a `TO MANY` relationship since entries could appear multiple times with the same key.',
|
|
47
|
+
description: 'Ambiguous key with a `TO MANY` relationship since entries could appear multiple times with the same key.',
|
|
13
48
|
category: 'Model Validation',
|
|
14
49
|
recommended: true,
|
|
15
50
|
url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/assoc2many-ambiguous-key',
|
|
@@ -26,8 +61,7 @@ module.exports = {
|
|
|
26
61
|
function checkAssocs () {
|
|
27
62
|
let csnOdata
|
|
28
63
|
const m = context.getModel()
|
|
29
|
-
if (
|
|
30
|
-
if (m && m.definitions) {
|
|
64
|
+
if (m?.definitions) {
|
|
31
65
|
try {
|
|
32
66
|
csnOdata = cds.compile.for.odata(m)
|
|
33
67
|
const csnOdataLinked = cds.linked(csnOdata)
|
|
@@ -42,142 +76,85 @@ module.exports = {
|
|
|
42
76
|
}
|
|
43
77
|
|
|
44
78
|
/**
|
|
45
|
-
* @param {
|
|
46
|
-
* @param {CDSRuleContext} context
|
|
79
|
+
* @param {import('../types').CSN} csn
|
|
80
|
+
* @param {import('../types').CDSRuleContext} context
|
|
47
81
|
*/
|
|
48
82
|
function associationCardinalityFlaw (csn, context) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
definition,
|
|
55
|
-
sourceEntity,
|
|
56
|
-
sourceAlias,
|
|
57
|
-
() => {
|
|
58
|
-
refCardinalityMult = false
|
|
59
|
-
refPlainElement = false
|
|
60
|
-
},
|
|
61
|
-
(refEntity, refElement) => {
|
|
62
|
-
if (refElement.type === 'cds.Association' || refElement.type === 'cds.Composition') {
|
|
63
|
-
if (refElement.cardinality && refElement.cardinality.max === '*') {
|
|
64
|
-
refCardinalityMult = true
|
|
65
|
-
}
|
|
66
|
-
} else {
|
|
67
|
-
refPlainElement = true
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
column => {
|
|
71
|
-
if (
|
|
72
|
-
definition.keys &&
|
|
73
|
-
Object.keys(definition.keys).length === 1 &&
|
|
74
|
-
Object.keys(definition.keys)[0] === 'ID' &&
|
|
75
|
-
refCardinalityMult &&
|
|
76
|
-
refPlainElement
|
|
77
|
-
) {
|
|
78
|
-
const keyName = Object.keys(definition.keys)[0]
|
|
79
|
-
const key = definition.keys[keyName]
|
|
80
|
-
const keyLoc = context.getLocation(keyName, key, csn)
|
|
81
|
-
const colName = column.as ? column.as : column.name
|
|
82
|
-
context.report({
|
|
83
|
-
messageId: 'ambiguous',
|
|
84
|
-
data: { name: definition.name, 'column-name': colName, 'key-name': keyName },
|
|
85
|
-
loc: keyLoc,
|
|
86
|
-
file: key.$location.file
|
|
87
|
-
})
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
)
|
|
91
|
-
})
|
|
83
|
+
for (const [name, definition] of Object.entries(csn.definitions)) {
|
|
84
|
+
if (!name.startsWith('localized.')) {
|
|
85
|
+
processEntity({ definition, csn, context})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
92
88
|
}
|
|
93
89
|
|
|
94
90
|
/**
|
|
95
|
-
* @param {
|
|
96
|
-
* @param {Function} eachCallback
|
|
91
|
+
* @param {import('../types').CSN} csn
|
|
97
92
|
*/
|
|
98
|
-
function processEntity (csn,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
definition.query.SELECT &&
|
|
108
|
-
definition.query.SELECT.columns
|
|
109
|
-
) {
|
|
110
|
-
let sourceEntity
|
|
111
|
-
const sourceAlias = []
|
|
112
|
-
if (definition.query.SELECT.from.ref) {
|
|
113
|
-
// From
|
|
114
|
-
sourceEntity = csn.definitions[definition.query.SELECT.from.ref.join('_')]
|
|
115
|
-
sourceAlias.push({
|
|
116
|
-
from: sourceEntity.name,
|
|
117
|
-
as: definition.query.SELECT.from.as || definition.query.SELECT.from.ref.slice(-1)[0].split('.').pop()
|
|
118
|
-
})
|
|
119
|
-
} else if (definition.query.SELECT.from.args && definition.query.SELECT.from.args[0].ref) {
|
|
120
|
-
// Join
|
|
121
|
-
sourceEntity = csn.definitions[definition.query.SELECT.from.args[0].ref.join('_')]
|
|
122
|
-
definition.query.SELECT.from.args.forEach(arg => {
|
|
123
|
-
sourceAlias.push({
|
|
124
|
-
from: arg.ref.join('_'),
|
|
125
|
-
as: arg.as || arg.ref.slice(-1)[0].split('.').pop()
|
|
126
|
-
})
|
|
127
|
-
})
|
|
128
|
-
}
|
|
129
|
-
if (!sourceEntity) {
|
|
130
|
-
return
|
|
131
|
-
}
|
|
132
|
-
eachCallback(definition, sourceEntity, sourceAlias)
|
|
133
|
-
}
|
|
134
|
-
})
|
|
93
|
+
function processEntity ({ definition, csn, context}) {
|
|
94
|
+
if (!isEntity(definition) || !definition.query?.SELECT?.columns) return
|
|
95
|
+
|
|
96
|
+
const { sourceEntity, sourceAlias } = resolveSourceAlias(csn, definition)
|
|
97
|
+
if (!sourceEntity) return
|
|
98
|
+
|
|
99
|
+
for (const column of definition.query.SELECT.columns) {
|
|
100
|
+
processElement({ csn, definition, column, sourceEntity, sourceAlias, context })
|
|
101
|
+
}
|
|
135
102
|
}
|
|
136
103
|
|
|
137
104
|
/**
|
|
138
|
-
* @param {object}
|
|
139
|
-
* @param {
|
|
140
|
-
* @param {object}
|
|
141
|
-
* @param {
|
|
142
|
-
* @param {
|
|
143
|
-
* @param {
|
|
144
|
-
* @param {
|
|
105
|
+
* @param {object} p
|
|
106
|
+
* @param {import('../types').CSN} p.csn
|
|
107
|
+
* @param {object} p.column
|
|
108
|
+
* @param {object} p.definition
|
|
109
|
+
* @param {object} p.sourceEntity
|
|
110
|
+
* @param {string} p.sourceAlias
|
|
111
|
+
* @param {import('../types').CDSRuleContext} p.context
|
|
145
112
|
*/
|
|
146
|
-
function processElement (csn, definition, sourceEntity, sourceAlias,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
} else {
|
|
162
|
-
refElement = refEntity.elements[ref]
|
|
113
|
+
function processElement ({csn, definition, column, sourceEntity, sourceAlias, context}) {
|
|
114
|
+
if (!(column.ref && column.ref.length > 1)) return
|
|
115
|
+
|
|
116
|
+
let refPlainElement = false
|
|
117
|
+
let refCardinalityMult = false
|
|
118
|
+
let refEntity = sourceEntity
|
|
119
|
+
const refAlias = sourceAlias
|
|
120
|
+
for (const ref of column.ref.map(ref => ref.id ?? ref)) {
|
|
121
|
+
// Alias
|
|
122
|
+
const matchAlias = refAlias.find(alias => alias.as === ref)
|
|
123
|
+
let refElement
|
|
124
|
+
if (matchAlias) {
|
|
125
|
+
refEntity = csn.definitions[matchAlias.from]
|
|
126
|
+
} else {
|
|
127
|
+
refElement = refEntity.elements[ref]
|
|
163
128
|
// Mixin
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
refAlias = []
|
|
179
|
-
})
|
|
180
|
-
afterCallback(column)
|
|
129
|
+
?? definition.elements[ref]
|
|
130
|
+
?? definition.query.SELECT.mixin[ref]
|
|
131
|
+
?? definition.query.SELECT.mixin[column.ref[0]]?._target?.elements[ref]
|
|
132
|
+
|
|
133
|
+
if (isAssociationOrComposition(refElement)) {
|
|
134
|
+
refCardinalityMult = isStarCardinality(refElement)
|
|
135
|
+
} else {
|
|
136
|
+
refPlainElement = true
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (isAssociationOrComposition(refElement)) {
|
|
141
|
+
refEntity = csn.definitions[refElement.target]
|
|
181
142
|
}
|
|
182
|
-
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const keyNames = Object.keys(definition.keys ?? {})
|
|
146
|
+
const [firstkey] = keyNames
|
|
147
|
+
if (keyNames.length === 1 && firstkey === 'ID' && refCardinalityMult && refPlainElement && !isScalarInfixFilter(column)) {
|
|
148
|
+
const key = definition.keys[firstkey]
|
|
149
|
+
context.report({
|
|
150
|
+
messageId: 'ambiguous',
|
|
151
|
+
data: {
|
|
152
|
+
name: definition.name,
|
|
153
|
+
'column-name': column.as ?? column.name,
|
|
154
|
+
'key-name': firstkey
|
|
155
|
+
},
|
|
156
|
+
loc: context.getLocation(firstkey, key, csn),
|
|
157
|
+
file: key.$location.file
|
|
158
|
+
})
|
|
159
|
+
}
|
|
183
160
|
}
|
package/lib/rules/index.js
CHANGED
|
@@ -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
|
-
|
|
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',
|
|
36
|
+
globalCache.set('rules', allRules)
|
|
25
37
|
|
|
26
|
-
module.exports =
|
|
38
|
+
module.exports = {
|
|
39
|
+
allRules,
|
|
40
|
+
jsRules,
|
|
41
|
+
javaRules,
|
|
42
|
+
cdsRules,
|
|
43
|
+
initialiseRules
|
|
44
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
const isString = node =>
|
|
37
|
+
node?.type === 'string_literal'
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
'import_declaration > scoped_identifier': function(node) {
|
|
41
|
+
if (!node) return
|
|
42
|
+
if (node.nameNode) {
|
|
43
|
+
knownClasses.add(node.nameNode.text)
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
"method_invocation > identifier[text='from']": function(node) {
|
|
48
|
+
if (!node?.parent?.argumentsNode) return
|
|
49
|
+
const args = node.parent.argumentsNode.children
|
|
50
|
+
.filter(c => c.constructor.name !== 'SyntaxNode') // filter out commas, parens, etc.
|
|
51
|
+
?? []
|
|
52
|
+
// we actually want to detect anything that is NOT a class literal,
|
|
53
|
+
// but there are some edge cases we need to care for in the future,
|
|
54
|
+
// such as parameters passing in a class:
|
|
55
|
+
// void myMethod(Class<MyClass> cls) { Select.from(cls); }
|
|
56
|
+
// for the time being, we will just warn on string literals
|
|
57
|
+
//if (!refersToClass(args[0])) {
|
|
58
|
+
if (isString(args[0])) {
|
|
59
|
+
context.report({
|
|
60
|
+
node: args[0],
|
|
61
|
+
messageId: 'selectOnNonClass',
|
|
62
|
+
data: { target: args[0].text }
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
package/lib/types.d.ts
CHANGED
|
@@ -44,3 +44,9 @@ export interface CDSRuleTestOpts {
|
|
|
44
44
|
/** list of errors from ESLint's [RuleTester](https://eslint.org/docs/developer-guide/nodejs-api#ruletester) */
|
|
45
45
|
errors: CDSTestCaseError[]
|
|
46
46
|
}
|
|
47
|
+
|
|
48
|
+
// FIXME: this is just a dummy and needs to go
|
|
49
|
+
// once we consolidate the various CSN types across packages
|
|
50
|
+
export type CSN = {
|
|
51
|
+
definitions: {}
|
|
52
|
+
}
|
|
@@ -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.1.
|
|
3
|
+
"version": "4.1.2",
|
|
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
|
-
"
|
|
23
|
+
"@eslint/plugin-kit": "^0.6.0",
|
|
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",
|
|
File without changes
|