eslint-plugin-sfmc 0.1.3 → 1.0.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.
Files changed (55) hide show
  1. package/README.md +43 -47
  2. package/docs/rules/ssjs/arg-types.md +72 -0
  3. package/docs/rules/ssjs/core-method-arity.md +72 -0
  4. package/docs/rules/ssjs/no-deprecated-function.md +56 -0
  5. package/docs/rules/ssjs/no-property-call.md +95 -0
  6. package/docs/rules/ssjs/no-unknown-function.md +57 -0
  7. package/package.json +18 -5
  8. package/src/ampscript-parser.js +11 -4
  9. package/src/ampscript-processor.js +9 -3
  10. package/src/index.js +23 -33
  11. package/src/processor.js +12 -4
  12. package/src/rules/amp/function-arity.js +3 -1
  13. package/src/rules/amp/naming-convention.js +7 -3
  14. package/src/rules/amp/no-deprecated-function.js +6 -2
  15. package/src/rules/amp/no-email-excluded-function.js +3 -1
  16. package/src/rules/amp/no-inline-statement.js +3 -1
  17. package/src/rules/amp/no-nested-ampscript-delimiter.js +5 -5
  18. package/src/rules/amp/no-nested-script-tag.js +3 -1
  19. package/src/rules/amp/no-smart-quotes.js +1 -1
  20. package/src/rules/amp/prefer-attribute-value.js +10 -4
  21. package/src/rules/amp/require-rowcount-check.js +3 -1
  22. package/src/rules/amp/require-variable-declaration.js +7 -3
  23. package/src/rules/ssjs/cache-loop-length.js +10 -4
  24. package/src/rules/ssjs/no-deprecated-function.js +181 -0
  25. package/src/rules/ssjs/no-hardcoded-credentials.js +3 -1
  26. package/src/rules/ssjs/no-property-call.js +136 -0
  27. package/src/rules/ssjs/no-treatascontent-injection.js +8 -4
  28. package/src/rules/ssjs/no-unavailable-method.js +30 -10
  29. package/src/rules/ssjs/no-unknown-function.js +236 -0
  30. package/src/rules/ssjs/no-unsupported-syntax.js +10 -5
  31. package/src/rules/ssjs/platform-function-arity.js +6 -2
  32. package/src/rules/ssjs/prefer-parsejson-safe-arg.js +17 -9
  33. package/src/rules/ssjs/prefer-platform-load-version.js +1 -4
  34. package/src/rules/ssjs/require-hasownproperty.js +34 -27
  35. package/src/rules/ssjs/require-platform-load-order.js +6 -2
  36. package/src/rules/ssjs/require-platform-load.js +32 -12
  37. package/src/rules/ssjs/ssjs-arg-types.js +345 -0
  38. package/src/rules/ssjs/ssjs-core-method-arity.js +176 -0
  39. package/src/ssjs-processor.js +3 -1
  40. package/docs/rules/ssjs/no-unknown-core-method.md +0 -46
  41. package/docs/rules/ssjs/no-unknown-http-method.md +0 -44
  42. package/docs/rules/ssjs/no-unknown-platform-client-browser.md +0 -44
  43. package/docs/rules/ssjs/no-unknown-platform-function.md +0 -44
  44. package/docs/rules/ssjs/no-unknown-platform-request.md +0 -43
  45. package/docs/rules/ssjs/no-unknown-platform-response.md +0 -43
  46. package/docs/rules/ssjs/no-unknown-platform-variable.md +0 -43
  47. package/docs/rules/ssjs/no-unknown-wsproxy-method.md +0 -46
  48. package/src/rules/ssjs/no-unknown-core-method.js +0 -103
  49. package/src/rules/ssjs/no-unknown-http-method.js +0 -41
  50. package/src/rules/ssjs/no-unknown-platform-client-browser.js +0 -50
  51. package/src/rules/ssjs/no-unknown-platform-function.js +0 -49
  52. package/src/rules/ssjs/no-unknown-platform-request.js +0 -50
  53. package/src/rules/ssjs/no-unknown-platform-response.js +0 -50
  54. package/src/rules/ssjs/no-unknown-platform-variable.js +0 -50
  55. package/src/rules/ssjs/no-unknown-wsproxy-method.js +0 -71
