@sap/eslint-plugin-cds 2.2.2 → 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.
package/CHANGELOG.md CHANGED
@@ -6,67 +6,17 @@ This project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
7
  The format is based on [Keep a Changelog](http://keepachangelog.com/).
8
8
 
9
- ## [2.1.2] - 2021-10-05
10
-
11
- ## Changed
12
-
13
- - Allow not only *.js but also other file types (i.e. *.ts, etc) to bypass plugin rules
14
-
15
- ## [2.1.1] - 2021-10-04
16
9
 
17
- ## Changed
18
-
19
- - Added preprocessor to avoid (other plugins) parsing errors on cds files
20
-
21
- ## [2.2.0] - 2021-10-29
10
+ ## [2.3.0] - 201-12-03
22
11
 
23
12
  ## Added
24
13
 
25
- - Added typings to javascript for all exposed apis
14
+ - Added new rule 'valid-csv-header'
26
15
 
27
16
  ## Changed
28
17
 
29
- - Aligned rule creation and tester api with ESLint
30
-
31
- ## [2.1.2] - 2021-10-05
32
-
33
- ## Changed
34
-
35
- - Allow not only *.js but also other file types (i.e. *.ts, etc) to bypass plugin rules
36
-
37
- ## [2.1.1] - 2021-10-04
38
-
39
- ## Changed
40
-
41
- - Added preprocessor to avoid (other plugins) parsing errors on cds files
42
-
43
- ## [2.2.1] - 2021-10-29
44
-
45
- ## Changed
46
-
47
- - Optimized model loading and fixed bug in loading of 'outsider' files
48
-
49
- ## [2.2.0] - 2021-10-29
50
-
51
- ## Added
52
-
53
- - Added typings to javascript for all exposed apis
54
-
55
- ## Changed
56
-
57
- - Aligned rule creation and tester api with ESLint
58
-
59
- ## [2.1.2] - 2021-10-05
60
-
61
- ## Changed
62
-
63
- - Allow not only *.js but also other file types (i.e. *.ts, etc) to bypass plugin rules
64
-
65
- ## [2.1.1] - 2021-10-04
66
-
67
- ## Changed
68
-
69
- - Added preprocessor to avoid (other plugins) parsing errors on cds files
18
+ - Fixed suggestion messages in editor option (and disabled auto-fix)
19
+ - Added rule properties 'docs.recommended', 'severity'
70
20
 
71
21
  ## [2.2.2] - 2021-11-08
72
22
 
@@ -3,7 +3,6 @@
3
3
  * - categories: The category labels we use to for model and environment rules
4
4
  * - customRulesDir: The custom rules directory name in the user's project home
5
5
  * which contains the subdirs 'docs', 'rules' and 'tests'
6
- * - recommended: The set of this plugin's recommended rules and their severities
7
6
  * - globals: The globals which should be exposed to ESLint by this plugin
8
7
  * - files: Any additional file extensions which ESLint should lint
9
8
  */
@@ -14,15 +13,6 @@ module.exports = {
14
13
  model: "Model Validation",
15
14
  },
16
15
  customRulesDir: ".eslint",
17
- recommended: {
18
- "@sap/cds/assoc2many-ambiguous-key": 1,
19
- "@sap/cds/cds-compile-error": 2,
20
- "@sap/cds/min-node-version": 2,
21
- "@sap/cds/no-join-on-draft-enabled-entities": 1,
22
- "@sap/cds/no-db-keywords": 2,
23
- "@sap/cds/require-2many-oncond": 2,
24
- "@sap/cds/sql-cast-suggestion": 1,
25
- },
26
16
  globals: {
27
17
  SELECT: true,
28
18
  INSERT: true,
@@ -35,6 +25,6 @@ module.exports = {
35
25
  CXL: true,
36
26
  cds: true,
37
27
  },
38
- files: ["*.cds", "*.csn"],
28
+ files: ["*.cds", "*.csn", "*.csv", "undeploy.json"],
39
29
  modelFiles: ["*.cds", "*.csn"],
40
30
  };
package/lib/impl/index.js CHANGED
@@ -22,7 +22,7 @@ const {
22
22
  getLocation,
23
23
  getRange,
24
24
  } = require("../impl/utils/model");
25
- const { files, globals, recommended } = require("./constants");
25
+ const { files, globals } = require("./constants");
26
26
 
27
27
  const cds = require("@sap/cds");
28
28
  cds.getLocation = getLocation;
@@ -36,6 +36,11 @@ if (!Cache.has("rulesInfo")) {
36
36
  } else {
37
37
  rulesInfo = Cache.get("rulesInfo");
38
38
  }
39
+ const recommended = Object.assign({},
40
+ ...Object.entries(rulesInfo.rules)
41
+ .filter(([k,v]) => (v.meta.docs.recommended))
42
+ .map(([k,v]) => ({ [`@sap/cds/${k}`]:v.meta.severity }))
43
+ );
39
44
 
40
45
  module.exports = {
41
46
  configs: {
@@ -31,13 +31,14 @@
31
31
  const fs = require("fs");
32
32
  const path = require("path");
33
33
  const { RuleTester, SourceCode } = require("eslint");
34
- const { isEditor, isValidFile, styleText } = require("./utils/helpers");
34
+ const { isEditor, isValidFile } = require("./utils/helpers");
35
35
  const { isValidEnv, isValidModel } = require("./utils/validate");
36
36
  const {
37
37
  Cache,
38
38
  populateModelAndEnv,
39
39
  hasCompilationError,
40
40
  getAST,
41
+ initModelRuleTester,
41
42
  loadConfigPath,
42
43
  } = require("./utils/model");
43
44
  const { isRuleDisabled, getRules, populateRules } = require("./utils/rules");
@@ -60,9 +61,6 @@ const { customRulesDir, categories } = require("./constants");
60
61
  */
61
62
  function createRule(spec) {
62
63
  const { meta, create } = spec;
63
- if (!meta.type) meta.type = "problem";
64
- if (meta.docs && !meta.docs.category)
65
- meta.docs.category = categories["model"];
66
64
  return {
67
65
  meta,
68
66
  create: function (context) {
@@ -84,11 +82,11 @@ function createRule(spec) {
84
82
  try {
85
83
  create(cdscontext);
86
84
  } catch (err) {
87
- // Do not throw to avoid ESLint VSCode editor pop-ups
88
- styleText(
89
- `Rule ${cdscontext.ruleID} has failed unexpectedly - please report this error!\n`,
90
- ["bold", "red"]
91
- );
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
+ }
92
90
  }
93
91
  // Show compilation error only on console
94
92
  } else if (hasCompilationError(cdscontext) && !isEditor()) {
@@ -210,7 +208,12 @@ function getProxyReport(obj) {
210
208
  */
211
209
  function createCDSContext(context, node, meta) {
212
210
  const filePath = context.getPhysicalFilename();
213
- const configPath = loadConfigPath(filePath);
211
+ let configPath;
212
+ if (!Cache.has("pluginpath")) {
213
+ configPath = loadConfigPath(filePath);
214
+ } else {
215
+ configPath = Cache.get("configpath")
216
+ }
214
217
  let category = "model";
215
218
  if (meta.docs.category === categories["env"]) {
216
219
  category = "env";
@@ -234,10 +237,32 @@ function createCDSContext(context, node, meta) {
234
237
  options: context.options,
235
238
  report: getProxyReport(context.report),
236
239
  ruleID: context.id,
237
- sourcecode,
240
+ sourcecode
238
241
  };
239
242
  }
240
243
 
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;
250
+ }
251
+ /* eslint no-extra-boolean-cast: "off" */
252
+ if (!!value) {
253
+ return new Proxy(value, handler);
254
+ }
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);
264
+ }
265
+
241
266
  /**
242
267
  * ESLint RuleTester (used by custom rule creator api)
243
268
  * Calls ESLint's RuleTester with custom cds parser and input for
@@ -247,20 +272,22 @@ function createCDSContext(context, node, meta) {
247
272
  * @param {CDSRuleTestOpts} options RuleTester input options
248
273
  * @returns RuleTester results
249
274
  */
250
- function runRuleTester(options) {
251
- process.env['RULE_TESTER'] = true;
275
+ function runRuleTester(options, dryRun=false) {
252
276
  let parser;
253
277
  let rule = {};
278
+ process.env.LINT_FLAVOR = "inferred";
254
279
  const rulename = path.basename(options.root);
255
280
  const plugin = "eslint-plugin-cds";
256
281
  if (options.root.includes(plugin)) {
257
282
  // For plugin's internal tests, resolve parser from here
258
283
  parser = require.resolve("./parser");
259
284
  rule = require(`./rules/${path.basename(options.root)}`);
285
+ const pluginPath = path.join(path.dirname(options.root), "../..");
260
286
  Cache.set(
261
287
  "rulesInfo",
262
288
  getRules(path.join(path.dirname(options.root), "../../lib/impl/rules"))
263
289
  );
290
+ Cache.set("pluginpath", pluginPath);
264
291
  } else {
265
292
  // Otherwise from project root
266
293
  const resolvedPlugin = require.resolve("@sap/eslint-plugin-cds", {
@@ -271,7 +298,9 @@ function runRuleTester(options) {
271
298
  options.root,
272
299
  `../../rules/${path.basename(options.root)}`
273
300
  ));
301
+ const pluginPath = path.join(path.dirname(options.root), "../../../..");
274
302
  Cache.set("rulesInfo", getRules(path.join(options.root, "../../rules")));
303
+ Cache.set("pluginpath", pluginPath);
275
304
  }
276
305
  let category = categories["model"];
277
306
  if (rule.meta) {
@@ -296,11 +325,12 @@ function runRuleTester(options) {
296
325
  ];
297
326
  } else if (!category || category === categories.model) {
298
327
  testerCases[type][0].code = fs.readFileSync(filePath, "utf8");
328
+ initModelRuleTester(filePath);
299
329
  }
300
330
  if (type === "invalid") {
301
331
  testerCases[type][0].errors = options.errors;
302
332
  const fileFixed = path.join(options.root, `fixed/${options.filename}`);
303
- if (fs.existsSync(fileFixed)) {
333
+ if (fs.existsSync(fileFixed) && rule.meta.type !== "suggestion") {
304
334
  testerCases[type][0].output = fs.readFileSync(fileFixed, "utf8");
305
335
  }
306
336
  }
@@ -1,11 +1,13 @@
1
1
  module.exports = require("../../api").createRule({
2
2
  meta: {
3
3
  docs: {
4
- description: `Ambiguous key with a \`TO MANY\` relationship since entries could appear multiple times with the same key.`,
4
+ description: "Ambiguous key with a `TO MANY` relationship since entries could appear multiple times with the same key.",
5
5
  category: "Model Validation",
6
+ recommended: true,
6
7
  version: "1.0.1",
7
8
  },
8
- type: "problem",
9
+ severity: "warn",
10
+ type: "problem"
9
11
  },
10
12
  create(context) {
11
13
  let csnOdata;
@@ -5,31 +5,30 @@ module.exports = require("../../api").createRule({
5
5
  category: "Model Validation",
6
6
  version: "1.0.0",
7
7
  },
8
+ severity: "error",
8
9
  type: "problem",
9
10
  },
10
11
  create: function (context) {
11
12
  const m = context.cds.model;
12
- if (m.err) {
13
- if (m.err) {
14
- // If any csn compile errors occur
15
- m.err.messages.forEach((err) => {
16
- const msg = err.message;
17
- let file = "";
18
- const loc = {
19
- start: { line: 0, column: 0 },
20
- end: { line: 1, column: 0 },
21
- };
22
- // Get its location if it exists
23
- if (err.$location) {
24
- loc.start.column = err.$location.col;
25
- loc.start.line = err.$location.line;
26
- loc.end.column = err.$location.endCol;
27
- loc.end.line = err.$location.endLine;
28
- file = err.$location.file;
29
- }
30
- context.report({ message: `${msg}`, loc, file });
31
- });
32
- }
13
+ if (m && m.err) {
14
+ // If any csn compile errors occur
15
+ m.err.messages.forEach((err) => {
16
+ const msg = err.message;
17
+ let file = "";
18
+ const loc = {
19
+ start: { line: 0, column: 0 },
20
+ end: { line: 1, column: 0 },
21
+ };
22
+ // Get its location if it exists
23
+ if (err.$location) {
24
+ loc.start.column = err.$location.col;
25
+ loc.start.line = err.$location.line;
26
+ loc.end.column = err.$location.endCol;
27
+ loc.end.line = err.$location.endLine;
28
+ file = err.$location.file;
29
+ }
30
+ context.report({ message: `${msg}`, loc, file });
31
+ });
33
32
  }
34
- },
33
+ }
35
34
  });
@@ -1,10 +1,13 @@
1
+ const os = require("os");
1
2
  const cp = require("child_process");
2
3
  const semver = require("semver");
3
4
 
5
+ const IS_WIN = os.platform() === "win32";
6
+
4
7
  module.exports = require("../../api").createRule({
5
8
  meta: {
6
9
  docs: {
7
- description: "Checks whether the latest cds version is being used.",
10
+ description: "Checks whether the latest `@sap/cds` version is being used.",
8
11
  category: "Environment",
9
12
  version: "1.0.4",
10
13
  },
@@ -23,6 +26,8 @@ module.exports = require("../../api").createRule({
23
26
  result = cp
24
27
  .execSync(`npm outdated @sap/cds --json`, {
25
28
  cwd: process.cwd(),
29
+ shell: IS_WIN,
30
+ stdio: "pipe",
26
31
  })
27
32
  .toString();
28
33
  cdsVersions = JSON.parse(result)["@sap/cds"];
@@ -4,10 +4,12 @@ const semver = require("semver");
4
4
  module.exports = require("../../api").createRule({
5
5
  meta: {
6
6
  docs: {
7
- description: `Checks whether the minimum node version required by the \`@sap/cds\` is achieved.`,
7
+ description: `Checks whether the minimum Node.js version required by \`@sap/cds\` is achieved.`,
8
8
  category: "Environment",
9
+ recommended: true,
9
10
  version: "1.0.0",
10
11
  },
12
+ severity: "error",
11
13
  type: "problem",
12
14
  },
13
15
  create: function (context) {
@@ -3,7 +3,10 @@ module.exports = require("../../api").defineRule({
3
3
  docs: {
4
4
  description: `Avoid using reserved SQL keywords.`,
5
5
  category: "Model Validation",
6
+ recommended: true,
7
+ version: "2.1.0",
6
8
  },
9
+ severity: "error"
7
10
  },
8
11
  create(context) {
9
12
  const { db = { kind: "sql" } } = context.cds.env.requires;
@@ -3,15 +3,17 @@ module.exports = require("../../api").createRule({
3
3
  docs: {
4
4
  description: `Draft-enabled entities shall not be used in views that make use of \`JOIN\`.`,
5
5
  category: "Model Validation",
6
+ recommended: true,
6
7
  version: "2.2.1",
7
8
  },
9
+ severity: "warn",
8
10
  type: "suggestion",
9
11
  messages: {
10
12
  noJoinOnDraftEnabledEntities: `Do not use draft-enabled entities in views that make use of \`JOIN\`.`,
11
13
  },
12
14
  },
13
15
  create: function (context) {
14
- const m = context.cds.model;
16
+ const m = context.cds.model; if (!m) return
15
17
  m.foreach("entity", (entity) => {
16
18
  if (entity["@odata.draft.enabled"]) {
17
19
  if (entity.query.SELECT.from.join) {
@@ -3,12 +3,14 @@ module.exports = require("../../api").createRule({
3
3
  docs: {
4
4
  description: `Foreign key information of a \`TO MANY\` relationship must be defined within the target and specified in an \`ON\` condition.`,
5
5
  category: "Model Validation",
6
+ recommended: true,
6
7
  version: "2.1.0",
7
8
  },
9
+ severity: "error",
8
10
  type: "problem",
9
11
  },
10
12
  create: function (context) {
11
- const m = context.cds.model;
13
+ const m = context.cds.model; if (!m) return
12
14
  m.forall((d) => {
13
15
  if (d.name) {
14
16
  if (!d.elements) return;
@@ -1,10 +1,12 @@
1
1
  module.exports = require("../../api").createRule({
2
2
  meta: {
3
3
  docs: {
4
- description: "Should make suggestions for possible missing sql casts.",
4
+ description: "Should make suggestions for possible missing SQL casts.",
5
5
  category: "Model Validation",
6
+ recommended: true,
6
7
  version: "1.0.8",
7
8
  },
9
+ severity: "warn",
8
10
  type: "suggestion",
9
11
  hasSuggestions: true,
10
12
  messages: {
@@ -10,6 +10,8 @@ module.exports = require("../../api").createRule({
10
10
  messages: {
11
11
  startLowercase:
12
12
  "Element name '{{entityName}}.{{elementName}}' should start with a lowercase letter.",
13
+ fixLowercase:
14
+ "Start element name with a lowercase letter."
13
15
  },
14
16
  fixable: "code",
15
17
  },
@@ -52,14 +54,13 @@ module.exports = require("../../api").createRule({
52
54
  messageId: "startLowercase",
53
55
  loc,
54
56
  file,
55
- fix,
56
57
  data: {
57
58
  entityName,
58
59
  elementName,
59
60
  },
60
61
  suggest: [
61
62
  {
62
- messageId: "startLowercase",
63
+ messageId: "fixLowercase",
63
64
  fix,
64
65
  },
65
66
  ],
@@ -10,6 +10,7 @@ module.exports = require("../../api").createRule({
10
10
  messages: {
11
11
  startUppercase:
12
12
  "Entity name '{{entityName}}' should start with an uppercase letter.",
13
+ fixUppercase: "Start entity name with an uppercase letter.",
13
14
  },
14
15
  fixable: "code",
15
16
  },
@@ -47,11 +48,10 @@ module.exports = require("../../api").createRule({
47
48
  messageId: "startUppercase",
48
49
  loc,
49
50
  file,
50
- fix,
51
51
  data: { entityName },
52
52
  suggest: [
53
53
  {
54
- messageId: "startUppercase",
54
+ messageId: "fixUppercase",
55
55
  fix,
56
56
  },
57
57
  ],
@@ -0,0 +1,92 @@
1
+ const {basename, extname} = require('path')
2
+ const findFuzzy = require('../utils/fuzzySearch')
3
+ const SEP = '[,;\t]'
4
+ const EOL = '\\r?\\n'
5
+
6
+ module.exports = require("../../api").createRule({
7
+ meta: {
8
+ docs: {
9
+ description: `CSV files for entities must refer to valid element names.`,
10
+ category: "Model Validation",
11
+ recommended: true,
12
+ version: "2.3.0",
13
+ },
14
+ severity: "warn",
15
+ type: "problem",
16
+ hasSuggestions: true,
17
+ messages: {
18
+ InvalidColumn: `Invalid column '{{column}}'. Did you mean '{{candidates}}'?`,
19
+ ReplaceColumnWith: `Replace '{{column}}' with '{{candidates}}'`
20
+ }
21
+ },
22
+ create: function (context) {
23
+ const {cds, code, filePath, sourcecode} = context
24
+
25
+ if (!filePath.endsWith('.csv')) return
26
+ if (!cds.model) return
27
+ let {env, model} = cds;
28
+ model = cds.compile.for.sql(model, {names:env.sql.names, messages: []} )
29
+
30
+ const filename = basename(filePath)
31
+ const entityName = filename.replace(/-/g,'.').slice(0, -extname(filename).length)
32
+ const entity = _entity4(entityName, model)
33
+ if (!entity) return
34
+
35
+ const elements = Object.values(entity.elements)
36
+ .filter (e => !!e['@cds.persistence.name'])
37
+ .map (e => e['@cds.persistence.name'].toUpperCase())
38
+
39
+ const [ cols ] = cds.parse.csv(code)
40
+ const missing = cols.filter (col => !elements.includes(col.toUpperCase()))
41
+ missing.forEach(miss => {
42
+ const index = _findInCode (miss, code)
43
+ const loc = sourcecode.getLocFromIndex(index)
44
+ const candidates = findFuzzy(miss, Object.keys(entity.elements).sort())
45
+ const suggest = candidates.map(cand => { return {
46
+ messageId: 'ReplaceColumnWith',
47
+ data: {column: miss, candidates:cand},
48
+ fix: (fixer) => fixer.replaceTextRange([index, index+miss.length], cand)
49
+ }})
50
+ context.report({
51
+ messageId: 'InvalidColumn',
52
+ data: {column: miss, candidates},
53
+ loc: {start: loc, end: {line: loc.line, column: loc.column+miss.length}},
54
+ file: filePath,
55
+ suggest
56
+ })
57
+ })
58
+ }
59
+
60
+ })
61
+
62
+ function _findInCode (miss, code) {
63
+ // middle
64
+ let match = new RegExp(SEP+miss+SEP).exec(code)
65
+ if (match) return match.index+1
66
+ // end of line
67
+ match = new RegExp(SEP+miss+EOL).exec(code)
68
+ if (match) return match.index+1
69
+ // start of doc
70
+ match = new RegExp('^'+miss+SEP).exec(code)
71
+ if (match) return match.index
72
+ // somewhere (fallback)
73
+ return code.indexOf(miss)
74
+ }
75
+
76
+ function _entity4 (name, csn) {
77
+ let entity = csn.definitions [name]
78
+ if (!entity) {
79
+ if (/(.+)[._]texts_?/.test (name)) { // 'Books.texts', 'Books.texts_de'
80
+ const base = csn.definitions [RegExp.$1]
81
+ return base && _entity4 (base.elements.texts.target, csn)
82
+ }
83
+ else return
84
+ }
85
+ // we also support simple views if they have no projection
86
+ const p = entity.query && entity.query.SELECT || entity.projection
87
+ if (p && !p.columns && p.from.ref && p.from.ref.length === 1) {
88
+ if (csn.definitions [p.from.ref[0]]) return entity
89
+ }
90
+ return entity.name ? entity : { name, __proto__:entity }
91
+ }
92
+
@@ -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
+
11
+ const cache = {};
12
+
13
+
14
+ module.exports = (input, list, log) => {
15
+ let minDistWords = [];
16
+
17
+ if (input.length > 50 || list.length > 50) {
18
+ return minDistWords;
19
+ }
20
+
21
+ let minDist = Number.MAX_SAFE_INTEGER;
22
+
23
+ log && log('\nword\t\tlevDist\t\ttime(ms)');
24
+
25
+ let runtime = 0;
26
+
27
+ for (const word of list) {
28
+ const start = log && Date.now();
29
+ const levDist = levDistance(input, word);
30
+
31
+ if (log) {
32
+ const duration = Date.now() - start;
33
+ runtime = runtime + duration;
34
+ log(`${word}\t\t${levDist}\t\t${duration}`);
35
+ }
36
+
37
+ if (levDist === minDist) {
38
+ minDistWords.push(word);
39
+ }
40
+
41
+ if (levDist < minDist) {
42
+ minDist = levDist;
43
+ minDistWords = [word];
44
+ }
45
+ }
46
+
47
+ log && log(`runtime: ${runtime}ms`);
48
+
49
+ return minDistWords.sort();
50
+ }
51
+
52
+
53
+ const levDistance = (a, b) => {
54
+
55
+ if (cache[a] && cache[a][b]) {
56
+ return cache[a][b];
57
+ }
58
+
59
+ if (a.length === 0) {
60
+ return addToCache(a, b, b.length);
61
+ }
62
+
63
+ if (b.length === 0) {
64
+ return addToCache(a, b, a.length);
65
+ }
66
+
67
+ const tail_a = a.substring(1);
68
+ const tail_b = b.substring(1);
69
+
70
+ if (a[0] === b[0]) {
71
+ return levDistance(tail_a, tail_b);
72
+ }
73
+
74
+ const lev1 = levDistance(tail_a, b);
75
+ const lev2 = levDistance(a, tail_b);
76
+ const lev3 = levDistance(tail_a, tail_b);
77
+
78
+ const levDist = Math.min(lev1, lev2, lev3) + 1;
79
+ return addToCache(a, b, levDist);
80
+ }
81
+
82
+
83
+ const addToCache = (a, b, value) => {
84
+ cache[a] = cache[a] || {};
85
+ cache[a][b] = value;
86
+ return value;
87
+ }