eslint-plugin-sfmc 4.2.0 → 4.4.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 CHANGED
@@ -128,7 +128,8 @@ export default [...sfmc.configs['recommended-next'], ...sfmc.configs['embedded-n
128
128
  | [`sfmc/ssjs-no-unsupported-syntax`](docs/rules/ssjs/no-unsupported-syntax.md) | `error` | Flag ES6+ syntax not supported by SFMC |
129
129
  | [`sfmc/ssjs-no-unknown-function`](docs/rules/ssjs/no-unknown-function.md) | `error` | Disallow unknown methods on Platform.\*, HTTP, Core Library, and WSProxy |
130
130
  | [`sfmc/ssjs-no-mcn-unsupported`](docs/rules/ssjs/no-mcn-unsupported.md) | off (`error` in `-next`) | Flag all SSJS API usage as unsupported in Marketing Cloud Next |
131
- | [`sfmc/ssjs-no-deprecated-function`](docs/rules/ssjs/no-deprecated-function.md) | `error` | Flag use of deprecated SFMC SSJS APIs (e.g. ContentArea, ContentAreaObj) |
131
+ | [`sfmc/ssjs-no-deprecated-function`](docs/rules/ssjs/no-deprecated-function.md) | `error` | Flag use of deprecated SFMC SSJS APIs (e.g. ContentArea, ErrorUtil) |
132
+ | [`sfmc/ssjs-no-nonexistent-global`](docs/rules/ssjs/no-nonexistent-global.md) | `error` | Flag documented SSJS globals that throw ReferenceError at runtime |
132
133
  | [`sfmc/ssjs-no-property-call`](docs/rules/ssjs/no-property-call.md) | `error` | Disallow calling Platform.Request/Response properties as functions |
133
134
  | [`sfmc/ssjs-no-clr-header-access`](docs/rules/ssjs/no-clr-header-access.md) | `error` | Disallow CLR-unsafe reads of `HttpResponse.headers`; read via `for..in` |
134
135
  | [`sfmc/ssjs-require-string-clr-content`](docs/rules/ssjs/require-string-clr-content.md) | `error` | Require wrapping `HttpResponse.content` with `String()` before use |
@@ -0,0 +1,71 @@
1
+ # `sfmc/ssjs-http-property-value`
2
+
3
+ Flags literal assignments to writable `Script.Util.HttpRequest` / `Script.Util.HttpGet`
4
+ instance properties whose value violates the property's documented, runtime-confirmed
5
+ constraint (allowed enum, integer-ness, or minimum).
6
+
7
+ Several request properties only accept a fixed set of values. Assigning something outside
8
+ that set throws or silently misbehaves in the SFMC SSJS engine:
9
+
10
+ | Property | Allowed values |
11
+ |---|---|
12
+ | `method` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
13
+ | `emptyContentHandling` | `0`, `1`, `2` |
14
+ | `retries` | non-negative integer |
15
+ | `timeout` | non-negative integer |
16
+
17
+ The allowed values live in [`ssjs-data`](../../../..) (`SCRIPT_UTIL_REQUEST_PROPERTIES` /
18
+ `SCRIPT_UTIL_HTTPGET_PROPERTIES` → `valueConstraint`), so this rule and the VS Code /
19
+ Cursor diagnostic (`ssjs/invalid-http-property-value`) share one source of truth.
20
+
21
+ ## Detection
22
+
23
+ The rule tracks data flow, so it only fires on a genuine request object:
24
+
25
+ 1. A **request** variable is one assigned from `new Script.Util.HttpRequest(...)` or
26
+ `Script.Util.HttpGet(...)`.
27
+ 2. An assignment `<requestVar>.<prop> = <literal>` where `<prop>` has a `valueConstraint`
28
+ and the right-hand side is a **literal** (string, number, or a negative numeric like
29
+ `-2.45`) is validated against the constraint.
30
+
31
+ Only literal values are checked. Variable / expression assignments cannot be verified
32
+ statically and are left alone to avoid false positives.
33
+
34
+ ## Examples
35
+
36
+ ### ❌ Incorrect
37
+
38
+ ```js
39
+ var req = new Script.Util.HttpRequest("https://api.example.com/data");
40
+
41
+ req.emptyContentHandling = 5; // ← allowed: 0 | 1 | 2
42
+ req.retries = -2.45; // ← must be a non-negative integer
43
+ req.method = 'POT'; // ← allowed: GET | POST | PUT | PATCH | DELETE
44
+ ```
45
+
46
+ ### ✅ Correct
47
+
48
+ ```js
49
+ var req = new Script.Util.HttpRequest("https://api.example.com/data");
50
+
51
+ req.emptyContentHandling = 1;
52
+ req.retries = 3;
53
+ req.method = 'POST';
54
+ ```
55
+
56
+ ## Fix (suggestions)
57
+
58
+ For enum violations the rule offers a suggestion per allowed value (e.g. replace `'POT'`
59
+ with `'GET'`, `'POST'`, …). Numeric violations are reported without an automatic
60
+ replacement, since there is no single unambiguous valid value.
61
+
62
+ The same diagnostic and quick-fix are available in the VS Code / Cursor extension
63
+ (`ssjs/invalid-http-property-value`).
64
+
65
+ ## Rule details
66
+
67
+ - **Type:** `problem`
68
+ - **Fixable:** No (offers suggestions)
69
+ - **Recommended:** Yes (`error`)
70
+ - **Strict:** Yes (`error`)
71
+ - **MCN:** Off (SSJS is not supported on Marketing Cloud Next)
@@ -1,7 +1,8 @@
1
1
  # `sfmc/ssjs-no-deprecated-function`
2
2
 
3
- Flags calls to deprecated SFMC SSJS APIs. Currently focuses on the **Content Areas** feature,
4
- which has been retired and no longer allows creating or updating content.
3
+ Flags calls to deprecated SFMC SSJS APIs, chiefly the **Content Areas** feature,
4
+ which has been retired and no longer allows creating or updating content, plus the
5
+ legacy `ErrorUtil` helper that only exists under the oldest Core version.
5
6
 
6
7
  ## What is flagged
7
8
 
@@ -16,6 +17,7 @@ which has been retired and no longer allows creating or updating content.
16
17
  | `ContentAreaObj.Retrieve(…)` | ContentAreaObj class is deprecated |
17
18
  | `<contentAreaVar>.Update(…)` | Instance method on a deprecated ContentAreaObj variable |
18
19
  | `<contentAreaVar>.Remove()` | Instance method on a deprecated ContentAreaObj variable |
20
+ | `ErrorUtil.ThrowWSProxyError(…)` | Only exists under `Platform.Load("Core", "1")`; undefined in newer Core versions — check `result.Status` and `throw new Error(…)` instead |
19
21
 
20
22
  ## Examples
21
23
 
@@ -0,0 +1,37 @@
1
+ # `sfmc/ssjs-no-nonexistent-global`
2
+
3
+ Flags bare-name SSJS globals that are officially documented but proven **not to exist
4
+ at runtime** — calling them throws a `ReferenceError`. Unlike deprecated APIs (which
5
+ still run), these never work.
6
+
7
+ ## What is flagged
8
+
9
+ | API | Reason |
10
+ |---|---|
11
+ | `Redirect(url, movedPermanently)` | Not injected by `Platform.Load` under any Core version; calling it throws a `ReferenceError`. Use `Platform.Response.Redirect(url, movedPermanently)` instead. |
12
+
13
+ The list is driven by `ssjs-data`'s `notDefinedAtRuntime` flag, so new phantom globals
14
+ are picked up automatically without editing this rule.
15
+
16
+ ## Examples
17
+
18
+ ### ❌ Incorrect
19
+
20
+ ```js
21
+ // Bare-name Redirect does not exist at runtime — throws ReferenceError
22
+ Redirect('https://example.com', false);
23
+ ```
24
+
25
+ ### ✅ Correct
26
+
27
+ ```js
28
+ // Platform.Response.Redirect is always available in CloudPages
29
+ Platform.Response.Redirect('https://example.com', false);
30
+ ```
31
+
32
+ ## Rule details
33
+
34
+ - **Type:** `problem`
35
+ - **Fixable:** No
36
+ - **Recommended:** Yes
37
+ - **Strict:** Yes
@@ -21,7 +21,8 @@ return wrong results:
21
21
  - `lastIndexOf` always returns `-1`.
22
22
 
23
23
  This rule detects both categories and offers a lightbulb suggestion to insert a polyfill
24
- at the end of the file.
24
+ at the top of the file (after a leading `/* global … */` directive when present), matching
25
+ the language-server quick-fix so both tools place polyfills consistently.
25
26
 
26
27
  ## Covered methods
27
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-sfmc",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "description": "ESLint plugin for Salesforce Marketing Cloud Engagement+Next - AMPscript, Server-Side JavaScript (SSJS) and Handlebars",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -42,7 +42,7 @@
42
42
  "eslint-plugin-mso-email": "^2.0.0",
43
43
  "handlebars-data": "^0.3.0",
44
44
  "mso-conditional-parser": "^2.0.1",
45
- "ssjs-data": "^0.16.0"
45
+ "ssjs-data": "^0.19.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "eslint": ">=9.0.0"
package/src/index.js CHANGED
@@ -43,6 +43,7 @@ import ssjsNoUnsupportedSyntax from './rules/ssjs/no-unsupported-syntax.js';
43
43
  import ssjsNoUnknownFunction from './rules/ssjs/no-unknown-function.js';
44
44
  import ssjsNoMcnUnsupported from './rules/ssjs/no-mcn-unsupported.js';
45
45
  import ssjsNoDeprecatedFunction from './rules/ssjs/no-deprecated-function.js';
46
+ import ssjsNoNonexistentGlobal from './rules/ssjs/no-nonexistent-global.js';
46
47
  import ssjsNoPropertyCall from './rules/ssjs/no-property-call.js';
47
48
  import ssjsPlatformFunctionArity from './rules/ssjs/platform-function-arity.js';
48
49
  import ssjsCacheLoopLength from './rules/ssjs/cache-loop-length.js';
@@ -58,6 +59,7 @@ import ssjsArgumentTypes from './rules/ssjs/ssjs-argument-types.js';
58
59
  import ssjsCoreMethodArity from './rules/ssjs/ssjs-core-method-arity.js';
59
60
  import ssjsNoClrHeaderAccess from './rules/ssjs/no-clr-header-access.js';
60
61
  import ssjsRequireStringClrContent from './rules/ssjs/require-string-clr-content.js';
62
+ import ssjsHttpPropertyValue from './rules/ssjs/http-property-value.js';
61
63
 
62
64
  // ── Handlebars (MCN) rules ──────────────────────────────────────────────────────
63
65
 
@@ -124,6 +126,7 @@ const plugin = {
124
126
  'ssjs-no-unknown-function': ssjsNoUnknownFunction,
125
127
  'ssjs-no-mcn-unsupported': ssjsNoMcnUnsupported,
126
128
  'ssjs-no-deprecated-function': ssjsNoDeprecatedFunction,
129
+ 'ssjs-no-nonexistent-global': ssjsNoNonexistentGlobal,
127
130
  'ssjs-no-property-call': ssjsNoPropertyCall,
128
131
  'ssjs-platform-function-arity': ssjsPlatformFunctionArity,
129
132
  'ssjs-cache-loop-length': ssjsCacheLoopLength,
@@ -139,6 +142,7 @@ const plugin = {
139
142
  'ssjs-core-method-arity': ssjsCoreMethodArity,
140
143
  'ssjs-no-clr-header-access': ssjsNoClrHeaderAccess,
141
144
  'ssjs-require-string-clr-content': ssjsRequireStringClrContent,
145
+ 'ssjs-http-property-value': ssjsHttpPropertyValue,
142
146
 
143
147
  // Handlebars (MCN) rules (hbs- prefix)
144
148
  'hbs-no-unknown-helper': hbsNoUnknownHelper,
@@ -169,6 +173,7 @@ const ssjsMcnRules = {
169
173
  'sfmc/ssjs-require-platform-load': 'off',
170
174
  'sfmc/ssjs-no-unsupported-syntax': 'off',
171
175
  'sfmc/ssjs-no-deprecated-function': 'off',
176
+ 'sfmc/ssjs-no-nonexistent-global': 'off',
172
177
  'sfmc/ssjs-no-property-call': 'off',
173
178
  'sfmc/ssjs-platform-function-arity': 'off',
174
179
  'sfmc/ssjs-cache-loop-length': 'off',
@@ -184,6 +189,7 @@ const ssjsMcnRules = {
184
189
  'sfmc/ssjs-core-method-arity': 'off',
185
190
  'sfmc/ssjs-no-clr-header-access': 'off',
186
191
  'sfmc/ssjs-require-string-clr-content': 'off',
192
+ 'sfmc/ssjs-http-property-value': 'off',
187
193
  'no-cond-assign': 'off',
188
194
  };
189
195
 
@@ -239,6 +245,7 @@ const ssjsRecommendedRules = {
239
245
  'sfmc/ssjs-no-unsupported-syntax': 'error',
240
246
  'sfmc/ssjs-no-unknown-function': 'error',
241
247
  'sfmc/ssjs-no-deprecated-function': 'error',
248
+ 'sfmc/ssjs-no-nonexistent-global': 'error',
242
249
  'sfmc/ssjs-no-property-call': 'error',
243
250
  'sfmc/ssjs-platform-function-arity': 'error',
244
251
  'sfmc/ssjs-cache-loop-length': 'warn',
@@ -254,6 +261,7 @@ const ssjsRecommendedRules = {
254
261
  'sfmc/ssjs-core-method-arity': 'warn',
255
262
  'sfmc/ssjs-no-clr-header-access': 'error',
256
263
  'sfmc/ssjs-require-string-clr-content': 'error',
264
+ 'sfmc/ssjs-http-property-value': 'error',
257
265
  'no-cond-assign': 'error',
258
266
  };
259
267
 
@@ -262,6 +270,7 @@ const ssjsStrictRules = {
262
270
  'sfmc/ssjs-no-unsupported-syntax': 'error',
263
271
  'sfmc/ssjs-no-unknown-function': 'error',
264
272
  'sfmc/ssjs-no-deprecated-function': 'error',
273
+ 'sfmc/ssjs-no-nonexistent-global': 'error',
265
274
  'sfmc/ssjs-no-property-call': 'error',
266
275
  'sfmc/ssjs-platform-function-arity': 'error',
267
276
  'sfmc/ssjs-cache-loop-length': 'error',
@@ -277,6 +286,7 @@ const ssjsStrictRules = {
277
286
  'sfmc/ssjs-core-method-arity': 'error',
278
287
  'sfmc/ssjs-no-clr-header-access': 'error',
279
288
  'sfmc/ssjs-require-string-clr-content': 'error',
289
+ 'sfmc/ssjs-http-property-value': 'error',
280
290
  'no-cond-assign': 'error',
281
291
  };
282
292
 
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Rule: ssjs-http-property-value
3
+ *
4
+ * Flags literal assignments to writable `Script.Util.HttpRequest` /
5
+ * `Script.Util.HttpGet` instance properties whose value violates the property's
6
+ * documented, runtime-confirmed constraint (allowed enum, integer-ness, or
7
+ * minimum). Examples that throw or misbehave at runtime:
8
+ *
9
+ * req.emptyContentHandling = 5; // allowed: 0 | 1 | 2
10
+ * req.retries = -2.45; // must be a non-negative integer
11
+ * req.method = 'POT'; // allowed: GET | POST | PUT | PATCH | DELETE
12
+ *
13
+ * Constraints live in ssjs-data (`SCRIPT_UTIL_REQUEST_PROPERTIES` /
14
+ * `SCRIPT_UTIL_HTTPGET_PROPERTIES` -> `valueConstraint`) so the LSP diagnostic
15
+ * (`ssjs/invalid-http-property-value`) and this rule share one source of truth.
16
+ * Only **literal** right-hand sides are checked; variables / expressions cannot
17
+ * be statically verified and are left alone to avoid false positives.
18
+ */
19
+
20
+ import { SCRIPT_UTIL_REQUEST_PROPERTIES, SCRIPT_UTIL_HTTPGET_PROPERTIES } from 'ssjs-data';
21
+
22
+ /**
23
+ * Map of property name => valueConstraint, built once from ssjs-data. When both
24
+ * request and get define the same property, the constraints are identical.
25
+ *
26
+ * @returns {Map<string, object>} property name -> valueConstraint
27
+ */
28
+ function buildConstraintLookup() {
29
+ const lookup = new Map();
30
+ for (const property of [...SCRIPT_UTIL_REQUEST_PROPERTIES, ...SCRIPT_UTIL_HTTPGET_PROPERTIES]) {
31
+ if (property && property.valueConstraint && !lookup.has(property.name)) {
32
+ lookup.set(property.name, property.valueConstraint);
33
+ }
34
+ }
35
+ return lookup;
36
+ }
37
+
38
+ const constraintLookup = buildConstraintLookup();
39
+
40
+ /**
41
+ * Returns true when the node is `Script.Util.HttpRequest` or
42
+ * `Script.Util.HttpGet` (a MemberExpression, optionally the callee of `new`).
43
+ *
44
+ * @param {import('eslint').Rule.Node} node - MemberExpression to test
45
+ * @returns {boolean} Whether it references a Script.Util HTTP constructor
46
+ */
47
+ function isHttpConstructorMember(node) {
48
+ return (
49
+ node.type === 'MemberExpression' &&
50
+ node.property.type === 'Identifier' &&
51
+ (node.property.name === 'HttpRequest' || node.property.name === 'HttpGet') &&
52
+ node.object.type === 'MemberExpression' &&
53
+ node.object.property.type === 'Identifier' &&
54
+ node.object.property.name === 'Util' &&
55
+ node.object.object.type === 'Identifier' &&
56
+ node.object.object.name === 'Script'
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Returns true when the node constructs a Script.Util HTTP request, i.e.
62
+ * `new Script.Util.HttpRequest(...)` or `Script.Util.HttpGet(...)`.
63
+ *
64
+ * @param {import('eslint').Rule.Node} node - init expression of a declarator
65
+ * @returns {boolean} Whether the expression yields an HTTP request instance
66
+ */
67
+ function isHttpRequestInit(node) {
68
+ if (!node) {
69
+ return false;
70
+ }
71
+ if (node.type === 'NewExpression' || node.type === 'CallExpression') {
72
+ return isHttpConstructorMember(node.callee);
73
+ }
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * Extracts the literal value from a RHS node, or null when it is not a plain
79
+ * literal (string / number / boolean, incl. a negative numeric like `-2.45`).
80
+ *
81
+ * @param {import('eslint').Rule.Node} node - the assignment right-hand side
82
+ * @returns {{value: string|number|boolean}|null} parsed value wrapper or null
83
+ */
84
+ function parseLiteral(node) {
85
+ if (node.type === 'Literal' && typeof node.value !== 'object' && node.value !== null) {
86
+ return { value: node.value };
87
+ }
88
+ // Unary minus / plus on a numeric literal, e.g. -2.45.
89
+ if (
90
+ node.type === 'UnaryExpression' &&
91
+ (node.operator === '-' || node.operator === '+') &&
92
+ node.argument.type === 'Literal' &&
93
+ typeof node.argument.value === 'number'
94
+ ) {
95
+ const n = node.operator === '-' ? -node.argument.value : node.argument.value;
96
+ return { value: n };
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Validates a value against a constraint. Returns a human-readable violation
103
+ * fragment (e.g. `must be one of "GET", "POST"`) or null when the value is ok.
104
+ *
105
+ * @param {string|number|boolean} value - the parsed literal value
106
+ * @param {object} constraint - the valueConstraint from ssjs-data
107
+ * @returns {string|null} violation description or null when valid
108
+ */
109
+ function checkConstraint(value, constraint) {
110
+ if (Array.isArray(constraint.enum)) {
111
+ if (!constraint.enum.includes(value)) {
112
+ const allowed = constraint.enum
113
+ .map((v) => (typeof v === 'string' ? `"${v}"` : String(v)))
114
+ .join(', ');
115
+ return `must be one of ${allowed}`;
116
+ }
117
+ return null;
118
+ }
119
+ if (constraint.numeric) {
120
+ if (typeof value !== 'number') {
121
+ return 'must be a number';
122
+ }
123
+ if (constraint.numeric === 'integer' && !Number.isSafeInteger(value)) {
124
+ return 'must be an integer';
125
+ }
126
+ if (typeof constraint.min === 'number' && value < constraint.min) {
127
+ return `must be >= ${constraint.min}`;
128
+ }
129
+ return null;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Builds source-ready replacement suggestions for a violation. Enum -> each
136
+ * allowed value (quoted for strings) with its optional `enumLabels` meaning.
137
+ * Numeric -> nothing (no single fix).
138
+ *
139
+ * @param {object} constraint - the valueConstraint from ssjs-data
140
+ * @returns {{code: string, label?: string}[]} replacement snippets to offer as suggestions
141
+ */
142
+ function buildSuggestions(constraint) {
143
+ if (Array.isArray(constraint.enum)) {
144
+ const labels = constraint.enumLabels;
145
+ return constraint.enum.map((v) => ({
146
+ code: typeof v === 'string' ? `'${v}'` : String(v),
147
+ label: labels ? labels[String(v)] : undefined,
148
+ }));
149
+ }
150
+ return [];
151
+ }
152
+
153
+ export default {
154
+ meta: {
155
+ type: 'problem',
156
+ hasSuggestions: true,
157
+ docs: {
158
+ description:
159
+ 'Disallow invalid literal values assigned to Script.Util.HttpRequest/HttpGet properties (e.g. method, emptyContentHandling, retries) based on their documented allowed values',
160
+ },
161
+ messages: {
162
+ invalidValue: 'Invalid value for {{ prop }}: it {{ violation }}.',
163
+ replaceWith: 'Replace with {{ value }}',
164
+ replaceWithLabel: 'Replace with {{ value }} ({{ label }})',
165
+ },
166
+ schema: [],
167
+ },
168
+
169
+ create(context) {
170
+ const requestVariables = new Set();
171
+ const pending = [];
172
+
173
+ return {
174
+ VariableDeclarator(node) {
175
+ if (node.id.type === 'Identifier' && isHttpRequestInit(node.init)) {
176
+ requestVariables.add(node.id.name);
177
+ }
178
+ },
179
+
180
+ AssignmentExpression(node) {
181
+ if (
182
+ node.operator !== '=' ||
183
+ node.left.type !== 'MemberExpression' ||
184
+ node.left.computed ||
185
+ node.left.object.type !== 'Identifier' ||
186
+ !requestVariables.has(node.left.object.name) ||
187
+ node.left.property.type !== 'Identifier'
188
+ ) {
189
+ return;
190
+ }
191
+ const propertyName = node.left.property.name;
192
+ const constraint = constraintLookup.get(propertyName);
193
+ if (!constraint) {
194
+ return;
195
+ }
196
+ const parsed = parseLiteral(node.right);
197
+ if (!parsed) {
198
+ return;
199
+ }
200
+ const violation = checkConstraint(parsed.value, constraint);
201
+ if (!violation) {
202
+ return;
203
+ }
204
+ pending.push({ rhs: node.right, propName: propertyName, violation, constraint });
205
+ },
206
+
207
+ 'Program:exit'() {
208
+ for (const { rhs, propName, violation, constraint } of pending) {
209
+ const suggest = buildSuggestions(constraint).map(({ code, label }) =>
210
+ label
211
+ ? {
212
+ messageId: 'replaceWithLabel',
213
+ data: { value: code, label },
214
+ fix: (fixer) => fixer.replaceText(rhs, code),
215
+ }
216
+ : {
217
+ messageId: 'replaceWith',
218
+ data: { value: code },
219
+ fix: (fixer) => fixer.replaceText(rhs, code),
220
+ },
221
+ );
222
+ context.report({
223
+ node: rhs,
224
+ messageId: 'invalidValue',
225
+ data: { prop: propName, violation },
226
+ suggest,
227
+ });
228
+ }
229
+ },
230
+ };
231
+ },
232
+ };
@@ -12,9 +12,15 @@
12
12
  * - ContentAreaObj.Retrieve(...) — static method
13
13
  * - <contentAreaObjVar>.Update(...) — instance method on a tracked ContentAreaObj variable
14
14
  * - <contentAreaObjVar>.Remove() — instance method on a tracked ContentAreaObj variable
15
+ * - ErrorUtil.ThrowWSProxyError(...) — deprecated; only exists under Platform.Load("Core", "1")
15
16
  */
16
17
 
17
- import { platformFunctionLookup, SSJS_GLOBALS, CONTENT_AREA_OBJ_METHODS } from 'ssjs-data';
18
+ import {
19
+ platformFunctionLookup,
20
+ SSJS_GLOBALS,
21
+ CONTENT_AREA_OBJ_METHODS,
22
+ ERROR_UTIL_METHODS,
23
+ } from 'ssjs-data';
18
24
 
19
25
  // Lookup Map: lowercase name → entry, for SSJS_GLOBALS entries that are deprecated.
20
26
  // Used to flag bare calls like ContentArea(...) and ContentAreaByName(...).
@@ -38,6 +44,12 @@ const CONTENT_AREA_INSTANCE_DEPRECATED = new Set(
38
44
  ),
39
45
  );
40
46
 
47
+ // Deprecated ErrorUtil methods (e.g. ThrowWSProxyError). Used to flag calls like
48
+ // ErrorUtil.ThrowWSProxyError(result), which only exists under Platform.Load("Core", "1").
49
+ const ERRORUTIL_DEPRECATED = new Set(
50
+ ERROR_UTIL_METHODS.filter((m) => m.deprecated).map((m) => m.name.toLowerCase()),
51
+ );
52
+
41
53
  export default {
42
54
  meta: {
43
55
  type: 'suggestion',
@@ -52,6 +64,8 @@ export default {
52
64
  "'ContentAreaObj.{{name}}' is deprecated. Content Areas are no longer supported.",
53
65
  deprecatedCoreInstance:
54
66
  "'{{method}}' called on a ContentAreaObj variable is deprecated. Content Areas are no longer supported.",
67
+ deprecatedErrorUtil:
68
+ "'ErrorUtil.{{name}}' is deprecated — it only exists under Platform.Load(\"Core\", \"1\") and is undefined in newer Core versions. Check 'result.Status' and 'throw new Error(...)' instead.",
55
69
  },
56
70
  schema: [],
57
71
  },
@@ -143,6 +157,20 @@ export default {
143
157
  return;
144
158
  }
145
159
 
160
+ // ── ErrorUtil.ThrowWSProxyError(…) — deprecated ───────────────
161
+ if (
162
+ callee.object.type === 'Identifier' &&
163
+ callee.object.name === 'ErrorUtil' &&
164
+ ERRORUTIL_DEPRECATED.has(methodName.toLowerCase())
165
+ ) {
166
+ context.report({
167
+ node: property,
168
+ messageId: 'deprecatedErrorUtil',
169
+ data: { name: methodName },
170
+ });
171
+ return;
172
+ }
173
+
146
174
  // ── <contentAreaVar>.Update/Remove() — instance deprecated ─────
147
175
  if (
148
176
  callee.object.type === 'Identifier' &&
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Rule: ssjs-no-nonexistent-global
3
+ *
4
+ * Flags bare-name SSJS globals that are officially documented but proven NOT to
5
+ * exist at runtime — calling them throws a ReferenceError. Currently covers:
6
+ *
7
+ * - Redirect(url, movedPermanently) — undefined under every Core version;
8
+ * use Platform.Response.Redirect(url, movedPermanently) instead.
9
+ *
10
+ * The offending names come from ssjs-data's `notDefinedAtRuntime` flag, so new
11
+ * phantom globals are picked up automatically without editing this rule.
12
+ */
13
+
14
+ import { notDefinedAtRuntimeGlobalLookup } from 'ssjs-data';
15
+
16
+ /**
17
+ * Extract a runtime-safe replacement suggestion for a phantom global from its
18
+ * ssjs-data entry. Prefers the `Platform.*` call named in the officialDocsNote,
19
+ * falling back to a generic hint.
20
+ *
21
+ * @param {object} entry - The ssjs-data global entry.
22
+ * @returns {string} A replacement suggestion (e.g. `Platform.Response.Redirect(...)`).
23
+ */
24
+ function replacementFor(entry) {
25
+ const source = `${entry.officialDocsNote ?? ''} ${entry.description ?? ''}`;
26
+ const match = source.match(/Platform\.[A-Za-z.]+\([^)]*\)/);
27
+ return match ? match[0] : 'a supported alternative';
28
+ }
29
+
30
+ export default {
31
+ meta: {
32
+ type: 'problem',
33
+ docs: {
34
+ description:
35
+ 'Disallow SSJS globals that are documented but do not exist at runtime (throw ReferenceError)',
36
+ },
37
+ messages: {
38
+ nonexistentGlobal:
39
+ "'{{name}}' does not exist at runtime (calling it throws a ReferenceError). Use {{replacement}} instead.",
40
+ },
41
+ schema: [],
42
+ },
43
+
44
+ create(context) {
45
+ return {
46
+ CallExpression(node) {
47
+ const callee = node.callee;
48
+ if (callee.type !== 'Identifier') {
49
+ return;
50
+ }
51
+ const entry = notDefinedAtRuntimeGlobalLookup.get(callee.name.toLowerCase());
52
+ if (!entry) {
53
+ return;
54
+ }
55
+ context.report({
56
+ node: callee,
57
+ messageId: 'nonexistentGlobal',
58
+ data: { name: callee.name, replacement: replacementFor(entry) },
59
+ });
60
+ },
61
+ };
62
+ },
63
+ };
@@ -7,7 +7,7 @@
7
7
  * Two catalogs from ssjs-data feed this rule:
8
8
  *
9
9
  * 1. POLYFILLABLE_METHODS — a shipped ES3-safe polyfill exists. The report
10
- * carries a suggestion that inserts the polyfill at the end of the file.
10
+ * carries a suggestion that inserts the polyfill at the top of the file.
11
11
  * 2. KNOWN_UNSUPPORTED — no polyfill is feasible. The report has no fix; the
12
12
  * message carries the ssjs-data `suggestion` (e.g. use Platform.Function.X).
13
13
  *
@@ -15,8 +15,9 @@
15
15
  * - category 'broken': method exists natively but returns incorrect results.
16
16
  *
17
17
  * No auto-fix is applied because prototype assignments are not hoisted —
18
- * the suggestion inserts the polyfill at the end of the file, and users
19
- * should verify placement before the first call (or load via Content Block).
18
+ * the suggestion inserts the polyfill at the top of the file (after a leading
19
+ * `/* global *\/` directive when present), and users should verify placement
20
+ * before the first call (or load via Content Block).
20
21
  */
21
22
 
22
23
  import {
@@ -58,7 +59,7 @@ export default {
58
59
  broken:
59
60
  "'{{owner}}.{{method}}' exists in SFMC SSJS but produces incorrect results. " +
60
61
  'Add a polyfill to get the correct behavior.',
61
- addPolyfill: "Insert '{{owner}}.{{method}}' polyfill at end of file",
62
+ addPolyfill: "Insert '{{owner}}.{{method}}' polyfill at top of file",
62
63
  unavailableNoPolyfill:
63
64
  "'{{owner}}.{{method}}' is not available in SFMC SSJS (ECMAScript 3). {{suggestion}}",
64
65
  brokenNoPolyfill:
@@ -99,8 +100,21 @@ export default {
99
100
  function buildSuggestFix(entry) {
100
101
  return function fix(fixer) {
101
102
  const source = context.sourceCode;
102
- const end = source.ast.range[1];
103
- return fixer.insertTextAfterRange([end, end], '\n\n' + entry.polyfill);
103
+ // Insert the polyfill at the TOP of the file so it is defined
104
+ // before any call site (SSJS does not hoist prototype
105
+ // assignments). This mirrors the LSP quick-fix. When a leading
106
+ // `/* global ... */` ESLint directive is the first comment,
107
+ // insert right after it so the directive stays at the very top.
108
+ const leadingGlobal = source
109
+ .getAllComments()
110
+ .find(
111
+ (c) =>
112
+ c.type === 'Block' && c.range[0] === 0 && /^\s*global\b/.test(c.value),
113
+ );
114
+ if (leadingGlobal) {
115
+ return fixer.insertTextAfterRange(leadingGlobal.range, '\n\n' + entry.polyfill);
116
+ }
117
+ return fixer.insertTextBeforeRange([0, 0], entry.polyfill + '\n\n');
104
118
  };
105
119
  }
106
120