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.
- package/README.md +57 -0
- package/docs/rules/amp/function-arity.md +8 -0
- package/docs/rules/amp/no-unknown-function.md +42 -3
- 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-deprecated-function.md +1 -1
- package/docs/rules/ssjs/no-property-call.md +8 -2
- package/docs/rules/ssjs/no-switch-default.md +1 -1
- package/docs/rules/ssjs/no-treatascontent-injection.md +1 -1
- package/docs/rules/ssjs/no-unknown-function.md +40 -7
- package/docs/rules/ssjs/no-unsupported-syntax.md +7 -10
- package/docs/rules/ssjs/prefer-parsejson-safe-arg.md +1 -1
- package/docs/rules/ssjs/require-hasownproperty.md +6 -5
- package/package.json +6 -3
- package/src/index.js +175 -0
- package/src/rules/amp/function-arity.js +102 -1
- package/src/rules/amp/no-deprecated-function.js +12 -30
- package/src/rules/amp/no-unknown-function.js +31 -3
- 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-unknown-function.js +49 -1
- package/src/rules/ssjs/no-unsupported-syntax.js +26 -43
- package/src/rules/ssjs/require-hasownproperty.js +51 -30
|
@@ -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
|
},
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|
|
@@ -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:
|
|
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);
|