@@ -31,7 +31,9 @@ export default {
31
31
 
32
32
  const stmts = body.type === 'BlockStatement' ? body.body : [body];
33
33
 
34
- if (stmts.length === 0) return;
34
+ if (stmts.length === 0) {
35
+ return;
36
+ }
35
37
 
36
38
  const hasGuard = stmts.some((stmt) => containsHasOwnProperty(stmt));
37
39
 
@@ -42,34 +44,32 @@ export default {
42
44
  context.report({
43
45
  node,
44
46
  messageId: 'missingGuard',
45
- suggest:
46
- keyName
47
- ? [
48
- {
49
- messageId: 'suggestAddGuard',
50
- data: { obj: objText, key: keyName },
51
- fix(fixer) {
52
- if (body.type === 'BlockStatement') {
53
- // Wrap the inner content of the existing block.
54
- const inner = context.sourceCode
55
- .getText(body)
56
- .slice(1, -1);
57
- return fixer.replaceText(
58
- body,
59
- `{ if (${objText}.hasOwnProperty(${keyName})) {${inner}} }`,
60
- );
61
- }
62
- // Single-statement body — create a new block with guard.
63
- const stmtText =
64
- context.sourceCode.getText(body);
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);
65
58
  return fixer.replaceText(
66
59
  body,
67
- `{ if (${objText}.hasOwnProperty(${keyName})) { ${stmtText} } }`,
60
+ `{ if (${objText}.hasOwnProperty(${keyName})) {${inner}} }`,
68
61
  );
69
- },
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
+ );
70
69
  },
71
- ]
72
- : [],
70
+ },
71
+ ]
72
+ : [],
73
73
  });
74
74
  }
75
75
  },
@@ -78,7 +78,9 @@ export default {
78
78
  };
79
79
 
