eslint-plugin-sfmc 2.0.1 → 2.1.1
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/docs/rules/amp/function-arity.md +8 -0
- package/docs/rules/amp/require-variable-declaration.md +11 -1
- package/docs/rules/ssjs/cache-loop-length.md +6 -5
- package/docs/rules/ssjs/no-property-call.md +7 -1
- package/docs/rules/ssjs/no-unsupported-syntax.md +7 -10
- package/docs/rules/ssjs/require-hasownproperty.md +6 -5
- package/package.json +4 -3
- package/src/rules/amp/function-arity.js +102 -1
- package/src/rules/amp/no-deprecated-function.js +12 -30
- package/src/rules/amp/require-variable-declaration.js +12 -1
- package/src/rules/ssjs/cache-loop-length.js +17 -23
- package/src/rules/ssjs/no-property-call.js +18 -10
- package/src/rules/ssjs/no-unsupported-syntax.js +26 -43
- package/src/rules/ssjs/require-hasownproperty.js +51 -30
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
AMPscript functions have fixed required and optional argument counts documented in the Salesforce catalog. Calling a function with too few arguments causes a runtime error. Calling it with too many can silently truncate or fail. This rule validates every call against the known min/max argument counts.
|
|
14
14
|
|
|
15
|
+
Several AMPscript functions are variadic: their trailing arguments repeat in fixed-size groups (for example `Concat` repeats a single value, while the DataExtension `UpdateData`/`UpsertData` family repeats column/value pairs). For these functions the catalog carries a `repeat` model describing where the repeating block starts, how many arguments form one group, and — for the Update/Upsert family — which earlier argument dictates how many search pairs precede the update pairs. This rule additionally reports `incompleteGroup` when the trailing arguments do not form complete groups.
|
|
16
|
+
|
|
15
17
|
## Settings
|
|
16
18
|
|
|
17
19
|
| Setting | Values | Default |
|
|
@@ -32,6 +34,9 @@ This rule has no configuration options.
|
|
|
32
34
|
|
|
33
35
|
/* Format accepts at most 3 arguments */
|
|
34
36
|
set @val = Format(@num, "0.00", "en-US", "extra")
|
|
37
|
+
|
|
38
|
+
/* UpdateData: one search pair, then an incomplete update group (odd trailing args) */
|
|
39
|
+
UpdateData("MyDE", 1, "Key", @key, "Col", @v, "Orphan")
|
|
35
40
|
]%%
|
|
36
41
|
```
|
|
37
42
|
|
|
@@ -42,6 +47,9 @@ This rule has no configuration options.
|
|
|
42
47
|
var @val
|
|
43
48
|
set @val = Lookup("MyDE", "Value", "Key", @key)
|
|
44
49
|
set @val = Format(@num, "0.00", "en-US")
|
|
50
|
+
|
|
51
|
+
/* UpdateData: one search pair + one update pair (complete groups) */
|
|
52
|
+
UpdateData("MyDE", 1, "Key", @key, "Col", @v)
|
|
45
53
|
]%%
|
|
46
54
|
```
|
|
47
55
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|---|---|
|
|
7
7
|
| **Type** | `suggestion` |
|
|
8
8
|
| **Default severity** | `off` by default |
|
|
9
|
-
| **Fixable** |
|
|
9
|
+
| **Fixable** | **Auto-fix** (`eslint --fix`) |
|
|
10
10
|
|
|
11
11
|
## Why This Rule Exists
|
|
12
12
|
|
|
@@ -41,6 +41,16 @@ This rule has no configuration options.
|
|
|
41
41
|
]%%
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
## Fix
|
|
45
|
+
|
|
46
|
+
This rule provides an **auto-fix**. Applied by:
|
|
47
|
+
|
|
48
|
+
- `eslint --fix` on the command line
|
|
49
|
+
- **Fix this issue** / **Fix all auto-fixable problems** in VS Code (requires the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint))
|
|
50
|
+
- On save via `editor.codeActionsOnSave: { "source.fixAll.eslint": "explicit" }`
|
|
51
|
+
|
|
52
|
+
What the auto-fix does: inserts `var @variableName` on the line immediately before the first `set` that uses an undeclared `@variable`. Only the first occurrence of each variable receives a fix in a single `--fix` pass to avoid duplicate `var` lines.
|
|
53
|
+
|
|
44
54
|
## When to Disable
|
|
45
55
|
|
|
46
56
|
This rule is off by default because undeclared `set` is valid AMPscript. Enable it for teams that want to enforce a strict declaration-first style.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|---|---|
|
|
7
7
|
| **Type** | `suggestion` |
|
|
8
8
|
| **Default severity** | `warn` in `recommended`; `error` in `strict` |
|
|
9
|
-
| **Fixable** | **
|
|
9
|
+
| **Fixable** | **Auto-fix** (`eslint --fix`) |
|
|
10
10
|
|
|
11
11
|
## Why This Rule Exists
|
|
12
12
|
|
|
@@ -44,12 +44,13 @@ for (var i = 0, _len = items.length; i < _len; i++) {
|
|
|
44
44
|
|
|
45
45
|
## Fix
|
|
46
46
|
|
|
47
|
-
This rule provides
|
|
47
|
+
This rule provides an **auto-fix**. Applied by:
|
|
48
48
|
|
|
49
|
-
-
|
|
50
|
-
-
|
|
49
|
+
- `eslint --fix` on the command line
|
|
50
|
+
- **Fix this issue** / **Fix all auto-fixable problems** in VS Code (requires the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint))
|
|
51
|
+
- On save via `editor.codeActionsOnSave: { "source.fixAll.eslint": "explicit" }`
|
|
51
52
|
|
|
52
|
-
What the
|
|
53
|
+
What the auto-fix does: appends `, _len = arr.length` to the last declarator in the `for` loop initialiser and replaces `arr.length` in the test condition with `_len`. The fix is only applied when the loop `init` is a `var` declaration (the typical case). Loops with other init forms (for example `for (i = 0; i < arr.length; i++)`) are still reported but not auto-fixed.
|
|
53
54
|
|
|
54
55
|
## When to Disable
|
|
55
56
|
|
|
@@ -6,7 +6,7 @@ Calling them with parentheses `()` is a runtime error — they must be accessed
|
|
|
6
6
|
The rule is fixable:
|
|
7
7
|
|
|
8
8
|
- **No-arg calls** (`Property()`) → removes the `()` on both `Request` and `Response` properties.
|
|
9
|
-
- **Single-arg calls on `Platform.Response.*`** (`ContentType("text/html")`) → rewrites to an assignment (`= value`),
|
|
9
|
+
- **Single-arg calls on `Platform.Response.*`** (`ContentType("text/html")`) → rewrites to an assignment (`= value`), including when the call appears in a comma (`SequenceExpression`) expression.
|
|
10
10
|
- **Any-arg calls on `Platform.Request.*`** → reported as an error with no auto-fix, because `Request` properties are read-only.
|
|
11
11
|
|
|
12
12
|
## Properties affected
|
|
@@ -64,6 +64,12 @@ Platform.Response.ContentType = "application/json";
|
|
|
64
64
|
Platform.Response.CharacterSet = "UTF-8";
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
Auto-fix also applies inside comma expressions:
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
Platform.Response.CharacterSet = "UTF-8", Platform.Response.Write("ok");
|
|
71
|
+
```
|
|
72
|
+
|
|
67
73
|
### ❌ Incorrect — attempting to set a read-only `Platform.Request` property (no fix)
|
|
68
74
|
|
|
69
75
|
```js
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|---|---|
|
|
7
7
|
| **Type** | `problem` |
|
|
8
8
|
| **Default severity** | `error` in `recommended` and `strict` |
|
|
9
|
-
| **Fixable** | **Auto-fix** for `let`/`const
|
|
9
|
+
| **Fixable** | **Auto-fix** for `let`/`const`, `??`, and direct object `return` |
|
|
10
10
|
|
|
11
11
|
## Why This Rule Exists
|
|
12
12
|
|
|
@@ -49,22 +49,19 @@ var value = (obj && obj.prop !== null && obj.prop !== undefined) ? obj.prop : "d
|
|
|
49
49
|
|
|
50
50
|
## Fix
|
|
51
51
|
|
|
52
|
-
**
|
|
52
|
+
This rule provides **auto-fix** for selected features. Applied by:
|
|
53
53
|
|
|
54
54
|
- `eslint --fix` on the command line
|
|
55
55
|
- **Fix this issue** / **Fix all auto-fixable problems** in VS Code (requires the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint))
|
|
56
56
|
- On save via `editor.codeActionsOnSave: { "source.fixAll.eslint": "explicit" }`
|
|
57
57
|
|
|
58
|
-
What the auto-fix does:
|
|
58
|
+
What the auto-fix does:
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
- **`let` / `const` declarations** — replaces the keyword with `var`, leaving the rest of the declaration unchanged.
|
|
61
|
+
- **Nullish coalescing (`??`)** — replaces `??` with `||`. Note that `||` has different semantics — it also treats `0`, `""`, and `false` as falsy, unlike `??` which only replaces `null` and `undefined`.
|
|
62
|
+
- **Direct object literal `return`** — rewrites `return { … }` to assign the object to a temporary `var _result` and then `return _result`.
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
- Click the **lightbulb** / press `Ctrl+.` on the flagged code in VS Code (requires the ESLint extension)
|
|
65
|
-
- `eslint --fix` does **not** apply suggestions (`--fix-type suggestion` filters fixable rules by rule category, it does **not** apply `hasSuggestions` suggestions)
|
|
66
|
-
|
|
67
|
-
What the suggestion does: replaces `??` with `||`. Note that `||` has different semantics — it also treats `0`, `""`, and `false` as falsy, unlike `??` which only replaces `null` and `undefined`.
|
|
64
|
+
Other unsupported features (arrow functions, template literals, classes, etc.) have no auto-fix.
|
|
68
65
|
|
|
69
66
|
## Configuration Example
|
|
70
67
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|---|---|
|
|
7
7
|
| **Type** | `suggestion` |
|
|
8
8
|
| **Default severity** | `warn` in `recommended`; `error` in `strict` |
|
|
9
|
-
| **Fixable** | **
|
|
9
|
+
| **Fixable** | **Auto-fix** (`eslint --fix`) |
|
|
10
10
|
|
|
11
11
|
## Why This Rule Exists
|
|
12
12
|
|
|
@@ -42,12 +42,13 @@ for (var key in myObject) {
|
|
|
42
42
|
|
|
43
43
|
## Fix
|
|
44
44
|
|
|
45
|
-
This rule provides
|
|
45
|
+
This rule provides an **auto-fix**. Applied by:
|
|
46
46
|
|
|
47
|
-
-
|
|
48
|
-
-
|
|
47
|
+
- `eslint --fix` on the command line
|
|
48
|
+
- **Fix this issue** / **Fix all auto-fixable problems** in VS Code (requires the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint))
|
|
49
|
+
- On save via `editor.codeActionsOnSave: { "source.fixAll.eslint": "explicit" }`
|
|
49
50
|
|
|
50
|
-
What the
|
|
51
|
+
What the auto-fix does: wraps the entire loop body in an `if (obj.hasOwnProperty(key)) { … }` guard. When the body is already a block statement, the existing content is preserved inside the new `if`; when it is a single statement, a new block is created around it.
|
|
51
52
|
|
|
52
53
|
## When to Disable
|
|
53
54
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-sfmc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "ESLint plugin for Salesforce Marketing Cloud  AMPscript and Server-Side JavaScript (SSJS)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"sfmc"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"ampscript-data": "^
|
|
35
|
+
"ampscript-data": "^2.0.1",
|
|
36
36
|
"ampscript-parser": "^0.1.3",
|
|
37
37
|
"ssjs-data": "^0.4.0"
|
|
38
38
|
},
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"eslint": ">=9.0.0"
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
|
-
"test": "node --test tests/rules.test.js && node --test tests/fixtures.test.mjs",
|
|
43
|
+
"test": "node --test tests/rules.test.js && node --test tests/fixtures.test.mjs && node --test tests/autofix-manual.test.mjs",
|
|
44
|
+
"test:autofix-manual": "node --test tests/autofix-manual.test.mjs",
|
|
44
45
|
"test:fixtures": "node --test tests/fixtures.test.mjs",
|
|
45
46
|
"test:fixtures:update": "node --test tests/fixtures.test.mjs -- --update-snapshots",
|
|
46
47
|
"lint": "eslint .",
|
|
@@ -1,5 +1,96 @@
|
|
|
1
1
|
import { functionLookup } from 'ampscript-data';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Reads a numeric-literal argument value from an AMPscript AST argument node.
|
|
5
|
+
* Returns the integer when the argument is a positive integer literal, otherwise null.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} argument - AMPscript argument AST node.
|
|
8
|
+
* @returns {number | null} The integer value, or null when it is not a usable literal.
|
|
9
|
+
*/
|
|
10
|
+
function readIntLiteral(argument) {
|
|
11
|
+
if (!argument) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
// NumberLiteral / StringLiteral nodes both carry the raw value as a string.
|
|
15
|
+
if (argument.type === 'NumberLiteral' || argument.type === 'StringLiteral') {
|
|
16
|
+
const n = Number(argument.value);
|
|
17
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validates the repeating-group structure of a variadic AMPscript call.
|
|
24
|
+
* Returns a messageId + data object when the trailing arguments do not form
|
|
25
|
+
* complete repeating groups, otherwise null.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} entry - ampscript-data function entry (with `repeat`).
|
|
28
|
+
* @param {object[]} args - the call's argument AST nodes.
|
|
29
|
+
* @returns {{messageId: string, data: object} | null} Violation descriptor or null.
|
|
30
|
+
*/
|
|
31
|
+
function checkRepeatGroups(entry, args) {
|
|
32
|
+
const groups = entry.repeat;
|
|
33
|
+
if (!Array.isArray(groups) || groups.length === 0) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const actual = args.length;
|
|
37
|
+
|
|
38
|
+
// Single repeating group: trailing args must be a whole multiple of groupSize.
|
|
39
|
+
if (groups.length === 1) {
|
|
40
|
+
const { startIndex, groupSize, minGroups } = groups[0];
|
|
41
|
+
if (actual <= startIndex) {
|
|
42
|
+
// not enough args even to start the group; minArgs check handles "too few"
|
|
43
|
+
return minGroups > 0 && actual < startIndex + groupSize * minGroups
|
|
44
|
+
? incompleteGroup(entry, groupSize)
|
|
45
|
+
: null;
|
|
46
|
+
}
|
|
47
|
+
const trailing = actual - startIndex;
|
|
48
|
+
if (trailing % groupSize !== 0) {
|
|
49
|
+
return incompleteGroup(entry, groupSize);
|
|
50
|
+
}
|
|
51
|
+
if (trailing / groupSize < minGroups) {
|
|
52
|
+
return incompleteGroup(entry, groupSize);
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Two repeating groups (DataExtension Update/Upsert family): the first group's
|
|
58
|
+
// size is dictated by a countParam literal; the second group fills the rest.
|
|
59
|
+
const [g1, g2] = groups;
|
|
60
|
+
const countArg = g1.countParam
|
|
61
|
+
? readIntLiteral(args[entry.params.findIndex((p) => p.name === g1.countParam)])
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
if (countArg === null) {
|
|
65
|
+
// countParam is not a literal we can evaluate; fall back to a parity check:
|
|
66
|
+
// every trailing arg pair must be even across both groups.
|
|
67
|
+
const trailing = actual - g1.startIndex;
|
|
68
|
+
return trailing % g1.groupSize === 0 ? null : incompleteGroup(entry, g1.groupSize);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const group1Args = countArg * g1.groupSize;
|
|
72
|
+
const group2Start = g1.startIndex + group1Args;
|
|
73
|
+
const group2Count = actual - group2Start;
|
|
74
|
+
if (group2Count <= 0 || group2Count % g2.groupSize !== 0) {
|
|
75
|
+
return incompleteGroup(entry, g2.groupSize);
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Builds the incompleteGroup report descriptor.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} entry - ampscript-data function entry.
|
|
84
|
+
* @param {number} groupSize - size of the repeating unit that is incomplete.
|
|
85
|
+
* @returns {{messageId: string, data: object}} Report descriptor.
|
|
86
|
+
*/
|
|
87
|
+
function incompleteGroup(entry, groupSize) {
|
|
88
|
+
return {
|
|
89
|
+
messageId: 'incompleteGroup',
|
|
90
|
+
data: { name: entry.name, size: String(groupSize) },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
3
94
|
export default {
|
|
4
95
|
meta: {
|
|
5
96
|
type: 'problem',
|
|
@@ -12,6 +103,8 @@ export default {
|
|
|
12
103
|
"'{{name}}' requires at least {{min}} argument(s) but was called with {{actual}}.",
|
|
13
104
|
tooManyArgs:
|
|
14
105
|
"'{{name}}' accepts at most {{max}} argument(s) but was called with {{actual}}.",
|
|
106
|
+
incompleteGroup:
|
|
107
|
+
"'{{name}}' expects its repeating arguments in complete groups of {{size}}.",
|
|
15
108
|
},
|
|
16
109
|
schema: [],
|
|
17
110
|
},
|
|
@@ -36,7 +129,9 @@ export default {
|
|
|
36
129
|
actual: String(actual),
|
|
37
130
|
},
|
|
38
131
|
});
|
|
39
|
-
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (actual > entry.maxArgs) {
|
|
40
135
|
context.report({
|
|
41
136
|
node,
|
|
42
137
|
messageId: 'tooManyArgs',
|
|
@@ -46,6 +141,12 @@ export default {
|
|
|
46
141
|
actual: String(actual),
|
|
47
142
|
},
|
|
48
143
|
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const groupViolation = checkRepeatGroups(entry, node.arguments);
|
|
148
|
+
if (groupViolation) {
|
|
149
|
+
context.report({ node, ...groupViolation });
|
|
49
150
|
}
|
|
50
151
|
},
|
|
51
152
|
};
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Flags usage of deprecated AMPscript functions and suggests their
|
|
5
5
|
* modern replacements.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Deprecation metadata is read inline from the FUNCTIONS catalog via the
|
|
8
|
+
* `deprecatedReplacement` and `deprecatedReason` fields. A 1:1 auto-fix is
|
|
9
|
+
* provided for each deprecated function.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { deprecatedFunctionLookup } from 'ampscript-data';
|
|
@@ -39,40 +39,22 @@ export default {
|
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
const
|
|
42
|
+
const replacement = entry.deprecatedReplacement;
|
|
43
|
+
|
|
44
|
+
context.report({
|
|
43
45
|
node,
|
|
44
46
|
messageId: 'deprecated',
|
|
45
47
|
data: {
|
|
46
48
|
name: functionName,
|
|
47
|
-
replacement
|
|
48
|
-
reason: entry.
|
|
49
|
+
replacement,
|
|
50
|
+
reason: entry.deprecatedReason,
|
|
49
51
|
},
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// Replacement strings that contain " or " have multiple options —
|
|
53
|
-
// offer them as manual suggestions rather than a single auto-fix.
|
|
54
|
-
const isMulti = entry.replacement.includes(' or ');
|
|
55
|
-
|
|
56
|
-
if (isMulti) {
|
|
57
|
-
const options = entry.replacement.split(' or ').map((s) => s.trim());
|
|
58
|
-
report.suggest = options.map((opt) => ({
|
|
59
|
-
messageId: 'replaceWith',
|
|
60
|
-
data: { name: functionName, replacement: opt },
|
|
61
|
-
fix: (fixer) =>
|
|
62
|
-
fixer.replaceTextRange(
|
|
63
|
-
[node.range[0], node.range[0] + functionName.length],
|
|
64
|
-
opt,
|
|
65
|
-
),
|
|
66
|
-
}));
|
|
67
|
-
} else {
|
|
68
|
-
report.fix = (fixer) =>
|
|
52
|
+
fix: (fixer) =>
|
|
69
53
|
fixer.replaceTextRange(
|
|
70
54
|
[node.range[0], node.range[0] + functionName.length],
|
|
71
|
-
|
|
72
|
-
)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
context.report(report);
|
|
55
|
+
replacement,
|
|
56
|
+
),
|
|
57
|
+
});
|
|
76
58
|
},
|
|
77
59
|
};
|
|
78
60
|
},
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
export default {
|
|
12
12
|
meta: {
|
|
13
13
|
type: 'suggestion',
|
|
14
|
+
fixable: 'code',
|
|
14
15
|
docs: {
|
|
15
16
|
description: 'Require variables to be declared with `var` before use',
|
|
16
17
|
recommended: false,
|
|
@@ -24,6 +25,7 @@ export default {
|
|
|
24
25
|
|
|
25
26
|
create(context) {
|
|
26
27
|
const declared = new Set();
|
|
28
|
+
const fixedVars = new Set();
|
|
27
29
|
|
|
28
30
|
return {
|
|
29
31
|
VarDeclaration(node) {
|
|
@@ -40,11 +42,20 @@ export default {
|
|
|
40
42
|
if (name.startsWith('@@')) {
|
|
41
43
|
return;
|
|
42
44
|
}
|
|
43
|
-
|
|
45
|
+
const lower = name.toLowerCase();
|
|
46
|
+
if (!declared.has(lower)) {
|
|
47
|
+
const isFirstFix = !fixedVars.has(lower);
|
|
48
|
+
if (isFirstFix) {
|
|
49
|
+
fixedVars.add(lower);
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
context.report({
|
|
45
53
|
node: node.target,
|
|
46
54
|
messageId: 'undeclared',
|
|
47
55
|
data: { name },
|
|
56
|
+
fix: isFirstFix
|
|
57
|
+
? (fixer) => fixer.insertTextBefore(node, `var ${name}\n`)
|
|
58
|
+
: undefined,
|
|
48
59
|
});
|
|
49
60
|
}
|
|
50
61
|
},
|
|
@@ -7,23 +7,21 @@
|
|
|
7
7
|
* Bad: for (var i = 0; i < arr.length; i++)
|
|
8
8
|
* Good: for (var i = 0, _len = arr.length; i < _len; i++)
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* case),
|
|
12
|
-
*
|
|
10
|
+
* Auto-fix: when the for-loop init is a VariableDeclaration (the common
|
|
11
|
+
* case), appends `, _len = arr.length` to the declarator list and replaces
|
|
12
|
+
* `arr.length` with `_len` in the test expression.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
export default {
|
|
16
16
|
meta: {
|
|
17
17
|
type: 'suggestion',
|
|
18
|
-
|
|
18
|
+
fixable: 'code',
|
|
19
19
|
docs: {
|
|
20
20
|
description: 'Require caching array length in for-loop conditions',
|
|
21
21
|
},
|
|
22
22
|
messages: {
|
|
23
23
|
cacheLength:
|
|
24
24
|
"Cache '.length' in a variable to avoid re-evaluation on each iteration (e.g. `for (var i = 0, _len = arr.length; i < _len; i++)`).",
|
|
25
|
-
suggestCacheLength:
|
|
26
|
-
'Cache .length in a variable (e.g. add `, _len = {{obj}}.length` to the init and replace `{{obj}}.length` with `_len` in the condition)',
|
|
27
25
|
},
|
|
28
26
|
schema: [],
|
|
29
27
|
},
|
|
@@ -47,10 +45,12 @@ export default {
|
|
|
47
45
|
return;
|
|
48
46
|
}
|
|
49
47
|
|
|
48
|
+
const fix = buildCacheFix(node, lengthExpr, context);
|
|
49
|
+
|
|
50
50
|
context.report({
|
|
51
51
|
node: test,
|
|
52
52
|
messageId: 'cacheLength',
|
|
53
|
-
|
|
53
|
+
...(fix ? { fix } : {}),
|
|
54
54
|
});
|
|
55
55
|
},
|
|
56
56
|
};
|
|
@@ -71,28 +71,22 @@ function containsMemberLength(node) {
|
|
|
71
71
|
return false;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function
|
|
74
|
+
function buildCacheFix(forNode, lengthExpr, context) {
|
|
75
75
|
const init = forNode.init;
|
|
76
|
-
// Only
|
|
76
|
+
// Only fix when init is a VariableDeclaration so we can safely
|
|
77
77
|
// append a new declarator without restructuring the loop header.
|
|
78
78
|
if (!init || init.type !== 'VariableDeclaration' || init.declarations.length === 0) {
|
|
79
|
-
return
|
|
79
|
+
return null;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
const objText = context.sourceCode.getText(lengthExpr.object);
|
|
83
83
|
const lenVar = '_len';
|
|
84
84
|
|
|
85
|
-
return
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
fixer.insertTextAfter(lastDecl, `, ${lenVar} = ${objText}.length`),
|
|
93
|
-
fixer.replaceText(lengthExpr, lenVar),
|
|
94
|
-
];
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
];
|
|
85
|
+
return function fix(fixer) {
|
|
86
|
+
const lastDecl = init.declarations.at(-1);
|
|
87
|
+
return [
|
|
88
|
+
fixer.insertTextAfter(lastDecl, `, ${lenVar} = ${objText}.length`),
|
|
89
|
+
fixer.replaceText(lengthExpr, lenVar),
|
|
90
|
+
];
|
|
91
|
+
};
|
|
98
92
|
}
|
|
@@ -19,16 +19,31 @@
|
|
|
19
19
|
|
|
20
20
|
import { PLATFORM_RESPONSE_METHODS, PLATFORM_REQUEST_METHODS } from 'ssjs-data';
|
|
21
21
|
|
|
22
|
-
// Platform.Response properties — readable AND writable
|
|
23
22
|
const RESPONSE_PROPERTIES = new Set(
|
|
24
23
|
PLATFORM_RESPONSE_METHODS.filter((m) => m.isProperty).map((m) => m.name.toLowerCase()),
|
|
25
24
|
);
|
|
26
25
|
|
|
27
|
-
// Platform.Request properties — read-only
|
|
28
26
|
const REQUEST_PROPERTIES = new Set(
|
|
29
27
|
PLATFORM_REQUEST_METHODS.filter((m) => m.isProperty).map((m) => m.name.toLowerCase()),
|
|
30
28
|
);
|
|
31
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Returns true when replacing a call with an assignment expression is syntactically safe.
|
|
32
|
+
*
|
|
33
|
+
* @param {import('eslint').Rule.Node} node - CallExpression node
|
|
34
|
+
* @returns {boolean} Whether assignment replacement is valid in this parent context
|
|
35
|
+
*/
|
|
36
|
+
function canReplaceCallWithAssignment(node) {
|
|
37
|
+
const parent = node.parent;
|
|
38
|
+
if (parent.type === 'ExpressionStatement' && parent.expression === node) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (parent.type === 'SequenceExpression' && parent.expressions.includes(node)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
export default {
|
|
33
48
|
meta: {
|
|
34
49
|
type: 'problem',
|
|
@@ -56,7 +71,6 @@ export default {
|
|
|
56
71
|
CallExpression(node) {
|
|
57
72
|
const callee = node.callee;
|
|
58
73
|
|
|
59
|
-
// Must be Platform.<NS>.<property>()
|
|
60
74
|
if (
|
|
61
75
|
callee.type !== 'MemberExpression' ||
|
|
62
76
|
callee.object.type !== 'MemberExpression' ||
|
|
@@ -75,7 +89,6 @@ export default {
|
|
|
75
89
|
|
|
76
90
|
if (ns === 'response' && RESPONSE_PROPERTIES.has(propName)) {
|
|
77
91
|
if (node.arguments.length === 0) {
|
|
78
|
-
// Reading with parens — remove ()
|
|
79
92
|
context.report({
|
|
80
93
|
node,
|
|
81
94
|
messageId: 'propertyReadWithCall',
|
|
@@ -85,14 +98,12 @@ export default {
|
|
|
85
98
|
},
|
|
86
99
|
});
|
|
87
100
|
} else if (node.arguments.length === 1) {
|
|
88
|
-
// Setting via function call — convert to assignment
|
|
89
101
|
context.report({
|
|
90
102
|
node,
|
|
91
103
|
messageId: 'writablePropertySet',
|
|
92
104
|
data: { ns: displayNs, name: displayName },
|
|
93
105
|
fix(fixer) {
|
|
94
|
-
|
|
95
|
-
if (node.parent.type !== 'ExpressionStatement') {
|
|
106
|
+
if (!canReplaceCallWithAssignment(node)) {
|
|
96
107
|
return null;
|
|
97
108
|
}
|
|
98
109
|
const argText = sourceCode.getText(node.arguments[0]);
|
|
@@ -103,7 +114,6 @@ export default {
|
|
|
103
114
|
},
|
|
104
115
|
});
|
|
105
116
|
} else {
|
|
106
|
-
// >1 args — unusual, flag without a fix
|
|
107
117
|
context.report({
|
|
108
118
|
node,
|
|
109
119
|
messageId: 'writablePropertySet',
|
|
@@ -112,7 +122,6 @@ export default {
|
|
|
112
122
|
}
|
|
113
123
|
} else if (ns === 'request' && REQUEST_PROPERTIES.has(propName)) {
|
|
114
124
|
if (node.arguments.length === 0) {
|
|
115
|
-
// Reading with parens — remove ()
|
|
116
125
|
context.report({
|
|
117
126
|
node,
|
|
118
127
|
messageId: 'propertyReadWithCall',
|
|
@@ -122,7 +131,6 @@ export default {
|
|
|
122
131
|
},
|
|
123
132
|
});
|
|
124
133
|
} else {
|
|
125
|
-
// Attempting to set a read-only property — no fix
|
|
126
134
|
context.report({
|
|
127
135
|
node,
|
|
128
136
|
messageId: 'readOnlyPropertySet',
|
|
@@ -4,39 +4,51 @@
|
|
|
4
4
|
* Flags ES6+ syntax features that are not supported by SFMC's legacy
|
|
5
5
|
* ECMAScript engine. These features cause runtime errors in SFMC.
|
|
6
6
|
*
|
|
7
|
-
* Auto-fix:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* Suggestions:
|
|
11
|
-
* - nullish coalescing (??) → replace with ||
|
|
7
|
+
* Auto-fix:
|
|
8
|
+
* - let/const declarations → var
|
|
9
|
+
* - nullish coalescing (??) → || (semantics differ for 0, '', false)
|
|
12
10
|
* - direct object literal return → extract to variable
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
13
|
import { unsupportedByNodeType } from 'ssjs-data';
|
|
16
14
|
|
|
17
|
-
// Features that can be safely auto-fixed without changing semantics.
|
|
18
15
|
const AUTO_FIX = {
|
|
19
16
|
LetDeclaration: (node) => (fixer) =>
|
|
20
|
-
// "let" is 3 chars; the keyword starts at node.range[0].
|
|
21
17
|
fixer.replaceTextRange([node.range[0], node.range[0] + 3], 'var'),
|
|
22
18
|
ConstDeclaration: (node) => (fixer) =>
|
|
23
|
-
// "const" is 5 chars.
|
|
24
19
|
fixer.replaceTextRange([node.range[0], node.range[0] + 5], 'var'),
|
|
25
20
|
};
|
|
26
21
|
|
|
22
|
+
function nullishCoalescingFix(context, node) {
|
|
23
|
+
return (fixer) => {
|
|
24
|
+
const src = context.sourceCode.getText();
|
|
25
|
+
const between = src.slice(node.left.range[1], node.right.range[0]);
|
|
26
|
+
const offset = between.indexOf('??');
|
|
27
|
+
if (offset === -1) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const start = node.left.range[1] + offset;
|
|
31
|
+
return fixer.replaceTextRange([start, start + 2], '||');
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function directObjectReturnFix(context, node) {
|
|
36
|
+
return (fixer) => {
|
|
37
|
+
const objText = context.sourceCode.getText(node.argument);
|
|
38
|
+
const indent = ' '.repeat(node.loc.start.column);
|
|
39
|
+
return fixer.replaceText(node, `var _result = ${objText};\n${indent}return _result;`);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
export default {
|
|
28
44
|
meta: {
|
|
29
45
|
type: 'problem',
|
|
30
46
|
fixable: 'code',
|
|
31
|
-
hasSuggestions: true,
|
|
32
47
|
docs: {
|
|
33
48
|
description: 'Disallow ES6+ syntax features not supported by the SFMC SSJS engine',
|
|
34
49
|
},
|
|
35
50
|
messages: {
|
|
36
51
|
unsupported: '{{label}} are not supported in SFMC SSJS. {{suggestion}}',
|
|
37
|
-
suggestLogicalOr:
|
|
38
|
-
"Replace ?? with || (note: semantics differ for falsy values like 0, '', and false)",
|
|
39
|
-
suggestVarReturn: 'Assign the object to a variable, then return the variable',
|
|
40
52
|
},
|
|
41
53
|
schema: [
|
|
42
54
|
{
|
|
@@ -83,38 +95,9 @@ export default {
|
|
|
83
95
|
if (entry.feature in AUTO_FIX) {
|
|
84
96
|
report.fix = AUTO_FIX[entry.feature](node);
|
|
85
97
|
} else if (entry.feature === 'NullishCoalescing') {
|
|
86
|
-
report.
|
|
87
|
-
{
|
|
88
|
-
messageId: 'suggestLogicalOr',
|
|
89
|
-
fix(fixer) {
|
|
90
|
-
const src = context.sourceCode.getText();
|
|
91
|
-
const between = src.slice(
|
|
92
|
-
node.left.range[1],
|
|
93
|
-
node.right.range[0],
|
|
94
|
-
);
|
|
95
|
-
const offset = between.indexOf('??');
|
|
96
|
-
if (offset === -1) {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
const start = node.left.range[1] + offset;
|
|
100
|
-
return fixer.replaceTextRange([start, start + 2], '||');
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
];
|
|
98
|
+
report.fix = nullishCoalescingFix(context, node);
|
|
104
99
|
} else if (entry.feature === 'DirectObjectReturn') {
|
|
105
|
-
report.
|
|
106
|
-
{
|
|
107
|
-
messageId: 'suggestVarReturn',
|
|
108
|
-
fix(fixer) {
|
|
109
|
-
const objText = context.sourceCode.getText(node.argument);
|
|
110
|
-
const indent = ' '.repeat(node.loc.start.column);
|
|
111
|
-
return fixer.replaceText(
|
|
112
|
-
node,
|
|
113
|
-
`var _result = ${objText};\n${indent}return _result;`,
|
|
114
|
-
);
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
];
|
|
100
|
+
report.fix = directObjectReturnFix(context, node);
|
|
118
101
|
}
|
|
119
102
|
|
|
120
103
|
context.report(report);
|
|
@@ -4,22 +4,20 @@
|
|
|
4
4
|
* In for-in loops, require a hasOwnProperty guard to avoid iterating
|
|
5
5
|
* over inherited properties (like _type) that SSJS objects may have.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Auto-fix: wraps the existing loop body in an
|
|
8
8
|
* `if (obj.hasOwnProperty(key)) { ... }` guard.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
export default {
|
|
12
12
|
meta: {
|
|
13
13
|
type: 'suggestion',
|
|
14
|
-
|
|
14
|
+
fixable: 'code',
|
|
15
15
|
docs: {
|
|
16
16
|
description: 'Require hasOwnProperty guard in for-in loops',
|
|
17
17
|
},
|
|
18
18
|
messages: {
|
|
19
19
|
missingGuard:
|
|
20
20
|
'Add a hasOwnProperty check inside for-in loops to avoid iterating over inherited properties.',
|
|
21
|
-
suggestAddGuard:
|
|
22
|
-
'Wrap the loop body in an `if ({{obj}}.hasOwnProperty({{key}})) { ... }` guard',
|
|
23
21
|
},
|
|
24
22
|
schema: [],
|
|
25
23
|
},
|
|
@@ -41,35 +39,58 @@ export default {
|
|
|
41
39
|
const keyName = getKeyName(node.left);
|
|
42
40
|
const objText = context.sourceCode.getText(node.right);
|
|
43
41
|
|
|
42
|
+
if (!keyName) {
|
|
43
|
+
context.report({
|
|
44
|
+
node,
|
|
45
|
+
messageId: 'missingGuard',
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
44
50
|
context.report({
|
|
45
51
|
node,
|
|
46
52
|
messageId: 'missingGuard',
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
53
|
+
fix(fixer) {
|
|
54
|
+
const sourceCode = context.sourceCode;
|
|
55
|
+
const innerStmts = body.type === 'BlockStatement' ? body.body : [body];
|
|
56
|
+
|
|
57
|
+
// Single-line loops keep compact output to avoid reformatting
|
|
58
|
+
// code the author intentionally wrote on one line.
|
|
59
|
+
const isSingleLine = node.loc.start.line === node.loc.end.line;
|
|
60
|
+
if (isSingleLine) {
|
|
61
|
+
const compactBody = innerStmts
|
|
62
|
+
.map((stmt) => sourceCode.getText(stmt))
|
|
63
|
+
.join(' ');
|
|
64
|
+
return fixer.replaceText(
|
|
65
|
+
body,
|
|
66
|
+
`{ if (${objText}.hasOwnProperty(${keyName})) { ${compactBody} } }`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Multi-line loops: emit a properly-indented guard block.
|
|
71
|
+
const loopLine = sourceCode.lines[node.loc.start.line - 1] ?? '';
|
|
72
|
+
const baseIndent = (loopLine.match(/^[\t ]*/) ?? [''])[0];
|
|
73
|
+
const firstStmt = innerStmts[0];
|
|
74
|
+
const firstStmtLine = firstStmt
|
|
75
|
+
? (sourceCode.lines[firstStmt.loc.start.line - 1] ?? '')
|
|
76
|
+
: '';
|
|
77
|
+
const firstStmtIndent = (firstStmtLine.match(/^[\t ]*/) ?? [''])[0];
|
|
78
|
+
const unit =
|
|
79
|
+
firstStmtIndent.length > baseIndent.length
|
|
80
|
+
? firstStmtIndent.slice(baseIndent.length)
|
|
81
|
+
: ' ';
|
|
82
|
+
const guardIndent = baseIndent + unit;
|
|
83
|
+
const stmtIndent = guardIndent + unit;
|
|
84
|
+
|
|
85
|
+
const guardedBody = innerStmts
|
|
86
|
+
.map((stmt) => `${stmtIndent}${sourceCode.getText(stmt)}`)
|
|
87
|
+
.join('\n');
|
|
88
|
+
|
|
89
|
+
return fixer.replaceText(
|
|
90
|
+
body,
|
|
91
|
+
`{\n${guardIndent}if (${objText}.hasOwnProperty(${keyName})) {\n${guardedBody}\n${guardIndent}}\n${baseIndent}}`,
|
|
92
|
+
);
|
|
93
|
+
},
|
|
73
94
|
});
|
|
74
95
|
}
|
|
75
96
|
},
|