eslint-plugin-sfmc 4.1.2 → 4.3.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
@@ -130,6 +130,8 @@ export default [...sfmc.configs['recommended-next'], ...sfmc.configs['embedded-n
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
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) |
132
132
  | [`sfmc/ssjs-no-property-call`](docs/rules/ssjs/no-property-call.md) | `error` | Disallow calling Platform.Request/Response properties as functions |
133
+ | [`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
+ | [`sfmc/ssjs-require-string-clr-content`](docs/rules/ssjs/require-string-clr-content.md) | `error` | Require wrapping `HttpResponse.content` with `String()` before use |
133
135
  | [`sfmc/ssjs-platform-function-arity`](docs/rules/ssjs/platform-function-arity.md) | `error` | Enforce correct arity for `Platform.Function.*` |
134
136
  | [`sfmc/ssjs-require-platform-load-order`](docs/rules/ssjs/require-platform-load-order.md) | `error` | Require `Platform.Load()` before Core usage in order |
135
137
  | [`sfmc/ssjs-no-hardcoded-credentials`](docs/rules/ssjs/no-hardcoded-credentials.md) | `error` | Flag hardcoded keys in encryption calls |
@@ -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)
@@ -0,0 +1,91 @@
1
+ # `sfmc/ssjs-no-clr-header-access`
2
+
3
+ Flags CLR-unsafe reads of the `headers` property of an `HttpResponse` — the object
4
+ returned by `req.send()` on a `Script.Util.HttpRequest` or `Script.Util.HttpGet`.
5
+
6
+ `resp.headers` is a Common Language Runtime (CLR) object. Reading an individual
7
+ header by indexing it (`resp.headers["Content-Type"]`), or by calling `.Get()` /
8
+ `.Item()`, throws at runtime:
9
+
10
+ ```
11
+ Use of Common Language Runtime (CLR) is not allowed
12
+ ```
13
+
14
+ The only reliable way to read header values is to enumerate `resp.headers` with a
15
+ `for..in` loop. Each enumeration key is shaped `"[Name, Value]"`, so the value is
16
+ embedded in the key string. The `getHeaderMap()` helper parses those keys into a
17
+ plain `{ name: value }` map (with lowercased names).
18
+
19
+ ## Detection
20
+
21
+ The rule tracks data flow, so it only fires on a genuine response object:
22
+
23
+ 1. A **request** variable is one assigned from `new Script.Util.HttpRequest(...)`
24
+ or `Script.Util.HttpGet(...)`.
25
+ 2. A **response** variable is one assigned from `<requestVar>.send()`.
26
+ 3. CLR-style reads of `<responseVar>.headers` are then flagged.
27
+
28
+ `.headers` access on any other object is ignored, so there are no false positives
29
+ on unrelated `headers` properties.
30
+
31
+ ## Examples
32
+
33
+ ### ❌ Incorrect — CLR-style reads throw at runtime
34
+
35
+ ```js
36
+ var req = new Script.Util.HttpRequest("https://api.example.com/data");
37
+ var resp = req.send();
38
+
39
+ var ct = resp.headers["Content-Type"]; // ← throws (CLR not allowed)
40
+ var loc = resp.headers.Get("Location"); // ← throws (CLR not allowed)
41
+ var enc = resp.headers.Item("Content-Encoding"); // ← throws (CLR not allowed)
42
+ ```
43
+
44
+ ### ✅ Correct — read via a `getHeaderMap()` helper
45
+
46
+ ```js
47
+ /**
48
+ * Build a plain { name: value } header map from an HttpResponse.
49
+ * Reads only the for..in enumeration keys (shaped "[Name, Value]") so it never
50
+ * touches a CLR value — avoiding "Use of Common Language Runtime (CLR) is not allowed".
51
+ * @param {object} resp - the response returned by req.send()
52
+ * @returns {object} map of lowercased header name => value string
53
+ */
54
+ function getHeaderMap(resp) {
55
+ var map = {};
56
+ for (var k in resp.headers) {
57
+ var pair = String(k);
58
+ if (pair.charAt(0) === "[") { pair = pair.substring(1); }
59
+ if (pair.charAt(pair.length - 1) === "]") { pair = pair.substring(0, pair.length - 1); }
60
+ var idx = pair.indexOf(", ");
61
+ if (idx > -1) {
62
+ map[pair.substring(0, idx).toLowerCase()] = pair.substring(idx + 2);
63
+ }
64
+ }
65
+ return map;
66
+ }
67
+
68
+ var req = new Script.Util.HttpRequest("https://api.example.com/data");
69
+ var resp = req.send();
70
+
71
+ var headers = getHeaderMap(resp);
72
+ var ct = headers["content-type"]; // names are lowercased
73
+ ```
74
+
75
+ ## Suggestion (quick-fix)
76
+
77
+ The rule provides a suggestion that:
78
+
79
+ 1. Inserts the `getHeaderMap()` helper once, at the top of the file (skipped if a
80
+ `getHeaderMap(` function already exists).
81
+ 2. Rewrites the flagged access to `getHeaderMap(<resp>)[<key>]`.
82
+
83
+ The same diagnostic and quick-fix are available in the VS Code / Cursor extension
84
+ (`ssjs/clr-header-access`).
85
+
86
+ ## Rule details
87
+
88
+ - **Type:** `problem`
89
+ - **Fixable:** No (offers a suggestion)
90
+ - **Recommended:** Yes (`error`)
91
+ - **Strict:** Yes (`error`)
@@ -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
 
@@ -0,0 +1,69 @@
1
+ # `sfmc/ssjs-require-string-clr-content`
2
+
3
+ Requires wrapping the `content` property of an `HttpResponse` with `String()` before
4
+ use — the object returned by `req.send()` on a `Script.Util.HttpRequest` or
5
+ `Script.Util.HttpGet`.
6
+
7
+ `resp.content` is a Common Language Runtime (CLR) string, **not** a native JavaScript
8
+ string. Passing it directly to `Platform.Function.ParseJSON()`, calling a string
9
+ method on it, concatenating it, or assigning it to a variable used as a string is
10
+ unreliable in the SFMC SSJS (ES3/CLR) engine. The verified fix is to convert it with
11
+ `String(resp.content)` first:
12
+
13
+ ```js
14
+ var responseJSON = Platform.Function.ParseJSON(String(resp.content));
15
+ ```
16
+
17
+ ## Detection
18
+
19
+ The rule tracks data flow, so it only fires on a genuine response object:
20
+
21
+ 1. A **request** variable is one assigned from `new Script.Util.HttpRequest(...)`
22
+ or `Script.Util.HttpGet(...)`.
23
+ 2. A **response** variable is one assigned from `<requestVar>.send()`.
24
+ 3. Any read of `<responseVar>.content` that is **not** already the direct argument
25
+ of a `String(...)` call is flagged.
26
+
27
+ `.content` access on any other object is ignored, so there are no false positives on
28
+ unrelated `content` properties.
29
+
30
+ ## Examples
31
+
32
+ ### ❌ Incorrect — raw CLR content used directly
33
+
34
+ ```js
35
+ var req = new Script.Util.HttpRequest("https://api.example.com/data");
36
+ var resp = req.send();
37
+
38
+ var parsed = Platform.Function.ParseJSON(resp.content); // ← unreliable
39
+ var body = resp.content; // ← unreliable
40
+ var msg = "Body: " + resp.content; // ← unreliable
41
+ var head = resp.content.substring(0, 20); // ← unreliable
42
+ ```
43
+
44
+ ### ✅ Correct — wrap with `String()` first
45
+
46
+ ```js
47
+ var req = new Script.Util.HttpRequest("https://api.example.com/data");
48
+ var resp = req.send();
49
+
50
+ var parsed = Platform.Function.ParseJSON(String(resp.content));
51
+ var body = String(resp.content);
52
+ var head = String(resp.content).substring(0, 20);
53
+ ```
54
+
55
+ ## Fix (auto-fix)
56
+
57
+ The rule is auto-fixable: it wraps the flagged `<resp>.content` access in
58
+ `String(...)`. For example `ParseJSON(resp.content)` becomes
59
+ `ParseJSON(String(resp.content))`.
60
+
61
+ The same diagnostic and quick-fix are available in the VS Code / Cursor extension
62
+ (`ssjs/clr-content-access`).
63
+
64
+ ## Rule details
65
+
66
+ - **Type:** `problem`
67
+ - **Fixable:** Yes (`code`)
68
+ - **Recommended:** Yes (`error`)
69
+ - **Strict:** Yes (`error`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-sfmc",
3
- "version": "4.1.2",
3
+ "version": "4.3.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.14.0"
45
+ "ssjs-data": "^0.18.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "eslint": ">=9.0.0"
package/src/index.js CHANGED
@@ -56,6 +56,9 @@ import ssjsNoSwitchDefault from './rules/ssjs/no-switch-default.js';
56
56
  import ssjsNoTreatAsContentInjection from './rules/ssjs/no-treatascontent-injection.js';
57
57
  import ssjsArgumentTypes from './rules/ssjs/ssjs-argument-types.js';
58
58
  import ssjsCoreMethodArity from './rules/ssjs/ssjs-core-method-arity.js';
59
+ import ssjsNoClrHeaderAccess from './rules/ssjs/no-clr-header-access.js';
60
+ import ssjsRequireStringClrContent from './rules/ssjs/require-string-clr-content.js';
61
+ import ssjsHttpPropertyValue from './rules/ssjs/http-property-value.js';
59
62
 
60
63
  // ── Handlebars (MCN) rules ──────────────────────────────────────────────────────
61
64
 
@@ -135,6 +138,9 @@ const plugin = {
135
138
  'ssjs-no-treatascontent-injection': ssjsNoTreatAsContentInjection,
136
139
  'ssjs-arg-types': ssjsArgumentTypes,
137
140
  'ssjs-core-method-arity': ssjsCoreMethodArity,
141
+ 'ssjs-no-clr-header-access': ssjsNoClrHeaderAccess,
142
+ 'ssjs-require-string-clr-content': ssjsRequireStringClrContent,
143
+ 'ssjs-http-property-value': ssjsHttpPropertyValue,
138
144
 
139
145
  // Handlebars (MCN) rules (hbs- prefix)
140
146
  'hbs-no-unknown-helper': hbsNoUnknownHelper,
@@ -178,6 +184,9 @@ const ssjsMcnRules = {
178
184
  'sfmc/ssjs-no-treatascontent-injection': 'off',
179
185
  'sfmc/ssjs-arg-types': 'off',
180
186
  'sfmc/ssjs-core-method-arity': 'off',
187
+ 'sfmc/ssjs-no-clr-header-access': 'off',
188
+ 'sfmc/ssjs-require-string-clr-content': 'off',
189
+ 'sfmc/ssjs-http-property-value': 'off',
181
190
  'no-cond-assign': 'off',
182
191
  };
183
192
 
@@ -246,6 +255,9 @@ const ssjsRecommendedRules = {
246
255
  'sfmc/ssjs-no-treatascontent-injection': 'warn',
247
256
  'sfmc/ssjs-arg-types': 'warn',
248
257
  'sfmc/ssjs-core-method-arity': 'warn',
258
+ 'sfmc/ssjs-no-clr-header-access': 'error',
259
+ 'sfmc/ssjs-require-string-clr-content': 'error',
260
+ 'sfmc/ssjs-http-property-value': 'error',
249
261
  'no-cond-assign': 'error',
250
262
  };
251
263
 
@@ -267,6 +279,9 @@ const ssjsStrictRules = {
267
279
  'sfmc/ssjs-no-treatascontent-injection': 'error',
268
280
  'sfmc/ssjs-arg-types': 'warn',
269
281
  'sfmc/ssjs-core-method-arity': 'error',
282
+ 'sfmc/ssjs-no-clr-header-access': 'error',
283
+ 'sfmc/ssjs-require-string-clr-content': 'error',
284
+ 'sfmc/ssjs-http-property-value': 'error',
270
285
  'no-cond-assign': 'error',
271
286
  };
272
287
 
@@ -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
+ };
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Rule: ssjs-no-clr-header-access
3
+ *
4
+ * The `headers` property of an HttpResponse (returned by `req.send()` on a
5
+ * `Script.Util.HttpRequest` or `Script.Util.HttpGet`) is a CLR object. Reading
6
+ * an individual header via indexing (`resp.headers["Content-Type"]`), a `.Get()`
7
+ * / `.Item()` call, or `String(resp.headers[key])` throws at runtime:
8
+ *
9
+ * "Use of Common Language Runtime (CLR) is not allowed"
10
+ *
11
+ * The only reliable way to read header values is to enumerate `resp.headers`
12
+ * with a `for..in` loop — each key is shaped `"[Name, Value]"`, so the value is
13
+ * embedded in the key string. The `getHeaderMap()` helper parses those keys into
14
+ * a plain `{ name: value }` map.
15
+ *
16
+ * This rule tracks variables assigned from `req.send()` (where `req` came from a
17
+ * `Script.Util.HttpRequest`/`HttpGet`) and flags CLR-style reads of their
18
+ * `.headers`. It offers a suggestion that inserts the `getHeaderMap()` helper
19
+ * and rewrites the access to `getHeaderMap(resp)["name"]`.
20
+ */
21
+
22
+ /** Canonical helper inserted by the quick-fix, mirrored from ssjs.guide. */
23
+ const HEADER_MAP_HELPER = `/**
24
+ * Build a plain { name: value } header map from an HttpResponse.
25
+ * Reads only the for..in enumeration keys (shaped "[Name, Value]") so it never
26
+ * touches a CLR value — avoiding "Use of Common Language Runtime (CLR) is not allowed".
27
+ * @param {object} resp - the response returned by req.send()
28
+ * @returns {object} map of lowercased header name => value string
29
+ */
30
+ function getHeaderMap(resp) {
31
+ var map = {};
32
+ for (var k in resp.headers) {
33
+ var pair = String(k);
34
+ if (pair.charAt(0) === "[") { pair = pair.substring(1); }
35
+ if (pair.charAt(pair.length - 1) === "]") { pair = pair.substring(0, pair.length - 1); }
36
+ var idx = pair.indexOf(", ");
37
+ if (idx > -1) {
38
+ map[pair.substring(0, idx).toLowerCase()] = pair.substring(idx + 2);
39
+ }
40
+ }
41
+ return map;
42
+ }
43
+ `;
44
+
45
+ /**
46
+ * Returns true when the node is `Script.Util.HttpRequest` or
47
+ * `Script.Util.HttpGet` (a MemberExpression, optionally the callee of `new`).
48
+ *
49
+ * @param {import('eslint').Rule.Node} node - MemberExpression to test
50
+ * @returns {boolean} Whether it references a Script.Util HTTP constructor
51
+ */
52
+ function isHttpConstructorMember(node) {
53
+ return (
54
+ node.type === 'MemberExpression' &&
55
+ node.property.type === 'Identifier' &&
56
+ (node.property.name === 'HttpRequest' || node.property.name === 'HttpGet') &&
57
+ node.object.type === 'MemberExpression' &&
58
+ node.object.property.type === 'Identifier' &&
59
+ node.object.property.name === 'Util' &&
60
+ node.object.object.type === 'Identifier' &&
61
+ node.object.object.name === 'Script'
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Returns true when the node constructs a Script.Util HTTP request, i.e.
67
+ * `new Script.Util.HttpRequest(...)` or `Script.Util.HttpGet(...)` (the latter
68
+ * is commonly called without `new`).
69
+ *
70
+ * @param {import('eslint').Rule.Node} node - init expression of a declarator
71
+ * @returns {boolean} Whether the expression yields an HTTP request instance
72
+ */
73
+ function isHttpRequestInit(node) {
74
+ if (!node) {
75
+ return false;
76
+ }
77
+ if (node.type === 'NewExpression') {
78
+ return isHttpConstructorMember(node.callee);
79
+ }
80
+ if (node.type === 'CallExpression') {
81
+ return isHttpConstructorMember(node.callee);
82
+ }
83
+ return false;
84
+ }
85
+
86
+ export default {
87
+ meta: {
88
+ type: 'problem',
89
+ hasSuggestions: true,
90
+ docs: {
91
+ description:
92
+ 'Disallow CLR-unsafe reads of HttpResponse.headers (indexing, .Get(), .Item(), String()) — read headers via a for..in map instead',
93
+ },
94
+ messages: {
95
+ clrHeaderAccess:
96
+ 'Reading `{{ text }}` throws "Use of Common Language Runtime (CLR) is not allowed" at runtime. ' +
97
+ 'HttpResponse headers are only readable by enumerating with for..in — use a getHeaderMap() helper.',
98
+ insertHelperAndRewrite:
99
+ 'Insert getHeaderMap() helper and read via getHeaderMap({{ respName }})[…]',
100
+ },
101
+ schema: [],
102
+ },
103
+
104
+ create(context) {
105
+ const sourceCode = context.sourceCode;
106
+
107
+ // Variable names that hold an HttpRequest/HttpGet instance.
108
+ const requestVariables = new Set();
109
+ // Variable names that hold the response of `<requestVar>.send()`.
110
+ const responseVariables = new Set();
111
+ // Deferred reports so name collection completes before we decide.
112
+ const pending = [];
113
+
114
+ /**
115
+ * Returns true when `node` is a `<requestVar>.send()` call.
116
+ *
117
+ * @param {import('eslint').Rule.Node} node - init expression
118
+ * @returns {boolean} Whether it is a tracked request's send() call
119
+ */
120
+ function isTrackedSendCall(node) {
121
+ return Boolean(
122
+ node &&
123
+ node.type === 'CallExpression' &&
124
+ node.callee.type === 'MemberExpression' &&
125
+ node.callee.property.type === 'Identifier' &&
126
+ node.callee.property.name === 'send' &&
127
+ node.callee.object.type === 'Identifier' &&
128
+ requestVariables.has(node.callee.object.name),
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Returns the response variable name for a `<obj>.headers` member
134
+ * expression when `<obj>` is a tracked response variable, else null.
135
+ *
136
+ * @param {import('eslint').Rule.Node} headersMember - the `.headers` MemberExpression
137
+ * @returns {string|null} Response variable name or null
138
+ */
139
+ function trackedResponseName(headersMember) {
140
+ if (
141
+ headersMember.type === 'MemberExpression' &&
142
+ headersMember.property.type === 'Identifier' &&
143
+ headersMember.property.name === 'headers' &&
144
+ headersMember.object.type === 'Identifier' &&
145
+ responseVariables.has(headersMember.object.name)
146
+ ) {
147
+ return headersMember.object.name;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * Builds the suggestion descriptor: insert the helper at the top of the
154
+ * program (once) and rewrite the flagged access to use getHeaderMap().
155
+ *
156
+ * @param {import('eslint').Rule.Node} reportNode - node covered by the diagnostic
157
+ * @param {import('eslint').Rule.Node} keyNode - the header-key expression
158
+ * @param {string} respName - tracked response variable name
159
+ * @returns {object} ESLint suggestion object
160
+ */
161
+ function buildSuggestion(reportNode, keyNode, respName) {
162
+ const keyText = keyNode ? sourceCode.getText(keyNode) : '';
163
+ return {
164
+ messageId: 'insertHelperAndRewrite',
165
+ data: { respName },
166
+ fix(fixer) {
167
+ const fixes = [
168
+ fixer.replaceText(reportNode, `getHeaderMap(${respName})[${keyText}]`),
169
+ ];
170
+ // Insert the helper once, at the very top of the program.
171
+ if (!sourceCode.getText().includes('function getHeaderMap(')) {
172
+ const program = sourceCode.ast;
173
+ fixes.push(fixer.insertTextBefore(program, `${HEADER_MAP_HELPER}\n`));
174
+ }
175
+ return fixes;
176
+ },
177
+ };
178
+ }
179
+
180
+ return {
181
+ VariableDeclarator(node) {
182
+ if (node.id.type !== 'Identifier') {
183
+ return;
184
+ }
185
+ if (isHttpRequestInit(node.init)) {
186
+ requestVariables.add(node.id.name);
187
+ } else if (isTrackedSendCall(node.init)) {
188
+ responseVariables.add(node.id.name);
189
+ }
190
+ },
191
+
192
+ // resp.headers["Content-Type"] — computed index read.
193
+ 'MemberExpression[computed=true]'(node) {
194
+ const respName = trackedResponseName(node.object);
195
+ if (!respName) {
196
+ return;
197
+ }
198
+ pending.push({
199
+ reportNode: node,
200
+ keyNode: node.property,
201
+ respName,
202
+ });
203
+ },
204
+
205
+ // resp.headers.Get(...) / resp.headers.Item(...) — CLR method call.
206
+ 'CallExpression > MemberExpression.callee'(node) {
207
+ if (
208
+ node.property.type !== 'Identifier' ||
209
+ (node.property.name !== 'Get' && node.property.name !== 'Item')
210
+ ) {
211
+ return;
212
+ }
213
+ const respName = trackedResponseName(node.object);
214
+ if (!respName) {
215
+ return;
216
+ }
217
+ const call = node.parent;
218
+ pending.push({
219
+ reportNode: call,
220
+ keyNode: call.arguments.length > 0 ? call.arguments[0] : null,
221
+ respName,
222
+ });
223
+ },
224
+
225
+ 'Program:exit'() {
226
+ for (const { reportNode, keyNode, respName } of pending) {
227
+ context.report({
228
+ node: reportNode,
229
+ messageId: 'clrHeaderAccess',
230
+ data: { text: sourceCode.getText(reportNode) },
231
+ suggest: [buildSuggestion(reportNode, keyNode, respName)],
232
+ });
233
+ }
234
+ },
235
+ };
236
+ },
237
+ };
@@ -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
 
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Rule: ssjs-require-string-clr-content
3
+ *
4
+ * The `content` property of an HttpResponse (returned by `req.send()` on a
5
+ * `Script.Util.HttpRequest` or `Script.Util.HttpGet`) is a CLR string object,
6
+ * not a native JavaScript string. Using it directly — passing it to
7
+ * `Platform.Function.ParseJSON()`, calling a string method on it, concatenating
8
+ * it, or assigning it to a variable used as a string — is unreliable in the
9
+ * SFMC SSJS (ES3/CLR) engine.
10
+ *
11
+ * The verified fix is to wrap the value with `String(resp.content)` before any
12
+ * further use, e.g. `Platform.Function.ParseJSON(String(resp.content))`.
13
+ *
14
+ * This rule tracks variables assigned from `req.send()` (where `req` came from a
15
+ * `Script.Util.HttpRequest`/`HttpGet`) and flags any read of their `.content`
16
+ * that is not already the direct argument of a `String(...)` call. The quick-fix
17
+ * wraps the access in `String(...)`.
18
+ */
19
+
20
+ /**
21
+ * Returns true when the node is `Script.Util.HttpRequest` or
22
+ * `Script.Util.HttpGet` (a MemberExpression, optionally the callee of `new`).
23
+ *
24
+ * @param {import('eslint').Rule.Node} node - MemberExpression to test
25
+ * @returns {boolean} Whether it references a Script.Util HTTP constructor
26
+ */
27
+ function isHttpConstructorMember(node) {
28
+ return (
29
+ node.type === 'MemberExpression' &&
30
+ node.property.type === 'Identifier' &&
31
+ (node.property.name === 'HttpRequest' || node.property.name === 'HttpGet') &&
32
+ node.object.type === 'MemberExpression' &&
33
+ node.object.property.type === 'Identifier' &&
34
+ node.object.property.name === 'Util' &&
35
+ node.object.object.type === 'Identifier' &&
36
+ node.object.object.name === 'Script'
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Returns true when the node constructs a Script.Util HTTP request, i.e.
42
+ * `new Script.Util.HttpRequest(...)` or `Script.Util.HttpGet(...)` (the latter
43
+ * is commonly called without `new`).
44
+ *
45
+ * @param {import('eslint').Rule.Node} node - init expression of a declarator
46
+ * @returns {boolean} Whether the expression yields an HTTP request instance
47
+ */
48
+ function isHttpRequestInit(node) {
49
+ if (!node) {
50
+ return false;
51
+ }
52
+ if (node.type === 'NewExpression') {
53
+ return isHttpConstructorMember(node.callee);
54
+ }
55
+ if (node.type === 'CallExpression') {
56
+ return isHttpConstructorMember(node.callee);
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * Returns true when the `.content` member is already the direct single argument
63
+ * of a `String(...)` call — the verified-safe pattern.
64
+ *
65
+ * @param {import('eslint').Rule.Node} node - the `.content` MemberExpression
66
+ * @returns {boolean} Whether it is wrapped by String(...)
67
+ */
68
+ function isWrappedInString(node) {
69
+ const parent = node.parent;
70
+ return Boolean(
71
+ parent &&
72
+ parent.type === 'CallExpression' &&
73
+ parent.callee.type === 'Identifier' &&
74
+ parent.callee.name === 'String' &&
75
+ parent.arguments.length === 1 &&
76
+ parent.arguments[0] === node,
77
+ );
78
+ }
79
+
80
+ export default {
81
+ meta: {
82
+ type: 'problem',
83
+ fixable: 'code',
84
+ docs: {
85
+ description:
86
+ 'Require wrapping HttpResponse.content with String() before use — the raw CLR string is unreliable in the SSJS engine',
87
+ },
88
+ messages: {
89
+ clrContentAccess:
90
+ 'Reading `{{ text }}` directly is unreliable — `content` is a CLR string, not a JavaScript string. ' +
91
+ 'Wrap it with `String({{ text }})` before passing it to ParseJSON() or any string operation.',
92
+ },
93
+ schema: [],
94
+ },
95
+
96
+ create(context) {
97
+ const sourceCode = context.sourceCode;
98
+
99
+ // Variable names that hold an HttpRequest/HttpGet instance.
100
+ const requestVariables = new Set();
101
+ // Variable names that hold the response of `<requestVar>.send()`.
102
+ const responseVariables = new Set();
103
+ // Deferred reports so name collection completes before we decide.
104
+ const pending = [];
105
+
106
+ /**
107
+ * Returns true when `node` is a `<requestVar>.send()` call.
108
+ *
109
+ * @param {import('eslint').Rule.Node} node - init expression
110
+ * @returns {boolean} Whether it is a tracked request's send() call
111
+ */
112
+ function isTrackedSendCall(node) {
113
+ return Boolean(
114
+ node &&
115
+ node.type === 'CallExpression' &&
116
+ node.callee.type === 'MemberExpression' &&
117
+ node.callee.property.type === 'Identifier' &&
118
+ node.callee.property.name === 'send' &&
119
+ node.callee.object.type === 'Identifier' &&
120
+ requestVariables.has(node.callee.object.name),
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Returns true when `node` is a `<responseVar>.content` member read on a
126
+ * tracked response variable.
127
+ *
128
+ * @param {import('eslint').Rule.Node} node - MemberExpression to test
129
+ * @returns {boolean} Whether it reads .content on a tracked response var
130
+ */
131
+ function isTrackedContentMember(node) {
132
+ return (
133
+ node.type === 'MemberExpression' &&
134
+ !node.computed &&
135
+ node.property.type === 'Identifier' &&
136
+ node.property.name === 'content' &&
137
+ node.object.type === 'Identifier' &&
138
+ responseVariables.has(node.object.name)
139
+ );
140
+ }
141
+
142
+ return {
143
+ VariableDeclarator(node) {
144
+ if (node.id.type !== 'Identifier') {
145
+ return;
146
+ }
147
+ if (isHttpRequestInit(node.init)) {
148
+ requestVariables.add(node.id.name);
149
+ } else if (isTrackedSendCall(node.init)) {
150
+ responseVariables.add(node.id.name);
151
+ }
152
+ },
153
+
154
+ // resp.content — non-computed member read on a tracked response var.
155
+ 'MemberExpression[computed=false]'(node) {
156
+ if (!isTrackedContentMember(node)) {
157
+ return;
158
+ }
159
+ if (isWrappedInString(node)) {
160
+ return;
161
+ }
162
+ pending.push(node);
163
+ },
164
+
165
+ 'Program:exit'() {
166
+ for (const node of pending) {
167
+ const text = sourceCode.getText(node);
168
+ context.report({
169
+ node,
170
+ messageId: 'clrContentAccess',
171
+ data: { text },
172
+ fix(fixer) {
173
+ return fixer.replaceText(node, `String(${text})`);
174
+ },
175
+ });
176
+ }
177
+ },
178
+ };
179
+ },
180
+ };