@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 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.0] - 2025-08-18
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
  }
@@ -0,0 +1,5 @@
1
+ 'use strict'
2
+
3
+ module.exports = {
4
+ '@sap/cds/cql-class-targets': 'warn',
5
+ }
@@ -0,0 +1,5 @@
1
+ 'use strict'
2
+
3
+ module.exports = {
4
+ '@sap/cds/cql-class-targets': 'warn',
5
+ }
@@ -0,0 +1,3 @@
1
+ 'use strict'
2
+
3
+ module.exports = {}
@@ -2,7 +2,7 @@
2
2
 
3
3
  module.exports = {
4
4
  '@sap/cds/no-shared-handler-variable': 'error',
5
- '@sap/cds/use-cql-select-template-strings': 'error',
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/use-cql-select-template-strings': 'error',
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 rules = Object.assign(
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 (!m) return
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 {object} csn
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
- processEntity(csn, (definition, sourceEntity, sourceAlias) => {
50
- let refCardinalityMult = false
51
- let refPlainElement = false
52
- processElement(
53
- csn,
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 {object} csn
96
- * @param {Function} eachCallback
91
+ * @param {import('../types').CSN} csn
97
92
  */
98
- function processEntity (csn, eachCallback) {
99
- Object.keys(csn.definitions).forEach(name => {
100
- if (name.startsWith('localized.')) {
101
- return
102
- }
103
- const definition = csn.definitions[name]
104
- if (
105
- definition.kind === 'entity' &&
106
- definition.query &&
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} csn
139
- * @param {object} definition
140
- * @param {object} sourceEntity
141
- * @param {string} sourceAlias
142
- * @param {Function} beforeCallback
143
- * @param {Function} eachCallback
144
- * @param {Function} afterCallback
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, beforeCallback, eachCallback, afterCallback) {
147
- definition.query.SELECT.columns.forEach(column => {
148
- if (column.ref && column.ref.length > 1) {
149
- let refEntity = sourceEntity
150
- let refAlias = sourceAlias
151
- beforeCallback()
152
- column.ref.forEach(ref => {
153
- ref = ref.id || ref
154
- // Alias
155
- const matchAlias = refAlias.find(alias => {
156
- return alias.as === ref
157
- })
158
- let refElement
159
- if (matchAlias) {
160
- refEntity = csn.definitions[matchAlias.from]
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
- if (!refElement) {
165
- refElement = definition.elements[ref]
166
- if (!refElement && definition.query.SELECT.mixin) {
167
- refElement = definition.query.SELECT.mixin[ref]
168
- if (!refElement && definition.query.SELECT.mixin[column.ref[0]]) {
169
- refElement = definition.query.SELECT.mixin[column.ref[0]]._target.elements[ref]
170
- }
171
- }
172
- }
173
- eachCallback(refEntity, refElement)
174
- if (refElement.type === 'cds.Association' || refElement.type === 'cds.Composition') {
175
- refEntity = csn.definitions[refElement.target]
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
  }
@@ -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
- const rules = {...cdsRules, ...jsRules}
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', rules)
36
+ globalCache.set('rules', allRules)
25
37
 
26
- module.exports = rules
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.0",
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
- "semver": "^7.7.1"
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",