eslint-plugin-sfmc 1.0.2 → 2.1.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.
@@ -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
  },
@@ -1,4 +1,4 @@
1
- import { functionNames } from 'ampscript-data';
1
+ import { functionNames, isMcnSupported } from 'ampscript-data';
2
2
 
3
3
  export default {
4
4
  meta: {
@@ -11,19 +11,47 @@ export default {
11
11
  messages: {
12
12
  unknownFunction:
13
13
  "'{{name}}' is not a recognized AMPscript function. AMPscript does not support custom functions.",
14
+ notSupportedInMcn: "'{{name}}' is not supported in Marketing Cloud Next.",
14
15
  },
15
- schema: [],
16
+ schema: [
17
+ {
18
+ type: 'object',
19
+ properties: {
20
+ target: {
21
+ type: 'string',
22
+ enum: ['engagement', 'next'],
23
+ description:
24
+ "Target platform. Set to 'next' to additionally flag functions not available in Marketing Cloud Next.",
25
+ },
26
+ },
27
+ additionalProperties: false,
28
+ },
29
+ ],
16
30
  },
17
31
 
18
32
  create(context) {
33
+ const options = context.options[0] ?? {};
34
+ const targetNext = options.target === 'next';
35
+
19
36
  return {
20
37
  FunctionCall(node) {
21
- if (!functionNames.has(node.name.toLowerCase())) {
38
+ const lower = node.name.toLowerCase();
39
+
40
+ if (!functionNames.has(lower)) {
22
41
  context.report({
23
42
  node,
24
43
  messageId: 'unknownFunction',
25
44
  data: { name: node.name },
26
45
  });
46
+ return;
47
+ }
48
+
49
+ if (targetNext && !isMcnSupported(lower)) {
50
+ context.report({
51
+ node,
52
+ messageId: 'notSupportedInMcn',
53
+ data: { name: node.name },
54
+ });
27
55
  }
28
56
  },
29
57
  };
@@ -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',
@@ -49,11 +49,29 @@ export default {
49
49
  unknownCoreMethod:
50
50
  "'{{method}}' is not a known method of {{objectType}}. Available methods: {{available}}.",
51
51
  unknownWsproxyMethod: "'{{name}}' is not a recognized WSProxy method.",
52
+ ssjsNotSupportedInMcn:
53
+ 'SSJS is not supported in Marketing Cloud Next. Use AMPscript instead.',
52
54
  },
53
- schema: [],
55
+ schema: [
56
+ {
57
+ type: 'object',
58
+ properties: {
59
+ target: {
60
+ type: 'string',
61
+ enum: ['engagement', 'next'],
62
+ description:
63
+ "Target platform. Set to 'next' to flag all SSJS API calls as unsupported in Marketing Cloud Next.",
64
+ },
65
+ },
66
+ additionalProperties: false,
67
+ },
68
+ ],
54
69
  },
55
70
 
56
71
  create(context) {
72
+ const options = context.options[0] ?? {};
73
+ const targetNext = options.target === 'next';
74
+
57
75
  // Track variable name → Core Library type name (assigned via TypeName.Init())
58
76
  const coreVariables = new Map();
59
77
  // Track variable names assigned via new Script.Util.WSProxy()
@@ -100,6 +118,36 @@ export default {
100
118
 
101
119
  const methodName = property.name;
102
120
 
121
+ // ── MCN target: flag all SSJS API calls as unsupported ────────
122
+ if (targetNext) {
123
+ // Only report on Platform.<NS>.<method>(), HTTP.<method>(),
124
+ // Core Library instance calls, or WSProxy calls — not bare JS.
125
+ const isPlatformCall =
126
+ callee.object.type === 'MemberExpression' &&
127
+ callee.object.object.type === 'Identifier' &&
128
+ callee.object.object.name === 'Platform';
129
+ const isHttpCall =
130
+ callee.object.type === 'Identifier' && callee.object.name === 'HTTP';
131
+
132
+ // Traverse the object chain to find the root identifier
133
+ // (handles de.Rows.Retrieve() where root is `de`)
134
+ let rootNode = callee.object;
135
+ while (rootNode.type === 'MemberExpression') {
136
+ rootNode = rootNode.object;
137
+ }
138
+ const isInstanceCall =
139
+ rootNode.type === 'Identifier' &&
140
+ (coreVariables.has(rootNode.name) || wsproxyVariables.has(rootNode.name));
141
+
142
+ if (isPlatformCall || isHttpCall || isInstanceCall) {
143
+ context.report({
144
+ node: property,
145
+ messageId: 'ssjsNotSupportedInMcn',
146
+ });
147
+ return;
148
+ }
149
+ }
150
+
103
151
  // ── Platform.<NS>.<method>() ──────────────────────────────────
104
152
  if (
105
153
  callee.object.type === 'MemberExpression' &&
@@ -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);