eslint-plugin-sfmc 0.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/LICENSE +21 -0
- package/README.md +100 -0
- package/docs/rules/amp/function-arity.md +53 -0
- package/docs/rules/amp/naming-convention.md +76 -0
- package/docs/rules/amp/no-deprecated-function.md +78 -0
- package/docs/rules/amp/no-email-excluded-function.md +69 -0
- package/docs/rules/amp/no-empty-block.md +59 -0
- package/docs/rules/amp/no-empty-then.md +62 -0
- package/docs/rules/amp/no-html-comment.md +67 -0
- package/docs/rules/amp/no-inline-statement.md +51 -0
- package/docs/rules/amp/no-js-line-comment.md +54 -0
- package/docs/rules/amp/no-loop-counter-assign.md +52 -0
- package/docs/rules/amp/no-nested-ampscript-delimiter.md +69 -0
- package/docs/rules/amp/no-nested-script-tag.md +58 -0
- package/docs/rules/amp/no-smart-quotes.md +60 -0
- package/docs/rules/amp/no-unknown-function.md +50 -0
- package/docs/rules/amp/no-var-redeclaration.md +52 -0
- package/docs/rules/amp/prefer-attribute-value.md +64 -0
- package/docs/rules/amp/require-rowcount-check.md +62 -0
- package/docs/rules/amp/require-variable-declaration.md +51 -0
- package/docs/rules/amp/set-requires-target.md +47 -0
- package/docs/rules/ssjs/cache-loop-length.md +59 -0
- package/docs/rules/ssjs/no-hardcoded-credentials.md +52 -0
- package/docs/rules/ssjs/no-switch-default.md +37 -0
- package/docs/rules/ssjs/no-treatascontent-injection.md +38 -0
- package/docs/rules/ssjs/no-unavailable-method.md +134 -0
- package/docs/rules/ssjs/no-unknown-core-method.md +46 -0
- package/docs/rules/ssjs/no-unknown-http-method.md +44 -0
- package/docs/rules/ssjs/no-unknown-platform-client-browser.md +44 -0
- package/docs/rules/ssjs/no-unknown-platform-function.md +44 -0
- package/docs/rules/ssjs/no-unknown-platform-request.md +43 -0
- package/docs/rules/ssjs/no-unknown-platform-response.md +43 -0
- package/docs/rules/ssjs/no-unknown-platform-variable.md +43 -0
- package/docs/rules/ssjs/no-unknown-wsproxy-method.md +46 -0
- package/docs/rules/ssjs/no-unsupported-syntax.md +83 -0
- package/docs/rules/ssjs/platform-function-arity.md +45 -0
- package/docs/rules/ssjs/prefer-parsejson-safe-arg.md +42 -0
- package/docs/rules/ssjs/prefer-platform-load-version.md +68 -0
- package/docs/rules/ssjs/require-hasownproperty.md +57 -0
- package/docs/rules/ssjs/require-platform-load-order.md +46 -0
- package/docs/rules/ssjs/require-platform-load.md +57 -0
- package/package.json +52 -0
- package/src/ampscript-parser.js +159 -0
- package/src/ampscript-processor.js +132 -0
- package/src/index.js +355 -0
- package/src/processor.js +147 -0
- package/src/rules/amp/function-arity.js +51 -0
- package/src/rules/amp/naming-convention.js +58 -0
- package/src/rules/amp/no-deprecated-function.js +75 -0
- package/src/rules/amp/no-email-excluded-function.js +72 -0
- package/src/rules/amp/no-empty-block.js +37 -0
- package/src/rules/amp/no-empty-then.js +34 -0
- package/src/rules/amp/no-html-comment.js +57 -0
- package/src/rules/amp/no-inline-statement.js +45 -0
- package/src/rules/amp/no-js-line-comment.js +54 -0
- package/src/rules/amp/no-loop-counter-assign.js +71 -0
- package/src/rules/amp/no-nested-ampscript-delimiter.js +102 -0
- package/src/rules/amp/no-nested-script-tag.js +65 -0
- package/src/rules/amp/no-smart-quotes.js +78 -0
- package/src/rules/amp/no-unknown-function.js +31 -0
- package/src/rules/amp/no-var-redeclaration.js +36 -0
- package/src/rules/amp/prefer-attribute-value.js +94 -0
- package/src/rules/amp/require-rowcount-check.js +116 -0
- package/src/rules/amp/require-variable-declaration.js +49 -0
- package/src/rules/amp/set-requires-target.js +27 -0
- package/src/rules/ssjs/cache-loop-length.js +92 -0
- package/src/rules/ssjs/no-hardcoded-credentials.js +66 -0
- package/src/rules/ssjs/no-switch-default.js +32 -0
- package/src/rules/ssjs/no-treatascontent-injection.js +76 -0
- package/src/rules/ssjs/no-unavailable-method.js +190 -0
- package/src/rules/ssjs/no-unknown-core-method.js +103 -0
- package/src/rules/ssjs/no-unknown-http-method.js +41 -0
- package/src/rules/ssjs/no-unknown-platform-client-browser.js +50 -0
- package/src/rules/ssjs/no-unknown-platform-function.js +49 -0
- package/src/rules/ssjs/no-unknown-platform-request.js +50 -0
- package/src/rules/ssjs/no-unknown-platform-response.js +50 -0
- package/src/rules/ssjs/no-unknown-platform-variable.js +50 -0
- package/src/rules/ssjs/no-unknown-wsproxy-method.js +71 -0
- package/src/rules/ssjs/no-unsupported-syntax.js +122 -0
- package/src/rules/ssjs/platform-function-arity.js +70 -0
- package/src/rules/ssjs/prefer-parsejson-safe-arg.js +94 -0
- package/src/rules/ssjs/prefer-platform-load-version.js +96 -0
- package/src/rules/ssjs/require-hasownproperty.js +142 -0
- package/src/rules/ssjs/require-platform-load-order.js +112 -0
- package/src/rules/ssjs/require-platform-load.js +124 -0
- package/src/ssjs-processor.js +48 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const SMART_QUOTE_RE = /[\u2018\u2019\u201C\u201D\u201A\u201E\u2039\u203A]/;
|
|
2
|
+
|
|
3
|
+
const SMART_QUOTES = {
|
|
4
|
+
'\u2018': 'left single curly quote \u2018',
|
|
5
|
+
'\u2019': 'right single curly quote \u2019',
|
|
6
|
+
'\u201C': 'left double curly quote \u201C',
|
|
7
|
+
'\u201D': 'right double curly quote \u201D',
|
|
8
|
+
'\u201A': 'single low-9 quote \u201A',
|
|
9
|
+
'\u201E': 'double low-9 quote \u201E',
|
|
10
|
+
'\u2039': 'single left angle quote \u2039',
|
|
11
|
+
'\u203A': 'single right angle quote \u203A',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Maps each smart-quote char to its nearest ASCII equivalent.
|
|
15
|
+
// Single-flavour smart quotes -> ' ; double-flavour -> "
|
|
16
|
+
const SMART_TO_ASCII = {
|
|
17
|
+
'\u2018': "'",
|
|
18
|
+
'\u2019': "'",
|
|
19
|
+
'\u201A': "'",
|
|
20
|
+
'\u2039': "'",
|
|
21
|
+
'\u203A': "'",
|
|
22
|
+
'\u201C': '"',
|
|
23
|
+
'\u201D': '"',
|
|
24
|
+
'\u201E': '"',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
meta: {
|
|
29
|
+
type: 'problem',
|
|
30
|
+
fixable: 'code',
|
|
31
|
+
docs: {
|
|
32
|
+
description:
|
|
33
|
+
'Disallow smart/curly quotes in string literals (AMPscript requires ASCII quotes)',
|
|
34
|
+
recommended: true,
|
|
35
|
+
},
|
|
36
|
+
messages: {
|
|
37
|
+
smartQuote:
|
|
38
|
+
'String contains {{kind}}. AMPscript only supports straight ASCII quotes (\' or ").',
|
|
39
|
+
},
|
|
40
|
+
schema: [],
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
create(context) {
|
|
44
|
+
return {
|
|
45
|
+
StringLiteral(node) {
|
|
46
|
+
if (SMART_QUOTE_RE.test(node.value)) {
|
|
47
|
+
for (const [char, kind] of Object.entries(SMART_QUOTES)) {
|
|
48
|
+
if (node.value.includes(char)) {
|
|
49
|
+
context.report({
|
|
50
|
+
node,
|
|
51
|
+
messageId: 'smartQuote',
|
|
52
|
+
data: { kind },
|
|
53
|
+
fix(fixer) {
|
|
54
|
+
const fixed = node.value.replace(
|
|
55
|
+
/[\u2018\u2019\u201C\u201D\u201A\u201E\u2039\u203A]/g,
|
|
56
|
+
(c) => SMART_TO_ASCII[c],
|
|
57
|
+
);
|
|
58
|
+
const q = node.quote;
|
|
59
|
+
// If the replacement introduces the outer delimiter, try switching quotes.
|
|
60
|
+
if (fixed.includes(q)) {
|
|
61
|
+
const other = q === '"' ? "'" : '"';
|
|
62
|
+
if (!fixed.includes(other)) {
|
|
63
|
+
return fixer.replaceText(node, other + fixed + other);
|
|
64
|
+
}
|
|
65
|
+
// Both quote chars present — cannot safely auto-fix.
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return fixer.replaceText(node, q + fixed + q);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { functionNames } from 'ampscript-data';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: 'problem',
|
|
6
|
+
docs: {
|
|
7
|
+
description:
|
|
8
|
+
'Disallow function calls to names not in the known AMPscript function catalog',
|
|
9
|
+
recommended: true,
|
|
10
|
+
},
|
|
11
|
+
messages: {
|
|
12
|
+
unknownFunction:
|
|
13
|
+
"'{{name}}' is not a recognized AMPscript function. AMPscript does not support custom functions.",
|
|
14
|
+
},
|
|
15
|
+
schema: [],
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
create(context) {
|
|
19
|
+
return {
|
|
20
|
+
FunctionCall(node) {
|
|
21
|
+
if (!functionNames.has(node.name.toLowerCase())) {
|
|
22
|
+
context.report({
|
|
23
|
+
node,
|
|
24
|
+
messageId: 'unknownFunction',
|
|
25
|
+
data: { name: node.name },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
meta: {
|
|
3
|
+
type: 'problem',
|
|
4
|
+
docs: {
|
|
5
|
+
description:
|
|
6
|
+
'Disallow re-declaring a variable with `var` (silently resets value to null)',
|
|
7
|
+
recommended: true,
|
|
8
|
+
},
|
|
9
|
+
messages: {
|
|
10
|
+
redeclared:
|
|
11
|
+
"'{{name}}' has already been declared. Re-declaring with `var` silently resets its value to null.",
|
|
12
|
+
},
|
|
13
|
+
schema: [],
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
create(context) {
|
|
17
|
+
const declared = new Map();
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
VarDeclaration(node) {
|
|
21
|
+
for (const variable of node.variables) {
|
|
22
|
+
const lower = variable.value.toLowerCase();
|
|
23
|
+
if (declared.has(lower)) {
|
|
24
|
+
context.report({
|
|
25
|
+
node: variable,
|
|
26
|
+
messageId: 'redeclared',
|
|
27
|
+
data: { name: variable.value },
|
|
28
|
+
});
|
|
29
|
+
} else {
|
|
30
|
+
declared.set(lower, variable);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggests wrapping bare personalization-string identifiers (e.g. `FirstName`)
|
|
3
|
+
* in `AttributeValue("FirstName")` to prevent send-aborting errors when the
|
|
4
|
+
* attribute is missing from the subscriber context.
|
|
5
|
+
*
|
|
6
|
+
* Only flags Identifier nodes that appear directly in SetStatement values or
|
|
7
|
+
* as standalone ExpressionStatements — contexts where a bare attribute
|
|
8
|
+
* reference is likely intentional data access rather than a function name or
|
|
9
|
+
* a value inside a function call.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { functionNames } from 'ampscript-data';
|
|
13
|
+
|
|
14
|
+
const AMPSCRIPT_KEYWORDS = new Set([
|
|
15
|
+
'var',
|
|
16
|
+
'set',
|
|
17
|
+
'if',
|
|
18
|
+
'then',
|
|
19
|
+
'elseif',
|
|
20
|
+
'else',
|
|
21
|
+
'endif',
|
|
22
|
+
'for',
|
|
23
|
+
'to',
|
|
24
|
+
'downto',
|
|
25
|
+
'do',
|
|
26
|
+
'next',
|
|
27
|
+
'and',
|
|
28
|
+
'or',
|
|
29
|
+
'not',
|
|
30
|
+
'true',
|
|
31
|
+
'false',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function isLikelyPersonalization(node) {
|
|
35
|
+
if (node.type !== 'Identifier') return false;
|
|
36
|
+
const lower = node.value.toLowerCase();
|
|
37
|
+
if (functionNames.has(lower)) return false;
|
|
38
|
+
if (AMPSCRIPT_KEYWORDS.has(lower)) return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
meta: {
|
|
44
|
+
type: 'suggestion',
|
|
45
|
+
hasSuggestions: true,
|
|
46
|
+
docs: {
|
|
47
|
+
description:
|
|
48
|
+
'Prefer AttributeValue() over bare personalization strings for safe attribute access',
|
|
49
|
+
recommended: false,
|
|
50
|
+
},
|
|
51
|
+
messages: {
|
|
52
|
+
preferAttributeValue:
|
|
53
|
+
'Use `AttributeValue("{{name}}")` instead of bare `{{name}}` to safely handle missing attributes.',
|
|
54
|
+
wrapWithAttributeValue: 'Wrap \'{{name}}\' in AttributeValue() for safe attribute access',
|
|
55
|
+
},
|
|
56
|
+
schema: [],
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
create(context) {
|
|
60
|
+
function reportWithSuggestion(identNode) {
|
|
61
|
+
context.report({
|
|
62
|
+
node: identNode,
|
|
63
|
+
messageId: 'preferAttributeValue',
|
|
64
|
+
data: { name: identNode.value },
|
|
65
|
+
suggest: [
|
|
66
|
+
{
|
|
67
|
+
messageId: 'wrapWithAttributeValue',
|
|
68
|
+
data: { name: identNode.value },
|
|
69
|
+
fix(fixer) {
|
|
70
|
+
return fixer.replaceText(
|
|
71
|
+
identNode,
|
|
72
|
+
`AttributeValue("${identNode.value}")`,
|
|
73
|
+
);
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
SetStatement(node) {
|
|
82
|
+
if (node.value && isLikelyPersonalization(node.value)) {
|
|
83
|
+
reportWithSuggestion(node.value);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
AmpExpressionStatement(node) {
|
|
88
|
+
if (isLikelyPersonalization(node.expression)) {
|
|
89
|
+
reportWithSuggestion(node.expression);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requires a RowCount > 0 guard before iterating over a LookupRows result
|
|
3
|
+
* with a FOR loop.
|
|
4
|
+
*
|
|
5
|
+
* Without the check, a RowCount of 0 causes a runtime error inside the loop.
|
|
6
|
+
* This rule tracks variables assigned from LookupRows/LookupOrderedRows
|
|
7
|
+
* (and their CS variants), then verifies that a RowCount check appears in
|
|
8
|
+
* an IF condition before the variable is used as a FOR loop's data source.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const LOOKUP_FUNCTIONS = new Set([
|
|
12
|
+
'lookuprows',
|
|
13
|
+
'lookuporderedrows',
|
|
14
|
+
'lookuprowscs',
|
|
15
|
+
'lookuporderedrowscs',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function isLookupCall(node) {
|
|
19
|
+
return node && node.type === 'FunctionCall' && LOOKUP_FUNCTIONS.has(node.name.toLowerCase());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isRowCountCall(node) {
|
|
23
|
+
return node && node.type === 'FunctionCall' && node.name.toLowerCase() === 'rowcount';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extractRowCountVariables(node, into) {
|
|
27
|
+
if (!node) return;
|
|
28
|
+
|
|
29
|
+
if (isRowCountCall(node) && node.arguments && node.arguments.length > 0) {
|
|
30
|
+
const argument = node.arguments[0];
|
|
31
|
+
if (argument.type === 'Variable') {
|
|
32
|
+
into.add(argument.value.toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (node.type === 'BinaryExpression') {
|
|
37
|
+
extractRowCountVariables(node.left, into);
|
|
38
|
+
extractRowCountVariables(node.right, into);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (node.type === 'LogicalExpression') {
|
|
42
|
+
extractRowCountVariables(node.left, into);
|
|
43
|
+
extractRowCountVariables(node.right, into);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (node.type === 'FunctionCall' && node.arguments) {
|
|
47
|
+
for (const argument of node.arguments) {
|
|
48
|
+
extractRowCountVariables(argument, into);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default {
|
|
54
|
+
meta: {
|
|
55
|
+
type: 'problem',
|
|
56
|
+
docs: {
|
|
57
|
+
description:
|
|
58
|
+
'Require a RowCount > 0 check before using a LookupRows result in a FOR loop',
|
|
59
|
+
recommended: true,
|
|
60
|
+
},
|
|
61
|
+
messages: {
|
|
62
|
+
missingRowCount:
|
|
63
|
+
"Variable '{{name}}' is used in a FOR loop without a prior RowCount > 0 check. This causes a runtime error when the rowset is empty.",
|
|
64
|
+
},
|
|
65
|
+
schema: [],
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
create(context) {
|
|
69
|
+
const lookupVariables = new Map();
|
|
70
|
+
const checkedVariables = new Set();
|
|
71
|
+
const forLoops = [];
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
SetStatement(node) {
|
|
75
|
+
if (node.target && node.value && isLookupCall(node.value)) {
|
|
76
|
+
lookupVariables.set(node.target.value.toLowerCase(), node.target);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
AmpIfStatement(node) {
|
|
81
|
+
if (node.condition) {
|
|
82
|
+
extractRowCountVariables(node.condition, checkedVariables);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
AmpForStatement(node) {
|
|
87
|
+
if (
|
|
88
|
+
node.endExpr &&
|
|
89
|
+
isRowCountCall(node.endExpr) &&
|
|
90
|
+
node.endExpr.arguments &&
|
|
91
|
+
node.endExpr.arguments.length > 0
|
|
92
|
+
) {
|
|
93
|
+
const argument = node.endExpr.arguments[0];
|
|
94
|
+
if (argument.type === 'Variable') {
|
|
95
|
+
forLoops.push({
|
|
96
|
+
varName: argument.value.toLowerCase(),
|
|
97
|
+
node,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
'Program:exit'() {
|
|
104
|
+
for (const loop of forLoops) {
|
|
105
|
+
if (lookupVariables.has(loop.varName) && !checkedVariables.has(loop.varName)) {
|
|
106
|
+
context.report({
|
|
107
|
+
node: loop.node,
|
|
108
|
+
messageId: 'missingRowCount',
|
|
109
|
+
data: { name: lookupVariables.get(loop.varName).value },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requires that @variables are declared with `var` before being used in
|
|
3
|
+
* `set` statements. System variables (@@) and personalization strings
|
|
4
|
+
* (no @ prefix) are not affected.
|
|
5
|
+
*
|
|
6
|
+
* AMPscript does not technically require `var`, but the ampscript.guide
|
|
7
|
+
* best-practices documentation recommends always declaring variables to
|
|
8
|
+
* make code self-documenting and catch typos.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
meta: {
|
|
13
|
+
type: 'suggestion',
|
|
14
|
+
docs: {
|
|
15
|
+
description: 'Require variables to be declared with `var` before use',
|
|
16
|
+
recommended: false,
|
|
17
|
+
},
|
|
18
|
+
messages: {
|
|
19
|
+
undeclared:
|
|
20
|
+
"'{{name}}' is used before being declared. Add `var {{name}}` before this `set` statement.",
|
|
21
|
+
},
|
|
22
|
+
schema: [],
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
create(context) {
|
|
26
|
+
const declared = new Set();
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
VarDeclaration(node) {
|
|
30
|
+
for (const v of node.variables) {
|
|
31
|
+
declared.add(v.value.toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
SetStatement(node) {
|
|
36
|
+
if (!node.target) return;
|
|
37
|
+
const name = node.target.value;
|
|
38
|
+
if (name.startsWith('@@')) return;
|
|
39
|
+
if (!declared.has(name.toLowerCase())) {
|
|
40
|
+
context.report({
|
|
41
|
+
node: node.target,
|
|
42
|
+
messageId: 'undeclared',
|
|
43
|
+
data: { name },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
meta: {
|
|
3
|
+
type: 'problem',
|
|
4
|
+
docs: {
|
|
5
|
+
description: 'Require `set` statements to have a target variable',
|
|
6
|
+
recommended: true,
|
|
7
|
+
},
|
|
8
|
+
messages: {
|
|
9
|
+
missingTarget:
|
|
10
|
+
'`set` statement is missing a target variable. Expected: `set @variable = expression`.',
|
|
11
|
+
},
|
|
12
|
+
schema: [],
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
create(context) {
|
|
16
|
+
return {
|
|
17
|
+
SetStatement(node) {
|
|
18
|
+
if (!node.target) {
|
|
19
|
+
context.report({
|
|
20
|
+
node,
|
|
21
|
+
messageId: 'missingTarget',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: cache-loop-length
|
|
3
|
+
*
|
|
4
|
+
* Flags `for` loops where `.length` is re-evaluated on every iteration.
|
|
5
|
+
* Recommends caching the length in a variable for better SSJS performance.
|
|
6
|
+
*
|
|
7
|
+
* Bad: for (var i = 0; i < arr.length; i++)
|
|
8
|
+
* Good: for (var i = 0, _len = arr.length; i < _len; i++)
|
|
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.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
meta: {
|
|
17
|
+
type: 'suggestion',
|
|
18
|
+
hasSuggestions: true,
|
|
19
|
+
docs: {
|
|
20
|
+
description: 'Require caching array length in for-loop conditions',
|
|
21
|
+
},
|
|
22
|
+
messages: {
|
|
23
|
+
cacheLength:
|
|
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
|
+
},
|
|
28
|
+
schema: [],
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
create(context) {
|
|
32
|
+
return {
|
|
33
|
+
ForStatement(node) {
|
|
34
|
+
const test = node.test;
|
|
35
|
+
if (!test || test.type !== 'BinaryExpression') return;
|
|
36
|
+
|
|
37
|
+
let lengthExpr = null;
|
|
38
|
+
if (containsMemberLength(test.right)) {
|
|
39
|
+
lengthExpr = test.right;
|
|
40
|
+
} else if (containsMemberLength(test.left)) {
|
|
41
|
+
lengthExpr = test.left;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!lengthExpr) return;
|
|
45
|
+
|
|
46
|
+
context.report({
|
|
47
|
+
node: test,
|
|
48
|
+
messageId: 'cacheLength',
|
|
49
|
+
suggest: buildCacheSuggestion(node, lengthExpr, context),
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function containsMemberLength(node) {
|
|
57
|
+
if (!node) return false;
|
|
58
|
+
if (
|
|
59
|
+
node.type === 'MemberExpression' &&
|
|
60
|
+
node.property.type === 'Identifier' &&
|
|
61
|
+
node.property.name === 'length'
|
|
62
|
+
) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildCacheSuggestion(forNode, lengthExpr, context) {
|
|
69
|
+
const init = forNode.init;
|
|
70
|
+
// Only suggest when init is a VariableDeclaration so we can safely
|
|
71
|
+
// append a new declarator without restructuring the loop header.
|
|
72
|
+
if (!init || init.type !== 'VariableDeclaration' || init.declarations.length === 0) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const objText = context.sourceCode.getText(lengthExpr.object);
|
|
77
|
+
const lenVar = '_len';
|
|
78
|
+
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
messageId: 'suggestCacheLength',
|
|
82
|
+
data: { obj: objText },
|
|
83
|
+
fix(fixer) {
|
|
84
|
+
const lastDecl = init.declarations[init.declarations.length - 1];
|
|
85
|
+
return [
|
|
86
|
+
fixer.insertTextAfter(lastDecl, `, ${lenVar} = ${objText}.length`),
|
|
87
|
+
fixer.replaceText(lengthExpr, lenVar),
|
|
88
|
+
];
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: no-hardcoded-credentials
|
|
3
|
+
*
|
|
4
|
+
* Detects string literals passed as key/IV/salt arguments to
|
|
5
|
+
* Platform.Function.DecryptSymmetric and EncryptSymmetric calls.
|
|
6
|
+
* These should use variable references (e.g. from a DE lookup)
|
|
7
|
+
* instead of hardcoded values.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const ENCRYPT_FUNCTIONS = new Set(['encryptsymmetric', 'decryptsymmetric']);
|
|
11
|
+
|
|
12
|
+
const KEY_ARG_INDICES = [1, 3, 5, 7];
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
meta: {
|
|
16
|
+
type: 'problem',
|
|
17
|
+
docs: {
|
|
18
|
+
description: 'Disallow hardcoded credentials in encryption/decryption calls',
|
|
19
|
+
},
|
|
20
|
+
messages: {
|
|
21
|
+
hardcodedCredential:
|
|
22
|
+
'Avoid hardcoded keys, IVs, or salts in {{fn}}(). Use a variable or DE lookup instead.',
|
|
23
|
+
},
|
|
24
|
+
schema: [],
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
create(context) {
|
|
28
|
+
return {
|
|
29
|
+
CallExpression(node) {
|
|
30
|
+
const callee = node.callee;
|
|
31
|
+
|
|
32
|
+
let functionName = null;
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
callee.type === 'MemberExpression' &&
|
|
36
|
+
callee.object.type === 'MemberExpression' &&
|
|
37
|
+
callee.object.object.type === 'Identifier' &&
|
|
38
|
+
callee.object.object.name === 'Platform' &&
|
|
39
|
+
callee.object.property.type === 'Identifier' &&
|
|
40
|
+
callee.object.property.name === 'Function' &&
|
|
41
|
+
callee.property.type === 'Identifier'
|
|
42
|
+
) {
|
|
43
|
+
functionName = callee.property.name;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!functionName || !ENCRYPT_FUNCTIONS.has(functionName.toLowerCase())) return;
|
|
47
|
+
|
|
48
|
+
for (const index of KEY_ARG_INDICES) {
|
|
49
|
+
const argument = node.arguments[index];
|
|
50
|
+
if (
|
|
51
|
+
argument &&
|
|
52
|
+
argument.type === 'Literal' &&
|
|
53
|
+
typeof argument.value === 'string' &&
|
|
54
|
+
argument.value.length > 0
|
|
55
|
+
) {
|
|
56
|
+
context.report({
|
|
57
|
+
node: argument,
|
|
58
|
+
messageId: 'hardcodedCredential',
|
|
59
|
+
data: { fn: functionName },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: no-switch-default
|
|
3
|
+
*
|
|
4
|
+
* The `default` case in switch statements may silently fail to execute in
|
|
5
|
+
* SFMC's SSJS engine. Explicitly enumerate all expected values instead.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
meta: {
|
|
10
|
+
type: 'problem',
|
|
11
|
+
docs: {
|
|
12
|
+
description:
|
|
13
|
+
"Disallow 'default' case in switch statements because SFMC SSJS may not process it",
|
|
14
|
+
},
|
|
15
|
+
messages: {
|
|
16
|
+
noDefault:
|
|
17
|
+
"The 'default' case in a switch statement may not execute in SFMC SSJS. " +
|
|
18
|
+
'List all expected values as explicit cases instead.',
|
|
19
|
+
},
|
|
20
|
+
schema: [],
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
create(context) {
|
|
24
|
+
return {
|
|
25
|
+
SwitchCase(node) {
|
|
26
|
+
if (node.test === null) {
|
|
27
|
+
context.report({ node, messageId: 'noDefault' });
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|