eslint-plugin-sfmc 2.6.1 → 4.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 (49) hide show
  1. package/README.md +77 -65
  2. package/docs/rules/amp/no-mcn-unsupported.md +76 -0
  3. package/docs/rules/amp/no-unknown-function.md +2 -39
  4. package/docs/rules/hbs/helper-arity.md +55 -0
  5. package/docs/rules/hbs/no-mcn-unsupported.md +69 -0
  6. package/docs/rules/hbs/no-unknown-binding.md +49 -0
  7. package/docs/rules/hbs/no-unknown-helper.md +60 -0
  8. package/docs/rules/hbs/no-unsupported-construct.md +65 -0
  9. package/docs/rules/ssjs/no-mcn-unsupported.md +68 -0
  10. package/docs/rules/ssjs/no-unknown-function.md +4 -31
  11. package/package.json +14 -12
  12. package/src/ampscript-parser.js +1 -1
  13. package/src/ampscript-processor.js +22 -16
  14. package/src/handlebars-parser.js +195 -0
  15. package/src/index.js +108 -23
  16. package/src/processor.js +76 -16
  17. package/src/rules/amp/{arg-types.js → argument-types.js} +16 -12
  18. package/src/rules/amp/function-arity.js +9 -9
  19. package/src/rules/amp/no-inline-statement.js +5 -5
  20. package/src/rules/amp/no-loop-counter-assign.js +7 -7
  21. package/src/rules/amp/no-mcn-unsupported.js +102 -0
  22. package/src/rules/amp/no-nested-ampscript-delimiter.js +58 -66
  23. package/src/rules/amp/no-smart-quotes.js +41 -47
  24. package/src/rules/amp/no-unknown-function.js +2 -28
  25. package/src/rules/amp/prefer-attribute-value.js +7 -10
  26. package/src/rules/amp/require-rowcount-check.js +12 -15
  27. package/src/rules/amp/require-variable-declaration.js +3 -3
  28. package/src/rules/hbs/_shared.js +116 -0
  29. package/src/rules/hbs/helper-arity.js +97 -0
  30. package/src/rules/hbs/no-mcn-unsupported.js +164 -0
  31. package/src/rules/hbs/no-unknown-binding.js +74 -0
  32. package/src/rules/hbs/no-unknown-helper.js +79 -0
  33. package/src/rules/hbs/no-unsupported-construct.js +70 -0
  34. package/src/rules/ssjs/cache-loop-length.js +14 -17
  35. package/src/rules/ssjs/no-deprecated-function.js +8 -8
  36. package/src/rules/ssjs/no-hardcoded-credentials.js +3 -6
  37. package/src/rules/ssjs/no-mcn-unsupported.js +182 -0
  38. package/src/rules/ssjs/no-property-call.js +6 -9
  39. package/src/rules/ssjs/no-treatascontent-injection.js +6 -13
  40. package/src/rules/ssjs/no-unavailable-method.js +22 -22
  41. package/src/rules/ssjs/no-unknown-function.js +15 -59
  42. package/src/rules/ssjs/no-unsupported-syntax.js +5 -5
  43. package/src/rules/ssjs/{prefer-parsejson-safe-arg.js → prefer-parsejson-safe-argument.js} +13 -21
  44. package/src/rules/ssjs/require-hasownproperty.js +54 -42
  45. package/src/rules/ssjs/require-platform-load-order.js +1 -1
  46. package/src/rules/ssjs/require-platform-load.js +6 -6
  47. package/src/rules/ssjs/{ssjs-arg-types.js → ssjs-argument-types.js} +59 -46
  48. package/src/rules/ssjs/ssjs-core-method-arity.js +41 -15
  49. /package/src/rules/amp/{no-var-redeclaration.js → no-variable-redeclaration.js} +0 -0
