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.
@@ -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** | **Suggestion** (manual, via VS Code lightbulb) |
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 a **suggestion** (not applied automatically). To apply it:
47
+ This rule provides an **auto-fix**. Applied by:
48
48
 
49
- - Click the **lightbulb** / press `Ctrl+.` on the flagged code in VS Code (requires the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint))
50
- - `eslint --fix` does **not** apply suggestions (`--fix-type suggestion` filters fixable rules by rule category, it does **not** apply `hasSuggestions` suggestions)
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 suggestion 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 suggestion is only offered when the loop `init` is a `var` declaration (the typical case).
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`), because those two properties are read/write.
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`; **Suggestion** for `??` |
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
- **Auto-fix** for `let` and `const` declarations. Applied by:
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: replaces the `let` or `const` keyword with `var`, leaving the rest of the declaration unchanged.
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
- **Suggestion** for nullish coalescing (`??`). Applied by:
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** | **Suggestion** (manual, via VS Code lightbulb) |
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 a **suggestion** (not applied automatically). To apply it:
45
+ This rule provides an **auto-fix**. Applied by:
46
46
 
47
- - Click the **lightbulb** / press `Ctrl+.` on the flagged code in VS Code (requires the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint))
48
- - `eslint --fix` does **not** apply suggestions (`--fix-type suggestion` filters fixable rules by rule category, it does **not** apply `hasSuggestions` suggestions)
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 suggestion 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
+ 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.0.1",
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": "^1.0.0",
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
- } else if (actual > entry.maxArgs) {
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
- * For 1:1 replacements (e.g. InsertDE -> InsertData) an auto-fix is provided.
8
- * For ambiguous replacements (e.g. ContentArea -> ContentBlockByKey OR
9
- * ContentBlockByName) two manual suggestions are offered instead.
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 report = {
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: entry.replacement,
48
- reason: entry.reason,
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
- entry.replacement,
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
- if (!declared.has(name.toLowerCase())) {
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
- * Suggestion: when the for-loop init is a VariableDeclaration (the common
11
- * case), the suggestion appends `, _len = arr.length` to the declarator
12
- * list and replaces `arr.length` with `_len` in the test expression.
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
- hasSuggestions: true,
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
- suggest: buildCacheSuggestion(node, lengthExpr, context),
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 buildCacheSuggestion(forNode, lengthExpr, context) {
74
+ function buildCacheFix(forNode, lengthExpr, context) {
75
75
  const init = forNode.init;
76
- // Only suggest when init is a VariableDeclaration so we can safely
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
- messageId: 'suggestCacheLength',
88
- data: { obj: objText },
89
- fix(fixer) {
90
- const lastDecl = init.declarations.at(-1);
91
- return [
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
- // Only safe when the call is a standalone expression statement
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: let/const declarations are rewritten to var (safe, 1:1 swap per
8
- * Mateusz Dąbrowski's SSJS style guide).
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.suggest = [
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.suggest = [
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
- * Suggestion: wraps the existing loop body in an
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
- hasSuggestions: true,
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
- suggest: keyName
48
- ? [
49
- {
50
- messageId: 'suggestAddGuard',
51
- data: { obj: objText, key: keyName },
52
- fix(fixer) {
53
- if (body.type === 'BlockStatement') {
54
- // Wrap the inner content of the existing block.
55
- const inner = context.sourceCode
56
- .getText(body)
57
- .slice(1, -1);
58
- return fixer.replaceText(
59
- body,
60
- `{ if (${objText}.hasOwnProperty(${keyName})) {${inner}} }`,
61
- );
62
- }
63
- // Single-statement body — create a new block with guard.
64
- const stmtText = context.sourceCode.getText(body);
65
- return fixer.replaceText(
66
- body,
67
- `{ if (${objText}.hasOwnProperty(${keyName})) { ${stmtText} } }`,
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
  },