80
80
  function containsHasOwnProperty(node) {
81
- if (!node) return false;
81
+ if (!node) {
82
+ return false;
83
+ }
82
84
 
83
85
  if (node.type === 'IfStatement' && node.test && hasOwnPropertyTest(node.test)) {
84
86
  return true;
@@ -104,7 +106,9 @@ function containsHasOwnProperty(node) {
104
106
  }
105
107
 
106
108
  function hasOwnPropertyTest(node) {
107
- if (!node) return false;
109
+ if (!node) {
110
+ return false;
111
+ }
108
112
 
109
113
  if (
110
114
  node.type === 'CallExpression' &&
@@ -129,6 +133,9 @@ function hasOwnPropertyTest(node) {
129
133
  /**
130
134
  * Extracts the loop key variable name from a for-in left-hand side.
131
135
  * Handles both `for (var k in obj)` and `for (k in obj)`.
136
+ *
137
+ * @param left - The left-hand side node of a ForInStatement.
138
+ * @returns The key variable name, or null if it cannot be determined.
132
139
  */
133
140
  function getKeyName(left) {
134
141
  if (left.type === 'VariableDeclaration' && left.declarations.length > 0) {
@@ -44,7 +44,9 @@ export default {
44
44
  },
45
45
 
46
46
  'Program:exit'() {
47
- if (platformLoadLine === null) return;
47
+ if (platformLoadLine === null) {
48
+ return;
49
+ }
48
50
 
49
51
  for (const usage of coreUsages) {
50
52
  if (usage.line < platformLoadLine) {
@@ -87,7 +89,9 @@ function isPlatformLoadCall(node) {
87
89
 
88
90
  function getCoreObjectUsage(node) {
89
91
  const callee = node.callee;
90
- if (callee.type !== 'MemberExpression') return null;
92
+ if (callee.type !== 'MemberExpression') {
93
+ return null;
94
+ }
91
95
 
92
96
  if (
93
97
  callee.object.type === 'MemberExpression' &&
@@ -10,10 +10,16 @@
10
10
  * reported violation so ESLint does not duplicate the insertion.
11
11
  */
12
12
 
13
- import { coreObjectNames } from 'ssjs-data';
13
+ import { coreObjectNames, SSJS_GLOBALS } from 'ssjs-data';
14
14
 
15
15
  const TOP_LEVEL_CORE_NAMES = new Set([...coreObjectNames].map((n) => n.split('.')[0]));
16
16
 
17
+ // Globals that resolve at runtime only after Platform.Load("core", ...) has been called.
18
+ // E.g. Now(), Write(), GUID(), Base64Encode(), Attribute.GetValue(), DateTime.Parse(), …
19
+ const REQUIRES_CORE_LOAD_GLOBALS = new Set(
20
+ SSJS_GLOBALS.filter((g) => g.requiresCoreLoad).map((g) => g.name.toLowerCase()),
21
+ );
22
+
17
23
  const PLATFORM_LOAD_STATEMENT = 'Platform.Load("core", "1.1.5");\n';
18
24
 
19
25
  export default {
@@ -49,8 +55,7 @@ export default {
49
55
 
50
56
  'Program:exit'() {
51
57
  if (!hasPlatformLoad) {
52
- for (let i = 0; i < pendingReports.length; i++) {
53
- const { node, name } = pendingReports[i];
58
+ for (const [i, { node, name }] of pendingReports.entries()) {
54
59
  context.report({
55
60
  node,
56
61
  messageId: 'missingLoad',
@@ -97,8 +102,20 @@ function isPlatformLoadCall(node) {
97
102
 
98
103
  function getCoreObjectUsage(node) {
99
104
  const callee = node.callee;
100
- if (callee.type !== 'MemberExpression') return null;
101
105
 
106
+ // Bare call: Now(), Write(), GUID(), Base64Encode(), Redirect(), …
107
+ if (callee.type === 'Identifier') {
108
+ if (REQUIRES_CORE_LOAD_GLOBALS.has(callee.name.toLowerCase())) {
109
+ return callee.name;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ if (callee.type !== 'MemberExpression') {
115
+ return null;
116
+ }
117
+
118
+ // Nested Core Library call: DataExtension.Rows.Init(…)
102
119
  if (
103
120
  callee.object.type === 'MemberExpression' &&
104
121
  callee.object.object.type === 'Identifier' &&
@@ -109,14 +126,17 @@ function getCoreObjectUsage(node) {
109
126
  return `${callee.object.object.name}.${callee.object.property.name}`;
110
127
  }
111
128
 
112
- if (
113
- callee.object.type === 'Identifier' &&
114
- TOP_LEVEL_CORE_NAMES.has(callee.object.name) &&
115
- callee.property.type === 'Identifier'
116
- ) {
117
- const method = callee.property.name;
118
- if (method === 'Init') {
119
- return callee.object.name;
129
+ if (callee.object.type === 'Identifier' && callee.property.type === 'Identifier') {
130
+ const objectName = callee.object.name;
131
+
132
+ // Core Library static Init: DataExtension.Init(…), Subscriber.Init(…),
133
+ if (TOP_LEVEL_CORE_NAMES.has(objectName) && callee.property.name === 'Init') {
134
+ return objectName;
135
+ }
136
+
137
+ // requiresCoreLoad globals used as object: Attribute.GetValue(…), DateTime.Parse(…), …
138
+ if (REQUIRES_CORE_LOAD_GLOBALS.has(objectName.toLowerCase())) {
139
+ return `${objectName}.${callee.property.name}`;
120
140
  }
121
141
  }
122
142
 
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Rule: ssjs-arg-types
3
+ *
4
+ * Checks that literal arguments match the expected parameter types for known
5
+ * SSJS functions and methods. Only literal values are checked — variable
6
+ * arguments are skipped because their type cannot be determined statically.
7
+ *
8
+ * Covers:
9
+ * - Platform.Function.*, Platform.Variable.*, Platform.Response.*,
10
+ * Platform.Request.*, Platform.Recipient.*
11
+ * - HTTP.*, HTTPHeader.*, Attribute.*
12
+ * - WSProxy instance methods
13
+ * - Core Library static calls (DataExtension.Init, BounceEvent.Retrieve, …)
14
+ * - Core Library instance methods (var de = DataExtension.Init(…); de.Add(…))
15
+ * - Global functions (Format, String, Error)
16
+ */
17
+
18
+ import {
19
+ platformFunctionLookup,
20
+ platformResponseLookup,
21
+ platformVariableLookup,
22
+ platformRequestLookup,
23
+ platformRecipientLookup,
24
+ httpMethodLookup,
25
+ httpHeaderMethodLookup,
26
+ wsproxyMethodLookup,
27
+ attributeMethodLookup,
28
+ ssjsGlobalsLookup,
29
+ coreMethodArityLookup,
30
+ coreObjectNames,
31
+ } from 'ssjs-data';
32
+
33
+ // Platform.SubNS → lookup Map
34
+ const PLATFORM_SUBNS_LOOKUPS = new Map([
35
+ ['function', platformFunctionLookup],
36
+ ['variable', platformVariableLookup],
37
+ ['response', platformResponseLookup],
38
+ ['request', platformRequestLookup],
39
+ ['recipient', platformRecipientLookup],
40
+ ]);
41
+
42
+ // Top-level non-Platform objects with static method lookups
43
+ const TOPLEVEL_LOOKUPS = new Map([
44
+ ['http', httpMethodLookup],
45
+ ['httpheader', httpHeaderMethodLookup],
46
+ ['attribute', attributeMethodLookup],
47
+ ]);
48
+
49
+ export default {
50
+ meta: {
51
+ type: 'suggestion',
52
+ docs: {
53
+ description:
54
+ 'Check that literal arguments match expected parameter types for SSJS functions',
55
+ },
56
+ messages: {
57
+ typeMismatch:
58
+ "Argument {{pos}} ('{{param}}') of '{{call}}' expects type '{{expected}}' but received '{{actual}}'.",
59
+ },
60
+ schema: [],
61
+ },
62
+
63
+ create(context) {
64
+ const coreVars = new Map(); // varName → className (Core Library instances)
65
+ const wsproxyVars = new Set(); // varNames assigned new WSProxy()
66
+
67
+ function checkArgs(entry, args, callName) {
68
+ if (!entry || !entry.params || entry.params.length === 0) {
69
+ return;
70
+ }
71
+ for (const [i, arg] of args.entries()) {
72
+ const param = entry.params[i];
73
+ if (!param) {
74
+ break;
75
+ } // beyond declared params — arity rule handles this
76
+ if (!param.type || param.type === 'any') {
77
+ continue;
78
+ }
79
+ const actual = inferArgType(arg);
80
+ if (actual === null) {
81
+ continue;
82
+ } // unknown type (variable/call) — skip
83
+ if (!isTypeCompatible(actual, param.type)) {
84
+ context.report({
85
+ node: arg,
86
+ messageId: 'typeMismatch',
87
+ data: {
88
+ pos: String(i + 1),
89
+ param: param.name,
90
+ call: callName,
91
+ expected: param.type,
92
+ actual,
93
+ },
94
+ });
95
+ }
96
+ }
97
+ }
98
+
99
+ return {
100
+ VariableDeclaration(node) {
101
+ for (const decl of node.declarations) {
102
+ if (!decl.init || !decl.id || decl.id.type !== 'Identifier') {
103
+ continue;
104
+ }
105
+ if (isWSProxyConstructor(decl.init)) {
106
+ wsproxyVars.add(decl.id.name);
107
+ continue;
108
+ }
109
+ const coreType = getCoreInitType(decl.init);
110
+ if (coreType) {
111
+ coreVars.set(decl.id.name, coreType);
112
+ }
113
+ }
114
+ },
115
+
116
+ AssignmentExpression(node) {
117
+ if (node.left.type !== 'Identifier') {
118
+ return;
119
+ }
120
+ if (isWSProxyConstructor(node.right)) {
121
+ wsproxyVars.add(node.left.name);
122
+ return;
123
+ }
124
+ const coreType = getCoreInitType(node.right);
125
+ if (coreType) {
126
+ coreVars.set(node.left.name, coreType);
127
+ }
128
+ },
129
+
130
+ CallExpression(node) {
131
+ const callee = node.callee;
132
+
133
+ // Global function: Format(...), String(...), Error(...)
134
+ if (callee.type === 'Identifier') {
135
+ const entry = ssjsGlobalsLookup.get(callee.name.toLowerCase());
136
+ if (entry) {
137
+ checkArgs(entry, node.arguments, callee.name);
138
+ }
139
+ return;
140
+ }
141
+
142
+ if (callee.type !== 'MemberExpression') {
143
+ return;
144
+ }
145
+ if (callee.property.type !== 'Identifier') {
146
+ return;
147
+ }
148
+ const methodName = callee.property.name;
149
+
150
+ // Platform.SubNS.Method(...): Platform.Function.X, Platform.Response.X, …
151
+ if (
152
+ callee.object.type === 'MemberExpression' &&
153
+ callee.object.object.type === 'Identifier' &&
154
+ callee.object.object.name === 'Platform' &&
155
+ callee.object.property.type === 'Identifier'
156
+ ) {
157
+ const subNs = callee.object.property.name.toLowerCase();
158
+ const lookup = PLATFORM_SUBNS_LOOKUPS.get(subNs);
159
+ if (lookup) {
160
+ const entry = lookup.get(methodName.toLowerCase());
161
+ if (entry) {
162
+ checkArgs(
163
+ entry,
164
+ node.arguments,
165
+ `Platform.${callee.object.property.name}.${methodName}`,
166
+ );
167
+ }
168
+ }
169
+ return;
170
+ }
171
+
172
+ if (callee.object.type === 'Identifier') {
173
+ const objName = callee.object.name;
174
+ const objLower = objName.toLowerCase();
175
+
176
+ // Static top-level: HTTP.Post(...), HTTPHeader.SetValue(...), Attribute.GetValue(...)
177
+ const topLookup = TOPLEVEL_LOOKUPS.get(objLower);
178
+ if (topLookup) {
179
+ const entry = topLookup.get(methodName.toLowerCase());
180
+ if (entry) {
181
+ checkArgs(entry, node.arguments, `${objName}.${methodName}`);
182
+ }
183
+ return;
184
+ }
185
+
186
+ // WSProxy instance method: proxy.Retrieve(...)
187
+ if (wsproxyVars.has(objName)) {
188
+ const entry = wsproxyMethodLookup.get(methodName.toLowerCase());
189
+ if (entry) {
190
+ checkArgs(entry, node.arguments, `WSProxy.${methodName}`);
191
+ }
192
+ return;
193
+ }
194
+
195
+ // Core Library instance method: de.Add(...)
196
+ const coreType = coreVars.get(objName);
197
+ if (coreType) {
198
+ const classLookup = coreMethodArityLookup.get(coreType.toLowerCase());
199
+ if (classLookup) {
200
+ const entry = classLookup.get(methodName.toLowerCase());
201
+ if (entry) {
202
+ checkArgs(entry, node.arguments, `${coreType}.${methodName}`);
203
+ }
204
+ }
205
+ return;
206
+ }
207
+
208
+ // Static Core Library single-name: DataExtension.Init(...), BounceEvent.Retrieve(...)
209
+ if (coreObjectNames.has(objName)) {
210
+ const classLookup = coreMethodArityLookup.get(objLower);
211
+ if (classLookup) {
212
+ const entry = classLookup.get(methodName.toLowerCase());
213
+ if (entry) {
214
+ checkArgs(entry, node.arguments, `${objName}.${methodName}`);
215
+ }
216
+ }
217
+ }
218
+ return;
219
+ }
220
+
221
+ // Multi-part static: DataExtension.Rows.Add(...), TriggeredSend.Tracking.Clicks.Retrieve(...)
222
+ const objectPath = getMemberPath(callee.object);
223
+ if (objectPath && coreObjectNames.has(objectPath)) {
224
+ const classLookup = coreMethodArityLookup.get(objectPath.toLowerCase());
225
+ if (classLookup) {
226
+ const entry = classLookup.get(methodName.toLowerCase());
227
+ if (entry) {
228
+ checkArgs(entry, node.arguments, `${objectPath}.${methodName}`);
229
+ }
230
+ }
231
+ }
232
+ },
233
+ };
234
+ },
235
+ };
236
+
237
+ function getCoreInitType(node) {
238
+ if (!node || node.type !== 'CallExpression') {
239
+ return null;
240
+ }
241
+ const callee = node.callee;
242
+ if (callee.type !== 'MemberExpression') {
243
+ return null;
244
+ }
245
+ if (callee.property.type !== 'Identifier' || callee.property.name !== 'Init') {
246
+ return null;
247
+ }
248
+ if (callee.object.type === 'Identifier' && coreObjectNames.has(callee.object.name)) {
249
+ return callee.object.name;
250
+ }
251
+ if (
252
+ callee.object.type === 'MemberExpression' &&
253
+ callee.object.object.type === 'Identifier' &&
254
+ callee.object.property.type === 'Identifier'
255
+ ) {
256
+ const fullName = `${callee.object.object.name}.${callee.object.property.name}`;
257
+ if (coreObjectNames.has(fullName)) {
258
+ return fullName;
259
+ }
260
+ }
261
+ return null;
262
+ }
263
+
264
+ function getMemberPath(node) {
265
+ if (node.type === 'Identifier') {
266
+ return node.name;
267
+ }
268
+ if (node.type === 'MemberExpression' && node.property.type === 'Identifier') {
269
+ const obj = getMemberPath(node.object);
270
+ return obj ? `${obj}.${node.property.name}` : null;
271
+ }
272
+ return null;
273
+ }
274
+
275
+ function inferArgType(node) {
276
+ if (!node || node.type === 'SpreadElement') {
277
+ return null;
278
+ }
279
+ if (node.type === 'Literal') {
280
+ if (typeof node.value === 'string') {
281
+ return 'string';
282
+ }
283
+ if (typeof node.value === 'number') {
284
+ return 'number';
285
+ }
286
+ if (typeof node.value === 'boolean') {
287
+ return 'boolean';
288
+ }
289
+ if (node.value === null) {
290
+ return 'null';
291
+ }
292
+ }
293
+ if (node.type === 'TemplateLiteral' && !node.tag) {
294
+ return 'string';
295
+ }
296
+ if (node.type === 'ArrayExpression') {
297
+ const elems = node.elements.filter(Boolean);
298
+ if (elems.length === 0) {
299
+ return 'array';
300
+ }
301
+ if (elems.every((e) => e.type === 'Literal' && typeof e.value === 'string')) {
302
+ return 'string[]';
303
+ }
304
+ if (elems.every((e) => e.type === 'Literal' && typeof e.value === 'number')) {
305
+ return 'number[]';
306
+ }
307
+ return 'array';
308
+ }
309
+ if (node.type === 'ObjectExpression') {
310
+ return 'object';
311
+ }
312
+ return null;
313
+ }
314
+
315
+ function isTypeCompatible(actual, expected) {
316
+ if (!expected || expected === 'any') {
317
+ return true;
318
+ }
319
+ const allowed = expected.split('|').map((t) => t.trim());
320
+ if (allowed.includes(actual)) {
321
+ return true;
322
+ }
323
+ // 'array' is compatible with typed array variants
324
+ if (allowed.includes('array') && (actual === 'string[]' || actual === 'number[]')) {
325
+ return true;
326
+ }
327
+ return false;
328
+ }
329
+
330
+ function isWSProxyConstructor(node) {
331
+ if (!node || node.type !== 'NewExpression') {
332
+ return false;
333
+ }
334
+ const c = node.callee;
335
+ return (
336
+ c.type === 'MemberExpression' &&
337
+ c.property.type === 'Identifier' &&
338
+ c.property.name === 'WSProxy' &&
339
+ c.object.type === 'MemberExpression' &&
340
+ c.object.property.type === 'Identifier' &&
341
+ c.object.property.name === 'Util' &&
342
+ c.object.object.type === 'Identifier' &&
343
+ c.object.object.name === 'Script'
344
+ );
345
+ }