@@ -0,0 +1,68 @@
1
+ # `sfmc/ssjs-no-mcn-unsupported`
2
+
3
+ > Disallow all Server-Side JavaScript API usage, which is unavailable in Marketing Cloud Next.
4
+
5
+ | | |
6
+ |---|---|
7
+ | **Type** | `problem` |
8
+ | **Default severity** | `error` in all `-next` configs; `off` otherwise |
9
+ | **Fixable** | — |
10
+
11
+ ## Why This Rule Exists
12
+
13
+ Server-Side JavaScript is not available in Marketing Cloud Next (MCN) at all. When targeting MCN, every SSJS API usage must be rewritten in AMPscript or Handlebars. This rule flags all SSJS API surface usage:
14
+
15
+ | Construct | Example |
16
+ |---|---|
17
+ | Any `Platform.*` call (incl. `Platform.Load`) | `Platform.Function.LookupRows(…)` |
18
+ | Any `HTTP.*` call | `HTTP.Get(…)` |
19
+ | Core Library instance calls | `de.Rows.Retrieve()` (tracked from `DataExtension.Init(…)`) |
20
+ | WSProxy instance calls | `api.retrieve(…)` |
21
+ | WSProxy construction | `new Script.Util.WSProxy()` |
22
+
23
+ Unlike [`sfmc/ssjs-no-unknown-function`](no-unknown-function.md), which only flags method names not in the catalog, this rule flags **every** SFMC API call — the whole SSJS block must be migrated.
24
+
25
+ ## Settings
26
+
27
+ | Setting | Values | Default |
28
+ |---------|--------|---------|
29
+ | severity | `"error"` \| `"warn"` \| `"off"` | `"error"` in `-next` configs |
30
+
31
+ ### Options
32
+
33
+ | Option | Type | Default | Description |
34
+ |--------|------|---------|-------------|
35
+ | `apiVersion` | `number` | — | Accepted for parity with the AMPscript and Handlebars MCN rules; currently has **no effect** because no MCN API version supports SSJS. |
36
+
37
+ ## Examples
38
+
39
+ ```js
40
+ // eslint.config.js
41
+ rules: {
42
+ 'sfmc/ssjs-no-mcn-unsupported': 'error'
43
+ }
44
+ ```
45
+
46
+ Or use the built-in `recommended-next` / `strict-next` / `embedded-next` configs, which apply this rule automatically (and disable the other SSJS quality rules, since the entire block must be migrated).
47
+
48
+ **Flagged (every API call):**
49
+
50
+ ```js
51
+ Platform.Load('Core', '1.1.1');
52
+ Platform.Function.LookupRows('MyDE', 'Status', 'Active');
53
+
54
+ var de = DataExtension.Init('CustomerData');
55
+ de.Rows.Retrieve();
56
+
57
+ var api = new Script.Util.WSProxy();
58
+ api.retrieve({ ObjectType: 'DataExtension' }, ['CustomerKey'], {});
59
+ ```
60
+
61
+ ## When to Disable
62
+
63
+ Disable this rule when you are not targeting Marketing Cloud Next (the default `engagement` configs already leave it `off`).
64
+
65
+ ```js
66
+ // eslint.config.js
67
+ rules: { 'sfmc/ssjs-no-mcn-unsupported': 'off' }
68
+ ```
@@ -2,7 +2,7 @@
2
2
 
3
3
  Flags calls to SFMC API methods that do not exist in the catalog. This single rule replaces the seven narrow `no-unknown-*` rules from earlier versions of the plugin.
4
4
 
5
- When targeting **Marketing Cloud Next**, SSJS is not supported at all. Set `target: 'next'` to flag every SSJS API call as an error, signalling that the SSJS block must be removed or rewritten in AMPscript.
5
+ This rule only reports **unknown** method names. When targeting Marketing Cloud Next, SSJS is not supported at all every SSJS API call is flagged by the dedicated [`sfmc/ssjs-no-mcn-unsupported`](no-mcn-unsupported.md) rule instead.
6
6
 
7
7
  ## Covered namespaces
8
8
 
