@sap/eslint-plugin-cds 2.1.0 → 2.3.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.
@@ -3,298 +3,264 @@
3
3
  * https://eslint.org/docs/developer-guide/working-with-rules
4
4
  *
5
5
  * Each ESLint rule module must be composed of a 'meta', 'create' object:
6
- * - 'meta': meta data for rule (docs, fixable, etc.)
7
- * - 'create': ingests rule context and returns object with nodes to visit
8
- * while traversing the AST
6
+ * - 'meta': meta data for rule (docs, fixable, etc.)
7
+ * - 'create': ingests rule context and returns object with nodes to visit
8
+ * while traversing the AST
9
9
  *
10
- * Since for the cds we want to lint without an AST, we simplified the context
11
- * object to explicitly pass a subset of ESLint's context arguments:
12
- * - 'code': code object based on ESLint getFromSourceCode()
13
- * - 'filePath': ESLint getFilename()
14
- * We add two custom properties to aid in model rule creation:
15
- * - 'model': Loaded cds model (CSN)
16
- * - 'cds': Proxy for cached cds calls
10
+ * Since we want to lint cds models without an AST, we adapted the context
11
+ * object to explicitly pass some additional information:
12
+ * - 'category' : Rule category
13
+ * - 'cds' : Proxy for cds object which also caches results
14
+ * - 'code' : Code object based on ESLint getFromSourceCode()
15
+ * - 'ruleID' : Rule ID ("@sap/cds/...")
16
+ * - 'filePath' : ESLint's 'physical' filename
17
+ * - 'options' : CDS Environment parameters
18
+ * - 'report' : Proxy for ESLint's context.report() for lint filtering
19
+ * - 'ruleID' : ESLint's rule ID
20
+ * - 'sourcecode': ESLint's SourceCode object
21
+ *
22
+ * @typedef { import('eslint').Rule.RuleModule } RuleModule
23
+ * @typedef { import('eslint').Rule.RuleContext } RuleContext
24
+ * @typedef { import('eslint').Rule.Node } RuleNode
25
+ * @typedef { import("./types").CDSRuleSpec } CDSRuleSpec
26
+ * @typedef { import("./types").CDSRuleMetaData } CDSRuleMetaData
27
+ * @typedef { import("./types").CDSRuleContext } CDSRuleContext
28
+ * @typedef { import("./types").CDSRuleTestOpts } CDSRuleTestOpts
17
29
  */
18
30
 
19
31
  const fs = require("fs");
20
32
  const path = require("path");
21
- const { RuleTester } = require("eslint");
22
- const { isEditor } = require("./utils/helpers");
23
- const { Cache, getConfigPath, loadModel, updateModel, updateConfigPath } = require("./utils/model");
24
- const { getFileExtensions, getDisabledFromComments, getEnvRules, getModelRules } = require("./utils/rules");
25
- const envRules = getEnvRules(path.join(__dirname, "rules"));
26
- const modelRules = getModelRules(path.join(__dirname, "rules"));
27
- const rules = envRules.concat(modelRules).map((rule) => `@sap/cds/${rule}`);
28
-
29
- const CONSTANTS = require("./constants");
30
-
31
- // Types:
32
- // Interface CDSRuleMetaData extends Rule.RuleMetaData {
33
- // docs?: Rule.RuleMetaData['docs'] & {
34
- // version?: string;
35
- // }
36
- // }
37
- // Interface CDSRuleContext {
38
- // (cds: any,
39
- // context: Rule.RuleContext,
40
- // node: Rule.Node): Rule.ReportDescriptor[]
41
- // }
42
- // CDSReport = Rule.ReportDescriptor & { file?: string };
43
- // RuleModule = Rule.RuleModule;
33
+ const { RuleTester, SourceCode } = require("eslint");
34
+ const { isEditor, isValidFile } = require("./utils/helpers");
35
+ const { isValidEnv, isValidModel } = require("./utils/validate");
36
+ const {
37
+ Cache,
38
+ populateModelAndEnv,
39
+ hasCompilationError,
40
+ getAST,
41
+ initModelRuleTester,
42
+ loadConfigPath,
43
+ } = require("./utils/model");
44
+ const { isRuleDisabled, getRules, populateRules } = require("./utils/rules");
45
+ const { customRulesDir, categories } = require("./constants");
44
46
 