@@ -19,14 +19,10 @@ When targeting **Marketing Cloud Next**, SSJS is not supported at all. Set `targ
19
19
 
20
20
  ## Options
21
21
 
22
- | Option | Type | Default | Description |
23
- |--------|------|---------|-------------|
24
- | `target` | `'engagement'` \| `'next'` | — | Target platform. Set to `'next'` to flag every SSJS API call (all of `Platform.*`, `HTTP.*`, Core Library, and WSProxy) as unsupported in Marketing Cloud Next. |
22
+ This rule takes no options.
25
23
 
26
24
  ## Examples
27
25
 
28
- ### Standard usage (Marketing Cloud Engagement)
29
-
30
26
  #### ❌ Incorrect
31
27
 
32
28
  ```js
@@ -54,32 +50,9 @@ var de = DataExtension.Init('CustomerData');
54
50
  de.Rows.Retrieve();
55
51
  ```
56
52
 
57
- ### MCN target (`target: 'next'`)
58
-
59
- SSJS is not supported in Marketing Cloud Next. Configuring `target: 'next'` flags every SFMC API call within SSJS files, indicating they must be rewritten or removed.
60
-
61
- ```js
62
- // eslint.config.js
63
- rules: {
64
- 'sfmc/ssjs-no-unknown-function': ['error', { target: 'next' }]
65
- }
66
- ```
67
-
68
- Or use the built-in `recommended-next` / `strict-next` / `embedded-next` configs which apply this automatically (and disable all other SSJS quality rules, since the entire SSJS block must be migrated).
69
-
70
- #### ❌ Flagged (with `target: 'next'`)
71
-
72
- ```js
73
- // All SFMC API calls are flagged — SSJS is not available in MCN
74
- Platform.Function.LookupRows('MyDE', 'Status', 'Active');
75
-
76
- var de = DataExtension.Init('CustomerData');
77
- de.Rows.Retrieve();
78
- ```
79
-
80
53
  ## When to use
81
54
 
82
- Enable this rule to catch typos, outdated method names, and unsupported API calls before they cause runtime failures. Use `target: 'next'` when migrating content to Marketing Cloud Next.
55
+ Enable this rule to catch typos and outdated method names before they cause runtime failures. When migrating content to Marketing Cloud Next, use [`sfmc/ssjs-no-mcn-unsupported`](no-mcn-unsupported.md) (applied automatically by the `recommended-next` / `strict-next` / `embedded-next` configs).
83
56
 
84
57
  ## Rule details
85
58
 
@@ -87,4 +60,4 @@ Enable this rule to catch typos, outdated method names, and unsupported API call
87
60
  - **Fixable:** No
88
61
  - **Recommended:** Yes (`engagement` mode)
89
62
  - **Strict:** Yes (`engagement` mode)
90
- - **`recommended-next` / `strict-next` / `embedded-next`:** Yes (`next` mode — all other SSJS rules disabled)
63
+ - **`recommended-next` / `strict-next` / `embedded-next`:** Off (superseded by `ssjs-no-mcn-unsupported`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-sfmc",
3
- "version": "2.6.1",
3
+ "version": "4.0.0",
4
4
  "description": "ESLint plugin for Salesforce Marketing Cloud — AMPscript and Server-Side JavaScript (SSJS)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -32,9 +32,11 @@
32
32
  "sfmc"
33
33
  ],
34
34
  "dependencies": {
35
- "ampscript-data": "^2.0.3",
36
- "ampscript-parser": "^0.1.3",
37
- "ssjs-data": "^0.11.0"
35
+ "@handlebars/parser": "^2.2.2",
36
+ "ampscript-data": "^3.0.0",
37
+ "ampscript-parser": "^0.2.0",
38
+ "handlebars-data": "^0.3.0",
39
+ "ssjs-data": "^0.14.0"
38
40
  },
39
41
  "peerDependencies": {
40
42
  "eslint": ">=9.0.0"
@@ -52,19 +54,19 @@
52
54
  },
53
55
  "license": "MIT",
54
56
  "engines": {
55
- "node": ">=18.0.0"
57
+ "node": ">=22.0.0"
56
58
  },
57
59
  "devDependencies": {
58
60
  "@eslint/js": "^10.0.1",
59
- "eslint": "^10.1.0",
61
+ "eslint": "^10.6.0",
60
62
  "eslint-config-prettier": "^10.1.8",
61
- "eslint-plugin-jsdoc": "^62.0.0",
62
- "eslint-plugin-prettier": "^5.5.0",
63
- "eslint-plugin-unicorn": "^64.0.0",
64
- "globals": "^17.4.0",
63
+ "eslint-plugin-jsdoc": "^63.0.10",
64
+ "eslint-plugin-prettier": "^5.5.6",
65
+ "eslint-plugin-unicorn": "^70.0.0",
66
+ "globals": "^17.7.0",
65
67
  "husky": "^9.1.7",
66
- "lint-staged": "^17.0.5",
67
- "prettier": "^3.8.2"
68
+ "lint-staged": "^17.0.8",
69
+ "prettier": "^3.9.4"
68
70
  },
69
71
  "lint-staged": {
70
72
  "*.{js,mjs,cjs}": [
@@ -91,7 +91,7 @@ function annotateNode(node, lineStarts, comments) {
91
91
  }
92
92
 
93
93
  // Rename colliding ESTree types to Amp-prefixed equivalents
94
- if (node.type && NODE_RENAME[node.type]) {
94
+ if (node.type && Object.hasOwn(NODE_RENAME, node.type)) {
95
95
  node.type = NODE_RENAME[node.type];
96
96
  }
97
97
 
@@ -24,6 +24,22 @@ function countNewlinesBefore(text, pos) {
24
24
  return count;
25
25
  }
26
26
 
27
+ /**
28
+ * Scans forward for the closing `=%%` of an inline AMPscript expression.
29
+ *
30
+ * @param {string} text - Full document text.
31
+ * @param {number} from - Index to start scanning from (just past the opening `%%=`).
32
+ * @returns {number} Index just past the closing `=%%`, or -1 when not found.
33
+ */
34
+ function findInlineExpressionEnd(text, from) {
35
+ for (let index = from; index < text.length; index++) {
36
+ if (text[index] === '=' && text[index + 1] === '%' && text[index + 2] === '%') {
37
+ return index + 3;
38
+ }
39
+ }
40
+ return -1;
41
+ }
42
+
27
43
  export function preprocess(text, filename) {
28
44
  const blocks = [];
29
45
  let index = 0;
@@ -91,28 +107,18 @@ export function preprocess(text, filename) {
91
107
 
92
108
  // %%= ... =%%
93
109
  if (text[index] === '%' && text[index + 1] === '%' && text[index + 2] === '=') {
94
- const exprStart = index;
95
- index += 3;
96
- let found = false;
97
-
98
- while (index < text.length) {
99
- if (text[index] === '=' && text[index + 1] === '%' && text[index + 2] === '%') {
100
- index += 3;
101
- found = true;
102
- break;
103
- }
104
- index++;
105
- }
106
-
107
- if (!found) {
110
+ const expressionStart = index;
111
+ const expressionEnd = findInlineExpressionEnd(text, index + 3);
112
+ if (expressionEnd === -1) {
108
113
  continue;
109
114
  }
115
+ index = expressionEnd;
110
116
 
111
117
  // Wrap in %%[ ]%% so the AMPscript parser receives valid block syntax.
112
118
  // %%[ is 3 chars, same as %%= — column offsets for inner code are preserved.
113
- const innerCode = text.slice(exprStart + 3, index - 3);
119
+ const innerCode = text.slice(expressionStart + 3, index - 3);
114
120
  const wrappedBlock = `%%[${innerCode}]%%`;
115
- const padding = '\n'.repeat(countNewlinesBefore(text, exprStart));
121
+ const padding = '\n'.repeat(countNewlinesBefore(text, expressionStart));
116
122
 
117
123
  blocks.push({
118
124
  text: padding + wrappedBlock,
@@ -0,0 +1,195 @@
1
+ /**
2
+ * ESLint-compatible parser for Marketing Cloud Next Handlebars.
3
+ *
4
+ * Wraps the `@handlebars/parser` package and adds the metadata ESLint needs:
5
+ * - `range: [start, end]` on every node (derived from the parser's
6
+ * 1-based-line / 0-based-column `loc` via a line-start table)
7
+ * - `loc: { start: {line,column}, end: {line,column} }` (kept from the parser,
8
+ * whose convention already matches ESLint: 1-based line, 0-based column)
9
+ * - Root node typed as "Program" with `body`, `tokens`, `comments`
10
+ * - `visitorKeys` so ESLint knows how to traverse Handlebars node types
11
+ *
12
+ * AMPscript regions inside the source must be blanked out before this parser
13
+ * runs (the processor does this) so Handlebars never sees `%%[ ... ]%%` syntax.
14
+ */
15
+
16
+ import { parse as hbsParse } from '@handlebars/parser';
17
+
18
+ // ── Node renaming for ESTree collisions ───────────────────────────────────
19
+ //
20
+ // `BlockStatement` is also an ESTree node type, so ESLint's built-in scope
21
+ // analysis (eslint-scope) tries to open a lexical scope for it and crashes on
22
+ // the Handlebars shape. Likewise, a Handlebars `BlockStatement` contains nested
23
+ // `Program` nodes (its `program`/`inverse` bodies); eslint-scope treats every
24
+ // `Program` as a global scope and asserts when they nest. Rename both to
25
+ // Hbs-prefixed types so the JS analyzer leaves them alone — only the single
26
+ // synthetic root keeps the real `Program` type. The hbs rules and visitorKeys
27
+ // reference the renamed types.
28
+
29
+ const NODE_RENAME = {
30
+ BlockStatement: 'HbsBlockStatement',
31
+ Program: 'HbsProgram',
32
+ };
33
+
34
+ // ── Visitor keys for all Handlebars AST node types ────────────────────────
35
+
36
+ export const visitorKeys = {
37
+ Program: ['body'],
38
+ HbsProgram: ['body'],
39
+ MustacheStatement: ['path', 'params', 'hash'],
40
+ HbsBlockStatement: ['path', 'params', 'hash', 'program', 'inverse'],
41
+ PartialStatement: ['name', 'params', 'hash'],
42
+ PartialBlockStatement: ['name', 'params', 'hash', 'program'],
43
+ DecoratorBlock: ['path', 'params', 'hash', 'program'],
44
+ Decorator: ['path', 'params', 'hash'],
45
+ ContentStatement: [],
46
+ CommentStatement: [],
47
+ SubExpression: ['path', 'params', 'hash'],
48
+ PathExpression: [],
49
+ StringLiteral: [],
50
+ NumberLiteral: [],
51
+ BooleanLiteral: [],
52
+ UndefinedLiteral: [],
53
+ NullLiteral: [],
54
+ Hash: ['pairs'],
55
+ HashPair: ['value'],
56
+ };
57
+
58
+ // ── line/column → offset mapping ──────────────────────────────────────────
59
+
60
+ function buildLineTable(text) {
61
+ const starts = [0];
62
+ for (let index = 0; index < text.length; index++) {
63
+ if (text[index] === '\n') {
64
+ starts.push(index + 1);
65
+ } else if (text[index] === '\r') {
66
+ if (text[index + 1] === '\n') {
67
+ starts.push(index + 2);
68
+ index++;
69
+ } else {
70
+ starts.push(index + 1);
71
+ }
72
+ }
73
+ }
74
+ return starts;
75
+ }
76
+
77
+ function locToOffset(position, lineStarts, textLength) {
78
+ if (!position || typeof position.line !== 'number') {
79
+ return 0;
80
+ }
81
+ const lineIndex = Math.max(0, position.line - 1);
82
+ const base = lineIndex < lineStarts.length ? lineStarts[lineIndex] : textLength;
83
+ const offset = base + Math.max(0, position.column);
84
+ return Math.min(offset, textLength);
85
+ }
86
+
87
+ // ── AST annotation ────────────────────────────────────────────────────────
88
+
89
+ function annotateNode(node, lineStarts, textLength, fallback) {
90
+ if (!node || typeof node !== 'object') {
91
+ return;
92
+ }
93
+
94
+ // Rename colliding ESTree types so ESLint's JS scope analysis ignores them.
95
+ if (node.type && Object.hasOwn(NODE_RENAME, node.type)) {
96
+ node.type = NODE_RENAME[node.type];
97
+ }
98
+
99
+ // ESLint requires every traversed node to expose a numeric `range` and a
100
+ // matching `loc`. `@handlebars/parser` attaches `loc` to most nodes but a
101
+ // few (e.g. Hash, HashPair) can omit it — inherit the parent's span so the
102
+ // traverser never sees an undefined range.
103
+ let span = fallback;
104
+ if (node.loc && node.loc.start && node.loc.end) {
105
+ const start = locToOffset(node.loc.start, lineStarts, textLength);
106
+ const end = locToOffset(node.loc.end, lineStarts, textLength);
107
+ node.range = [start, end];
108
+ node.start = start;
109
+ node.end = end;
110
+ node.loc = {
111
+ start: { line: node.loc.start.line, column: node.loc.start.column },
112
+ end: { line: node.loc.end.line, column: node.loc.end.column },
113
+ };
114
+ span = { range: node.range, loc: node.loc };
115
+ } else if (fallback) {
116
+ node.range = [...fallback.range];
117
+ node.start = fallback.range[0];
118
+ node.end = fallback.range[1];
119
+ node.loc = {
120
+ start: { ...fallback.loc.start },
121
+ end: { ...fallback.loc.end },
122
+ };
123
+ }
124
+
125
+ const keys = visitorKeys[node.type];
126
+ if (!keys) {
127
+ return;
128
+ }
129
+
130
+ for (const key of keys) {
131
+ const child = node[key];
132
+ if (Array.isArray(child)) {
133
+ for (const item of child) {
134
+ if (item && typeof item === 'object') {
135
+ annotateNode(item, lineStarts, textLength, span);
136
+ }
137
+ }
138
+ } else if (child && typeof child === 'object') {
139
+ annotateNode(child, lineStarts, textLength, span);
140
+ }
141
+ }
142
+ }
143
+
144
+ // ── Public ESLint parser interface ────────────────────────────────────────
145
+
146
+ export function parseForESLint(code) {
147
+ const lineStarts = buildLineTable(code);
148
+
149
+ let handlebarsAst;
150
+ try {
151
+ handlebarsAst = hbsParse(code);
152
+ } catch (ex) {
153
+ const loc = ex && ex.hash && ex.hash.loc;
154
+ const rawMessage = (ex && ex.message) || 'Handlebars syntax error.';
155
+ const messageLines = rawMessage.split('\n').filter((line) => line.trim().length > 0);
156
+ const error = new SyntaxError(messageLines.at(-1) || rawMessage);
157
+ error.lineNumber = loc ? Math.max(1, loc.first_line) : 1;
158
+ error.column = loc ? Math.max(1, loc.first_column + 1) : 1;
159
+ throw error;
160
+ }
161
+
162
+ const ast = {
163
+ type: 'Program',
164
+ body: handlebarsAst.body || [],
165
+ start: 0,
166
+ end: code.length,
167
+ range: [0, code.length],
168
+ loc: {
169
+ start: { line: 1, column: 0 },
170
+ end: handlebarsAst.loc
171
+ ? { line: handlebarsAst.loc.end.line, column: handlebarsAst.loc.end.column }
172
+ : {
173
+ line: lineStarts.length,
174
+ column: code.length - lineStarts.at(-1),
175
+ },
176
+ },
177
+ tokens: [],
178
+ comments: [],
179
+ };
180
+
181
+ for (const child of ast.body) {
182
+ annotateNode(child, lineStarts, code.length);
183
+ }
184
+
185
+ return {
186
+ ast,
187
+ visitorKeys,
188
+ services: {},
189
+ scopeManager: null,
190
+ };
191
+ }
192
+
193
+ export function parse(code) {
194
+ return parseForESLint(code).ast;
195
+ }