45
47
  /**
46
- * ESLint Rule creator:
48
+ * Wrapper for ESLint's Rule creator:
47
49
  * https://eslint.org/docs/developer-guide/working-with-rules
48
50
  * - Must follow the ESLint prescribed convention for all rule exports
49
- * - defineMeta:
50
- * - defineReport: Our wrapper for ESLint's 'create' rule function used by ESLint to traverse
51
- * its AST nodes. Since we do not work with an AST, we just access our dummy Programm node
52
- * and collect the required cds parameters for our rules
53
- *
54
- * @param defineMeta
55
- * @param defineReport
56
- * @returns
51
+ * - ESLint uses 'create' function to traverse its AST nodes
52
+ * - Since we do not work with an AST for cds models, a dummy 'Programm'
53
+ * node is used as an entry point
54
+ * - For all ESLint rules, we have two entry points for additional checks:
55
+ * 1. Before ESLint's rule creation via create()
56
+ * (see below)
57
+ * 2. Before ESLint's report via context.report()
58
+ * (see getProxyReport())
59
+ * @param {CDSRuleSpec} spec
60
+ * @returns {RuleModule}
57
61
  */
58
-
59
- // Types: { defineMeta: CDSRuleMetaData, defineReport: CDSRuleContext): RuleModule }
60
- function createRule(defineMeta, defineReport) {
62
+ function createRule(spec) {
63
+ const { meta, create } = spec;
61
64
  return {
62
- meta: {
63
- ...defineMeta,
64
- },
65
- // Types: { context: Rule.RuleContext }
66
- create(context) {
67
- {
68
- return cbRuleFunctions(context, defineReport);
69
- }
65
+ meta,
66
+ create: function (context) {
67
+ return {
68
+ Program: function (node) {
69
+ // --- Checks before create() ---
70
+ let cdscontext = createCDSContext(context, node, meta);
71
+ // 1. Is file exension allowed?
72
+ // (i.e. when using globs + other plugins)
73
+ if (
74
+ isValidFile(cdscontext.filePath) ||
75
+ meta.docs.category === categories["env"]
76
+ ) {
77
+ // Update model/env contents and rules (at runtime)
78
+ populateModelAndEnv(cdscontext);
79
+ populateRules(cdscontext, customRulesDir);
80
+ // 2. Is rule allowed and model/env valid?
81
+ if (isValidEnv(cdscontext) || isValidModel(cdscontext)) {
82
+ try {
83
+ create(cdscontext);
84
+ } catch (err) {
85
+ if (isEditor()) { // Do not throw to avoid ESLint VSCode editor pop-ups
86
+ console.error(`An error occurred while linting. Rule: ${cdscontext.ruleID}\n`, err);
87
+ } else {
88
+ throw err;
89
+ }
90
+ }
91
+ // Show compilation error only on console
92
+ } else if (hasCompilationError(cdscontext) && !isEditor()) {
93
+ create(cdscontext);
94
+ }
95
+ }
96
+ },
97
+ };
70
98
  },
71
99
  };
72
100
  }
73
101
 
74
102
  /**
75
- * More eslint-like API with more convenience re error reports
103
+ * Experimental wrapper for 'createRule' to yield:
104
+ * - More eslint-like API
105
+ * - More convenience re error reports
106
+ * @param {CDSRuleSpec} spec
107
+ * @returns {RuleModule}
76
108
  */
77
- function defineRule (spec) {
78
- const { meta, create } = spec
79
- if (!meta.type) meta.type = 'problem'
80
- if (meta.docs && !meta.docs.category) meta.docs.category = "Model Validation"
81
- return createRule (meta, (report, cds) => {
82
- const handlers = create({ cds, model:cds.model, report:(r)=>report.push(r) })
83
- cds.model.forall (d => {
84
- for (let each in handlers) if (d.is(each)) {
85
- let r = handlers[each](d)
86
- if (r) {
87
- if (typeof r === 'string') r = { message: r }
88
- if (!r.loc) r.loc = cds.getLocation(d.name,d)
89
- if (!r.file) r.file = d.$location && d.$location.file || 'unknown.cds'
90
- }
91
- report.push (r)
109
+ function defineRule(spec) {
110
+ const { meta, create } = spec;
111
+ if (!meta.type) meta.type = "problem";
112
+ if (meta.docs && !meta.docs.category) meta.docs.category = "Model Validation";
113
+ return createRule({
114
+ meta,
115
+ create: (context) => {
116
+ const { cds, report } = context;
117
+ const handlers = create({
118
+ cds,
119
+ model: cds.model,
120
+ report: (r) => report(r),
121
+ });
122
+ if (cds.model) {
123
+ cds.model.forall((d) => {
124
+ for (let each in handlers)
125
+ if (d.is(each)) {
126
+ let r = handlers[each](d);
127
+ if (r) {
128
+ if (typeof r === "string") r = { message: r };
129
+ if (!r.loc) r.loc = cds.getLocation(d.name, d);
130
+ if (!r.file)
131
+ r.file = (d.$location && d.$location.file) || "unknown.cds";
132
+ context.report(r);
133
+ }
134
+ }
135
+ });
92
136
  }
93
- })
94
- return report
95
- })
137
+ },
138
+ });
96
139
  }
97
140
 
98
-
99
141
  /**
100
- * ESLint Rule creator callback object
101
- * - Must return an object with nodes ESLint can visit while traversing the AST
102
- * (here, an empty Program node)
103
- * - Must ingest 'context' object which contains info in each rule's context
104
- * https://eslint.org/docs/developer-guide/working-with-rules#the-context-object
105
- *
106
- * @param context rule context info
107
- * @param defineReport rule report
108
- * @returns rule callback function
142
+ * Generates proxy for ESLint's context object which adds caching
143
+ * @param obj ESLint's context object
144
+ * @returns Proxy for cds
109
145
  */
110
- // Types: { context: Rule.RuleContext, defineReport: CDSRuleContext }
111
- function cbRuleFunctions(context, defineReport) {
112
- return {
113
- Program(node) {
114
- let report = [];
115
-
116
- const ruleID = context.id;
117
- const cds = context.parserServices.cdsProxy;
118
- const sourcecode = context.getSourceCode();
119
- const code = sourcecode.getText(node);
120
- const filePath = context.getFilename();
121
-
122
- let category = CONSTANTS.categories.model;
123
- if (envRules.includes(ruleID.replace("@sap/cds/", ""))) {
124
- category = CONSTANTS.categories.env;
146
+ function getProxyReport(obj) {
147
+ const handler = {
148
+ get(target, prop, receiver) {
149
+ const value = Reflect.get(target, prop, receiver);
150
+ if (typeof value !== "object") {
151
+ return value;
125
152
  }
126
-
127
- // Set configPath according to filePath
128
- let configPath = path.dirname(getConfigPath(filePath));
129
- Cache.set("configpath", configPath);
130
-
131
- // Get cds model for current project
132
- loadModel(code, configPath, filePath);
133
-
134
- // Update config path (any files not part of the above model)
135
- // Can only do this if model.$sources are known, otherwise no
136
- // way to distinguish between 'model' vs 'outsider' files
137
- if (cds && cds.model && !cds.model.err) {
138
- updateConfigPath(code, configPath, filePath);
139
- configPath = Cache.get("configpath");
140
- }
141
-
142
- // Update cds model on every 'type' event
143
- if (
144
- Cache.has(`file:${filePath}`) &&
145
- code !== Cache.get(`file:${filePath}`)
146
- ) {
147
- // Update file contents in Cache
148
- Cache.set(`file:${filePath}`, code);
149
- updateModel(code, configPath, filePath);
153
+ /* eslint no-extra-boolean-cast: "off" */
154
+ if (!!value) {
155
+ return new Proxy(value, handler);
150
156
  }
151
-
152
- // Get cds environment (when called from ruleTester)
153
- if (context.options[0] && context.options[0].environment) {
154
- Cache.set(`environment`, context.options[0].environment);
155
- }
156
-
157
- // Get report for triggered cds 'environment' and 'model' rules
158
- const properties = { filePath, configPath, ruleID, code, sourcecode };
159
- if (cds && isValidModel(cds, ruleID)) {
160
- if (
161
- (category === CONSTANTS.categories.model && cds.model &&
162
- getFileExtensions().includes(`*${path.extname(filePath)}`)) ||
163
- (category === CONSTANTS.categories.env && cds.environment)
164
- ) {
165
- report = defineReport(report, cds, sourcecode, node, filePath);
166
- }
167
- if (report.length > 0) {
168
- reportErrors(report, context, properties);
169
- }
170
- } else {
171
- // Add any compilation errors to ESLint report, except when plugin
172
- // is used within VS Code ESLint extension editor as it would duplicate
173
- // CDS compile messages
174
- if (hasCompilationError(cds, ruleID) && !isEditor()) {
175
- report = defineReport(report, cds, sourcecode, node);
176
- if (report.length > 0) {
177
- reportErrors(report, context, properties);
157
+ return {
158
+ err: `Property ${prop} prop does not exist on object ${obj}!`,
159
+ };
160
+ },
161
+ apply(target, thisArg, argumentsList) {
162
+ let report = false;
163
+ if (argumentsList.length > 0) {
164
+ argumentsList.forEach((lint) => {
165
+ if (lint) {
166
+ // --- Checks before context.report() ---
167
+ // 1. Is lint (loc) not disabled by ESLint disable comments?
168
+ if (!isRuleDisabled(lint, thisArg)) {
169
+ const fileRel = lint.file;
170
+ let fileAbs = lint.file || "";
171
+ if (!path.isAbsolute(fileAbs)) {
172
+ fileAbs = fileRel
173
+ ? path.join(Cache.get("configpath"), fileRel)
174
+ : "";
175
+ }
176
+ // Only show 'env' lints on console
177
+ if (thisArg.category === "env" && !isEditor()) {
178
+ lint["loc"] = {
179
+ start: { line: 0, column: -1 },
180
+ end: { line: 0, column: -1 },
181
+ };
182
+ report = true;
183
+ // Only show 'model' lints at corrsponding file
184
+ } else if (
185
+ fileAbs === thisArg.filePath ||
186
+ fileRel === "<stdin>.cds"
187
+ ) {
188
+ report = true;
189
+ }
190
+ if (report) {
191
+ return thisArg._context.report(lint);
192
+ }
193
+ }
178
194
  }
179
- }
195
+ });
180
196
  }
181
197
  },
182
198
  };
199
+ return new Proxy(obj, handler);
183
200
  }
184
201
 
185
202
  /**
186
- * Checks whether the compiled cds model or environment
187
- * is eligible for performing the plugin's rule checks
188
- * @param cds cds object
189
- * @param ruleID rule name
190
- * @returns
203
+ * Expands CDS context object with some CDS properties
204
+ * We also retrieve the file contents cached by the preprocessor
205
+ * @param {RuleContext} context
206
+ * @param {RuleNode} node
207
+ * @returns cdscontext
191
208
  */
192
- function isValidModel(cds, ruleID) {
193
- if (
194
- (cds && cds.model && !cds.model.err) ||
195
- (cds.environment &&
196
- !(
197
- ruleID === "@sap/cds/cds-compile-error" ||
198
- ruleID === "cds-compile-error"
199
- ))
200
- ) {
201
- return true;
209
+ function createCDSContext(context, node, meta) {
210
+ const filePath = context.getPhysicalFilename();
211
+ let configPath;
212
+ if (!Cache.has("pluginpath")) {
213
+ configPath = loadConfigPath(filePath);
214
+ } else {
215
+ configPath = Cache.get("configpath")
202
216
  }
203
- return false;
204
- }
205
-
206
- /**
207
- * Checks whether the compiled cds model contains compilation errors
208
- * which should only be reported via the 'cds-compile-error' rule
209
- * @param cds cds object
210
- * @param ruleID rule name
211
- * @returns
212
- */
213
- function hasCompilationError(cds, ruleID) {
214
- if (
215
- cds &&
216
- cds.model &&
217
- cds.model.err &&
218
- cds.model.err.message.startsWith("CDS compilation failed")
219
- ) {
220
- if (
221
- ruleID === "@sap/cds/cds-compile-error" ||
222
- ruleID === "cds-compile-error"
223
- ) {
224
- cds.model.err
225
- return true;
226
- }
217
+ let category = "model";
218
+ if (meta.docs.category === categories["env"]) {
219
+ category = "env";
220
+ }
221
+ let sourcecode = context.getSourceCode();
222
+ let code = sourcecode.getText(node);
223
+ if (!code) {
224
+ code = Cache.get(`file:${context.getPhysicalFilename()}`);
225
+ }
226
+ if (code) {
227
+ sourcecode = new SourceCode(code, getAST(code));
227
228
  }
228
- return false;
229
+ return {
230
+ _context: context,
231
+ ...context,
232
+ category,
233
+ cds: context.parserServices.cdsProxy || Cache.get("cds"),
234
+ configPath,
235
+ code,
236
+ filePath,
237
+ options: context.options,
238
+ report: getProxyReport(context.report),
239
+ ruleID: context.id,
240
+ sourcecode
241
+ };
229
242
  }
230
243
 
231
- /**
232
- * Allows to pass on every ESLint error report only for its corresponding file
233
- * @param report rule report
234
- * @param context ESLint rule context
235
- * @param properties Current file/path/rule association
236
- * Only send report if:
237
- * - Rule is not disabled via comment
238
- * - Rule is an environment check
239
- * - Rule is a model check and:
240
- * - Error/warning can be allocated to an actual file
241
- * - File is <stdin>.cds from model rule test
242
- */
243
- // Types: { report: any[], context: Rule.RuleContext, properties: any }
244
- function reportErrors(report, context, properties) {
245
- // Types: { entry: CDSReport, i: number }
246
- report.forEach((entry) => {
247
- if (entry) {
248
- const ruleID = properties.ruleID;
249
- const configPath = properties.configPath;
250
- const fileRel = entry.file;
251
- let fileAbs = entry.file || "";
252
- if (!path.isAbsolute(fileAbs)) {
253
- fileAbs = fileRel ? path.join(Cache.get("configpath"), fileRel) : "";
254
- }
255
- let isDisabled = false;
256
- if (entry.loc && entry.loc.start) {
257
- const line = entry.loc.start.line;
258
- const rulesDisabled = getDisabledFromComments(
259
- rules,
260
- properties.code,
261
- properties.sourcecode,
262
- line
263
- );
264
- if (
265
- line &&
266
- ruleID in rulesDisabled &&
267
- rulesDisabled[ruleID] === "off"
268
- ) {
269
- isDisabled = true;
270
- }
244
+ function getProxyRun(obj) {
245
+ const handler = {
246
+ get(target, prop, receiver) {
247
+ const value = Reflect.get(target, prop, receiver);
248
+ if (typeof value !== "object") {
249
+ return value;
271
250
  }
272
- if (!isDisabled) {
273
- if (
274
- envRules.includes(ruleID) ||
275
- envRules.includes(ruleID.replace("@sap/cds/", ""))
276
- ) {
277
- if (!isEditor()) {
278
- if (!Cache.has(`envChecks:${configPath}`)) {
279
- context.report(entry);
280
- Cache.set(`envChecks:${configPath}`, [entry.message]);
281
- } else if (
282
- Cache.has(`envChecks:${configPath}`) &&
283
- !Cache.get(`envChecks:${configPath}`).includes(entry.message)
284
- ) {
285
- context.report(entry);
286
- Cache.set(`envChecks:${configPath}`, [entry.message]);
287
- }
288
- }
289
- } else if (
290
- fileRel &&
291
- (fileAbs === properties.filePath || fileRel === "<stdin>.cds")
292
- ) {
293
- context.report(entry);
294
- }
251
+ /* eslint no-extra-boolean-cast: "off" */
252
+ if (!!value) {
253
+ return new Proxy(value, handler);
295
254
  }
296
- }
297
- });
255
+ return {
256
+ err: `Property ${prop} prop does not exist on object ${obj}!`,
257
+ };
258
+ },
259
+ apply(target, thisArg, argumentsList) {
260
+ return thisArg.run();
261
+ },
262
+ };
263
+ return new Proxy(obj, handler);
298
264
  }
299
265
 
300
266
  /**
@@ -303,22 +269,25 @@ function reportErrors(report, context, properties) {
303
269
  * valid/invalid checks:
304
270
  * Model checks require input 'code' entries
305
271
  * Env checks require input 'options' with selected parameters
306
- * @param options RuleTester input options
272
+ * @param {CDSRuleTestOpts} options RuleTester input options
307
273
  * @returns RuleTester results
308
274
  */
309
- function runRuleTester(options) {
275
+ function runRuleTester(options, dryRun=false) {
310
276
  let parser;
311
277
  let rule = {};
278
+ process.env.LINT_FLAVOR = "inferred";
312
279
  const rulename = path.basename(options.root);
313
280
  const plugin = "eslint-plugin-cds";
314
281
  if (options.root.includes(plugin)) {
315
- // For internal plugin tests, resolve parser from here
316
- try {
317
- parser = options.parser;
318
- rule = options.rule;
319
- } catch (err) {
320
- throw new Error(err);
321
- }
282
+ // For plugin's internal tests, resolve parser from here
283
+ parser = require.resolve("./parser");
284
+ rule = require(`./rules/${path.basename(options.root)}`);
285
+ const pluginPath = path.join(path.dirname(options.root), "../..");
286
+ Cache.set(
287
+ "rulesInfo",
288
+ getRules(path.join(path.dirname(options.root), "../../lib/impl/rules"))
289
+ );
290
+ Cache.set("pluginpath", pluginPath);
322
291
  } else {
323
292
  // Otherwise from project root
324
293
  const resolvedPlugin = require.resolve("@sap/eslint-plugin-cds", {
@@ -326,13 +295,21 @@ function runRuleTester(options) {
326
295
  });
327
296
  parser = path.join(path.dirname(resolvedPlugin), "parser");
328
297
  rule = require(path.join(
329
- path.dirname(resolvedPlugin),
330
- `rules/${rulename}`
298
+ options.root,
299
+ `../../rules/${path.basename(options.root)}`
331
300
  ));
301
+ const pluginPath = path.join(path.dirname(options.root), "../../../..");
302
+ Cache.set("rulesInfo", getRules(path.join(options.root, "../../rules")));
303
+ Cache.set("pluginpath", pluginPath);
304
+ }
305
+ let category = categories["model"];
306
+ if (rule.meta) {
307
+ category = rule.meta.docs.category;
308
+ }
309
+ let tester = new RuleTester({});
310
+ if (parser) {
311
+ tester = new RuleTester({ parser });
332
312
  }
333
-
334
- const category = rule.meta.docs.category;
335
- const tester = new RuleTester({ parser });
336
313
  const testerCases = {};
337
314
  ["valid", "invalid"].forEach((type) => {
338
315
  const filePath = path.join(options.root, `${type}/${options.filename}`);
@@ -341,18 +318,19 @@ function runRuleTester(options) {
341
318
  filename: filePath,
342
319
  },
343
320
  ];
344
- if (category === "Environment") {
321
+ if (category === categories["env"]) {
345
322
  testerCases[type][0].code = "";
346
323
  testerCases[type][0].options = [
347
324
  { environment: JSON.parse(fs.readFileSync(filePath, "utf8")) },
348
325
  ];
349
- } else if (category === CONSTANTS.categories.model) {
326
+ } else if (!category || category === categories.model) {
350
327
  testerCases[type][0].code = fs.readFileSync(filePath, "utf8");
328
+ initModelRuleTester(filePath);
351
329
  }
352
330
  if (type === "invalid") {
353
331
  testerCases[type][0].errors = options.errors;
354
332
  const fileFixed = path.join(options.root, `fixed/${options.filename}`);
355
- if (fs.existsSync(fileFixed)) {
333
+ if (fs.existsSync(fileFixed) && rule.meta.type !== "suggestion") {
356
334
  testerCases[type][0].output = fs.readFileSync(fileFixed, "utf8");
357
335
  }
358
336
  }
@@ -360,4 +338,4 @@ function runRuleTester(options) {
360
338
  return tester.run(rulename, rule, testerCases);
361
339
  }
362
340
 
363
- module.exports = { defineRule, createRule, runRuleTester };
341
+ module.exports = { createRule, defineRule, runRuleTester };