eslint-plugin-chrome-extensions 1.0.1 → 2.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.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  <a href="#license">
8
8
  <img alt="[License: MIT]" src="https://img.shields.io/badge/License-MIT-f1f20a.svg?logo=internetarchive&logoColor=white&labelColor=464646&style=for-the-badge"></a>
9
9
  <a target="_blank" href="https://eslint.org/">
10
- <img alt="[Requires ESLint >=9.0.0]" src="https://img.shields.io/badge/Requires-ESLint_%3E%3D9.0.0-4B32C3?style=for-the-badge&logo=eslint"></a>
10
+ <img alt="[Requires ESLint >=9.6.0]" src="https://img.shields.io/badge/Requires-ESLint_%3E%3D9.6.0-4B32C3?style=for-the-badge&logo=eslint"></a>
11
11
  <br><br>
12
12
 
13
13
  ESLint rules for Chrome extensions.
@@ -31,26 +31,37 @@ npm i -D eslint-plugin-chrome-extensions
31
31
  Usage
32
32
  </h2>
33
33
 
34
- Add a bundled config to your `eslint.config.*js`:
34
+ Add bundled configs to your `eslint.config.*js`:
35
35
 
36
36
  ```js
37
37
  ...
38
38
  import chromeExtensions from 'eslint-plugin-chrome-extensions'
39
+ import json from '@eslint/json'
39
40
 
40
41
  export default [
41
42
  // other configs
42
43
  {
43
- files: ['**/*.js'], plugins: { 'chrome-extensions': chromeExtensions },
44
+ files: ['extension/**/*.js'],
45
+ plugins: { 'chrome-extensions': chromeExtensions },
44
46
  rules: {
45
47
  ...chromeExtensions.configs.recommended.rules,
46
- 'chrome-extensions/no-unused-permissions': 'off', // selectively disable rule
48
+ 'chrome-extensions/no-insecure-messaging': 'off', // selectively disable rule
47
49
  'chrome-extensions/no-deprecated-apis': ['error', { target: 'mv3' }] // selectively tweak rule
48
50
  }
51
+ },
52
+ {
53
+ files: ['extension/manifest.json'], language: 'json/json',
54
+ plugins: { 'chrome-extensions': chromeExtensions, json },
55
+ rules: {
56
+ ...chromeExtensions.configs.manifest.rules,
57
+ 'chrome-extensions/require-mv3': 'off', // selectively disable rule
58
+ 'chrome-extensions/valid-version-field': ['error', { versionType: 'date' }] // selectively tweak rule
59
+ }
49
60
  }
50
61
  ]
51
62
  ```
52
63
 
53
- Available bundles: `recommended`, `strict` (everything), `problem` (deterministic bugs only)
64
+ Available bundles: `recommended`, `strict` (everything), `problem` (deterministic bugs only), `manifest`
54
65
 
55
66
  **Note:** Rule config is either a severity string (`'off'`, `'warn'`, `'error'`) or an array `[severity, options]` when the rule accepts options.
56
67
 
@@ -61,6 +72,75 @@ Available bundles: `recommended`, `strict` (everything), `problem` (deterministi
61
72
  Supported rules
62
73
  </h2>
63
74
 
75
+ <h3 id="manifest-key-order">
76
+ <code>manifest-key-order</code>
77
+ <a title="Accepts eslint --fix" target="_blank" href="https://eslint.org/docs/latest/use/command-line-interface#--fix">
78
+ <img height=18 alt="[Auto-fixable]" src="https://img.shields.io/badge/Auto--fixable-3b5bdb?style=flat-square" align="middle"></a>
79
+ <a title="Doesn't prevent bugs" href="#manifest-key-order">
80
+ <img height=18 alt="[Stylistic]" src="https://img.shields.io/badge/Stylistic-af68ff?style=flat-square" align="middle"></a>
81
+ <a title="Accepts array w/ options" href="#usage">
82
+ <img height=18 alt="[Configurable]" src="https://img.shields.io/badge/Configurable-black?style=flat-square" align="middle"></a>
83
+ <a title="Copy rule URL" target="_blank"
84
+ href="https://copy-to-clipboard.onrender.com/copy/https%3A%2F%2Fcodeberg.org%2Fadamlui%2Feslint-plugin-chrome-extensions%2F%23manifest-key-order">
85
+ <img height=16 width=21 alt="[Copy rule URL]" src="https://api.iconify.design/mdi/content-copy.svg?color=%23cccccc" align="middle"></a>
86
+ </h3>
87
+
88
+ <details>
89
+
90
+ <summary>Enforces a preferred order for manifest keys.</summary>
91
+
92
+ #### Options
93
+
94
+ - `order` (default `[]`) – manifest keys in custom order (e.g. `['name', 'manifest_version']`) to selectively override built-in order with
95
+
96
+ #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/gray.svg" align="bottom"> Incorrect code
97
+
98
+ ```diff
99
+ /* eslint chrome-extensions/manifest-key-order: ['error', { order: ['name', 'manifest_version'] }] */
100
+
101
+ {
102
+ "manifest_version": 3,
103
+ ! "name": "My Extension",
104
+ "version": "1.0.0"
105
+ }
106
+ ```
107
+
108
+ #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
109
+
110
+ ```diff
111
+ /* eslint chrome-extensions/manifest-key-order: ['error', { order: ['name', 'manifest_version'] }] */
112
+
113
+ {
114
+ - "manifest_version": 3,
115
+ "name": "My Extension",
116
+ + "manifest_version": 3,
117
+ "version": "1.0.0"
118
+ }
119
+ ```
120
+
121
+ #### When not to use it
122
+
123
+ If you don't care about the order of manifest keys.
124
+
125
+ #### Introduced in
126
+
127
+ <a target="_blank" href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/releases/tag/v2.0.0">v2.0.0</a> (October 4, 2026)
128
+
129
+ #### See also
130
+
131
+ - [`require-mv3`](#require-mv3)
132
+ - [`valid-mv3-schema`](#valid-mv3-schema)
133
+
134
+ <div align="center">
135
+
136
+ [Back to index ↑][epc-rules]
137
+
138
+ </div>
139
+
140
+ ---
141
+
142
+ </details>
143
+
64
144
  <h3 id="no-content-style-leak">
65
145
  <code>no-content-style-leak</code>
66
146
  <a title="Only included in strict config" href="#usage">
@@ -83,13 +163,16 @@ Available bundles: `recommended`, `strict` (everything), `problem` (deterministi
83
163
  #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
84
164
 
85
165
  ```diff
166
+ + const host = document.createElement('div'),
167
+ + root = host.attachShadow({ mode: 'open' })
168
+ + document.body.append(host)
86
169
  - document.head.append(document.createElement('style'))
87
- + await chrome.scripting.insertCSS({ files: ['content.css'] })
170
+ + root.append(document.createElement('style'))
88
171
  ```
89
172
 
90
173
  #### When not to use it
91
174
 
92
- If you already scope every injected style inside a `shadowRoot` you own.
175
+ If you intentionally interact w/ host page styles.
93
176
 
94
177
  #### Introduced in
95
178
 
@@ -97,9 +180,8 @@ If you already scope every injected style inside a `shadowRoot` you own.
97
180
 
98
181
  #### See also
99
182
 
100
- - [`valid-service-worker`](#valid-service-worker)
101
- - <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/api/scripting">Chrome Extensions — `chrome.scripting`</a>
102
- - <a target="_blank" href="https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts">Chrome Extensions — Content scripts</a>
183
+ - <a target="_blank" href="https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts">Chrome for Developers — Content scripts</a>
184
+ - <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM">MDN — Using shadow DOM</a>
103
185
 
104
186
  <div align="center">
105
187
 
@@ -138,6 +220,7 @@ If you already scope every injected style inside a `shadowRoot` you own.
138
220
 
139
221
  ! chrome.pageAction.show(tabId)
140
222
  ! chrome.extension.getBackgroundPage().doThing()
223
+ ! chrome.tabs.executeScript(tabId, { code: 'doThing()' })
141
224
  ```
142
225
 
143
226
  #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
@@ -149,6 +232,8 @@ If you already scope every injected style inside a `shadowRoot` you own.
149
232
  + chrome.action.enable(tabId)
150
233
  - chrome.extension.getBackgroundPage().doThing()
151
234
  + await chrome.runtime.getContexts({ contextTypes: ['BACKGROUND'] })
235
+ - chrome.tabs.executeScript(tabId, { code: 'doThing()' })
236
+ + chrome.scripting.executeScript({ target: { tabId }, func: doThing })
152
237
  ```
153
238
 
154
239
  #### When not to use it
@@ -161,9 +246,7 @@ If you don't want deprecation validation at all. For individual deprecated APIs
161
246
 
162
247
  #### See also
163
248
 
164
- - [`no-remote-code`](#no-remote-code)
165
- - [`valid-mv3-schema`](#valid-mv3-schema)
166
- - <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/api">Chrome Extensions — API reference</a>
249
+ - <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/api">Chrome for Developers — API reference</a>
167
250
 
168
251
  <div align="center">
169
252
 
@@ -191,26 +274,27 @@ If you don't want deprecation validation at all. For individual deprecated APIs
191
274
  #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/gray.svg" align="bottom"> Incorrect code
192
275
 
193
276
  ```diff
194
- ! chrome.runtime.onMessageExternal.addListener((msg, sender, respond) => {
195
- ! handle(msg) })
277
+ chrome.runtime.onMessageExternal.addListener((msg, sender, respond) => {
278
+ ! handle(msg)
279
+ })
196
280
 
197
- ! chrome.tabs.query({}).then(tabs => tabs.forEach(tab =>
198
- ! chrome.tabs.sendMessage(tab.id, { action: 'ping' })))
281
+ ! chrome.tabs.query({})
282
+ .then(tabs => tabs.forEach(tab =>
283
+ chrome.tabs.sendMessage(tab.id, { action: 'ping' })))
199
284
  ```
200
285
 
201
286
  #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
202
287
 
203
288
  ```diff
204
- - chrome.runtime.onMessageExternal.addListener((msg, sender, respond) => {
205
- - handle(msg) })
206
- + chrome.runtime.onMessageExternal.addListener((msg, sender, respond) => {
289
+ chrome.runtime.onMessageExternal.addListener((msg, sender, respond) => {
207
290
  + if (sender.id != 'allowed-extension-id') return
208
- + handle(msg)
209
- + })
291
+ handle(msg)
292
+ })
210
293
 
211
- - chrome.tabs.query({}).then(tabs => tabs.forEach(tab =>
212
- + chrome.tabs.query({ url: 'https://example.com/*' }).then(tabs => tabs.forEach(tab =>
213
- chrome.tabs.sendMessage(tab.id, { action: 'ping' })))
294
+ - chrome.tabs.query({})
295
+ + chrome.tabs.query({ url: 'https://allowed-origin.com/*' })
296
+ .then(tabs => tabs.forEach(tab =>
297
+ chrome.tabs.sendMessage(tab.id, { action: 'ping' })))
214
298
  ```
215
299
 
216
300
  #### When not to use it
@@ -223,8 +307,8 @@ If you trust every external caller and open tab.
223
307
 
224
308
  #### See also
225
309
 
226
- - [`no-deprecated-apis`](#no-deprecated-apis)
227
- - [`no-missing-permissions`](#no-missing-permissions)
310
+ - <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/api/runtime#event-onMessageExternal">Chrome for Developers — `runtime.onMessageExternal`</a>
311
+ - <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/api/tabs#method-query">Chrome for Developers — `tabs.query()`</a>
228
312
 
229
313
  <div align="center">
230
314
 
@@ -262,8 +346,8 @@ If you trust every external caller and open tab.
262
346
  /* eslint chrome-extensions/no-invalid-permissions: ['error', { target: 'mv3' }] */
263
347
 
264
348
  {
265
- ! "permissions": ["browserAction", "tabs"],
266
- ! "optional_permissions": ["history", "pageAction"]
349
+ ! "permissions": ["browserAction", "tabs"],
350
+ ! "optional_permissions": ["history", "pageAction"]
267
351
  }
268
352
  ```
269
353
 
@@ -273,10 +357,10 @@ If you trust every external caller and open tab.
273
357
  /* eslint chrome-extensions/no-invalid-permissions: ['error', { target: 'mv3' }] */
274
358
 
275
359
  {
276
- - "permissions": ["browserAction", "tabs"],
277
- + "permissions": ["tabs"],
278
- - "optional_permissions": ["history", "pageAction"]
279
- + "optional_permissions": ["history"]
360
+ - "permissions": ["browserAction", "tabs"],
361
+ + "permissions": ["tabs"],
362
+ - "optional_permissions": ["history", "pageAction"]
363
+ + "optional_permissions": ["history"]
280
364
  }
281
365
  ```
282
366
 
@@ -324,7 +408,7 @@ If you don't want permission validation at all. For custom permissions from a Ch
324
408
  ! "permissions": []
325
409
  }
326
410
 
327
- // background.js
411
+ // service-worker.js
328
412
  ! chrome.cookies.get({ url: 'https://example.com', name: 'x' })
329
413
  ! chrome.history.search({ text: '' })
330
414
  ```
@@ -338,7 +422,7 @@ If you don't want permission validation at all. For custom permissions from a Ch
338
422
  + "permissions": ["cookies", "history"]
339
423
  }
340
424
 
341
- // background.js
425
+ // service-worker.js
342
426
  chrome.cookies.get({ url: 'https://example.com', name: 'x' })
343
427
  chrome.history.search({ text: '' })
344
428
  ```
@@ -379,7 +463,7 @@ If you use APIs through aliases or wrappers that obscure the original call site.
379
463
 
380
464
  <details>
381
465
 
382
- <summary>Disallows remote or dynamically-evaluated code.</summary>
466
+ <summary>Disallows remote or dynamically-evaluated code (blocked by MV3's CSP at runtime).</summary>
383
467
 
384
468
  #### Options
385
469
 
@@ -389,33 +473,32 @@ If you use APIs through aliases or wrappers that obscure the original call site.
389
473
  #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/gray.svg" align="bottom"> Incorrect code
390
474
 
391
475
  ```diff
476
+ ! await import('https://cdn.example.com/lib.js')
392
477
  ! const run = eval('1 + 1')
393
478
  ! setTimeout('doThing()', 1000)
394
- ! await import('https://cdn.example.com/lib.js')
479
+ ! chrome.scripting.executeScript({ target: { tabId }, func: () => doThing(value) })
395
480
  ```
396
481
 
397
482
  #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
398
483
 
399
484
  ```diff
485
+ - await import('https://cdn.example.com/lib.js')
486
+ + await import('./lib.js')
400
487
  - const run = eval('1 + 1')
401
488
  + const run = 1 + 1
402
489
  - setTimeout('doThing()', 1000)
403
490
  + setTimeout(doThing, 1000)
404
- - await import('https://cdn.example.com/lib.js')
405
- + await import('./lib.js')
491
+ - chrome.scripting.executeScript({ target: { tabId }, func: () => doThing(value) })
492
+ + chrome.scripting.executeScript({ target: { tabId }, func: doThing, args: [value] })
406
493
  ```
407
494
 
408
- #### When not to use it
409
-
410
- Never (MV3 CSP blocks remote code at runtime).
411
-
412
495
  #### Introduced in
413
496
 
414
497
  <a target="_blank" href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/releases/tag/v1.0.0">v1.0.0</a> (October 2, 2026)
415
498
 
416
499
  #### See also
417
500
 
418
- - [`no-deprecated-apis`](#no-deprecated-apis)
501
+ - [`require-mv3`](#require-mv3)
419
502
  - [`valid-mv3-schema`](#valid-mv3-schema)
420
503
 
421
504
  <div align="center">
@@ -430,7 +513,7 @@ Never (MV3 CSP blocks remote code at runtime).
430
513
 
431
514
  <h3 id="no-unused-permissions">
432
515
  <code>no-unused-permissions</code>
433
- <a title="Only included in recommended + strict configs" href="#usage">
516
+ <a title="Included in recommended + strict configs" href="#usage">
434
517
  <img height=18 alt="[Recommended]" src="https://img.shields.io/badge/Recommended-f1f20a?style=flat-square" align="middle"></a>
435
518
  <a title="Copy rule URL" target="_blank"
436
519
  href="https://copy-to-clipboard.onrender.com/copy/https%3A%2F%2Fcodeberg.org%2Fadamlui%2Feslint-plugin-chrome-extensions%2F%23no-unused-permissions">
@@ -449,7 +532,7 @@ Never (MV3 CSP blocks remote code at runtime).
449
532
  ! "permissions": ["tabs", "downloads"]
450
533
  }
451
534
 
452
- // background.js
535
+ // service-worker.js
453
536
  chrome.tabs.create({ url: 'https://example.com' })
454
537
  ```
455
538
 
@@ -462,7 +545,7 @@ Never (MV3 CSP blocks remote code at runtime).
462
545
  + "permissions": ["tabs"]
463
546
  }
464
547
 
465
- // background.js
548
+ // service-worker.js
466
549
  chrome.tabs.create({ url: 'https://example.com' })
467
550
  ```
468
551
 
@@ -505,7 +588,6 @@ If you access the granted API through an alias or computed property rather than
505
588
  #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/gray.svg" align="bottom"> Incorrect code
506
589
 
507
590
  ```diff
508
- // manifest.json
509
591
  {
510
592
  ! "manifest_version": 2,
511
593
  "name": "My Extension",
@@ -516,7 +598,6 @@ If you access the granted API through an alias or computed property rather than
516
598
  #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
517
599
 
518
600
  ```diff
519
- // manifest.json
520
601
  {
521
602
  - "manifest_version": 2,
522
603
  + "manifest_version": 3,
@@ -564,11 +645,11 @@ If you intentionally target MV2 for a Chromium fork that still supports it.
564
645
 
565
646
  ```diff
566
647
  {
567
- "manifest_version": 3,
568
- ! "browser_action": {},
569
- ! "page_action": {},
570
- ! "background": { "scripts": ["bg.js"], "persistent": true },
571
- ! "content_security_policy": "script-src 'self' 'unsafe-eval'"
648
+ "manifest_version": 3,
649
+ ! "browser_action": {},
650
+ ! "page_action": {},
651
+ ! "background": { "scripts": ["background.js"], "persistent": true },
652
+ ! "content_security_policy": "script-src 'self' 'unsafe-eval'"
572
653
  }
573
654
  ```
574
655
 
@@ -576,14 +657,14 @@ If you intentionally target MV2 for a Chromium fork that still supports it.
576
657
 
577
658
  ```diff
578
659
  {
579
- "manifest_version": 3,
580
- - "browser_action": {},
581
- - "page_action": {},
582
- + "action": {},
583
- - "background": { "scripts": ["bg.js"], "persistent": true },
584
- + "background": { "service_worker": "sw.js" },
585
- - "content_security_policy": "script-src 'self' 'unsafe-eval'"
586
- + "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }
660
+ "manifest_version": 3,
661
+ - "browser_action": {},
662
+ - "page_action": {},
663
+ + "action": {},
664
+ - "background": { "scripts": ["background.js"], "persistent": true },
665
+ + "background": { "service_worker": "service-worker.js" },
666
+ - "content_security_policy": "script-src 'self' 'unsafe-eval'"
667
+ + "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }
587
668
  }
588
669
  ```
589
670
 
@@ -629,7 +710,7 @@ If you intentionally target MV2 for a Chromium fork that still supports it.
629
710
  ! (function init() { chrome.runtime.onMessage.addListener(handler) })()
630
711
  ! setInterval(() => {}, 5000)
631
712
  ! const loc = window.location
632
- ! localStorage.setItem('key', 'val')
713
+ ! localStorage.setItem('key', 'value')
633
714
  ```
634
715
 
635
716
  #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
@@ -655,7 +736,7 @@ If your background context isn't a service worker.
655
736
 
656
737
  #### See also
657
738
 
658
- - [`no-content-style-leak`](#no-content-style-leak)
739
+ - [`require-mv3`](#require-mv3)
659
740
  - [`no-remote-code`](#no-remote-code)
660
741
 
661
742
  <div align="center">
@@ -664,11 +745,81 @@ If your background context isn't a service worker.
664
745
 
665
746
  </div>
666
747
 
748
+ ---
749
+
750
+ </details>
751
+
752
+ <h3 id="valid-version-field">
753
+ <code>valid-version-field</code>
754
+ <a title="Included in recommended config" href="#usage">
755
+ <img height=18 alt="[Recommended]" src="https://img.shields.io/badge/Recommended-f1f20a?style=flat-square" align="middle"></a>
756
+ <a title="Accepts array w/ options" href="#usage">
757
+ <img height=18 alt="[Configurable]" src="https://img.shields.io/badge/Configurable-black?style=flat-square" align="middle"></a>
758
+ <a title="Copy rule URL" target="_blank"
759
+ href="https://copy-to-clipboard.onrender.com/copy/https%3A%2F%2Fcodeberg.org%2Fadamlui%2Feslint-plugin-chrome-extensions%2F%23valid-version-field">
760
+ <img height=16 width=21 alt="[Copy rule URL]" src="https://api.iconify.design/mdi/content-copy.svg?color=%23cccccc" align="middle"></a>
761
+ </h3>
762
+
763
+ <details>
764
+
765
+ <summary>Validates the manifest <code>version</code> field.</summary>
766
+
767
+ #### Options
768
+
769
+ - `versionType` (default `'chrome'`) – version format to enforce (`'chrome'`, `'semver'`, `'date'`)
770
+ - `allowFutureDate` (default `false`) – allow `version` dates later than the current date
771
+ - `timezone` (default `''`) – <a target="_blank" href="https://data.iana.org/time-zones/tzdb/zone1970.tab">IANA timezone</a> used to determine the current date (when omitted, only year/month are enforced)
772
+
773
+ #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/gray.svg" align="bottom"> Incorrect code
774
+
775
+ ```diff
776
+ /* eslint chrome-extensions/valid-version-field: ['error', { versionType: 'date' }] */
777
+
778
+ // manifest.json
779
+ {
780
+ "manifest_version": 3,
781
+ ! "version": "2026.1.111",
782
+ "name": "My Extension"
783
+ }
784
+ ```
785
+
786
+ #### <img width=13 height=13 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/99f14247b2/assets/images/icons/square/with-symbol/green.svg" align="bottom"> Correct code
787
+
788
+ ```diff
789
+ /* eslint chrome-extensions/valid-version-field: ['error', { versionType: 'date' }] */
790
+
791
+ // manifest.json
792
+ {
793
+ "manifest_version": 3,
794
+ - "version": "2026.1.111",
795
+ + "version": "2026.10.3",
796
+ "name": "My Extension"
797
+ }
798
+ ```
799
+
800
+ #### When not to use it
801
+
802
+ If you don't care about version format consistency, or your version is injected at build time.
803
+
804
+ #### Introduced in
805
+
806
+ <a target="_blank" href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/releases/tag/v2.0.0">v2.0.0</a> (October 4, 2026)
807
+
808
+ #### See also
809
+
810
+ - <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/manifest/version">Chrome for Developers — `version`</a>
811
+
812
+ <div align="center">
813
+
814
+ [Back to index ↑][epc-rules]
815
+
816
+ </div>
817
+
667
818
  </details>
668
819
 
669
820
  ---
670
821
 
671
- ## <img height=30 align="top" src="https://api.iconify.design/boxicons/code-alt-filled.svg?color=%232b2b2a"> Shield embed
822
+ ## <img width=30 align="top" src="https://api.iconify.design/boxicons/code-alt-filled.svg?color=%232b2b2a"> Shield embed
672
823
 
673
824
  <a target="_blank" href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/#readme">
674
825
  <img height=32 alt="[Linted by eslint-plugin-chrome-extensions]" src="https://img.shields.io/badge/Linted_by-eslint--plugin--chrome--extensions-black?logo=eslint&logoColor=white&labelColor=464646&style=for-the-badge"></a>
@@ -676,7 +827,7 @@ If your background context isn't a service worker.
676
827
  #### HTML
677
828
 
678
829
  ```html
679
- <a href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/#readme">
830
+ <a target="_blank" href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/#readme">
680
831
  <img height=32 alt="[Linted by eslint-plugin-chrome-extensions]" src="https://img.shields.io/badge/Linted_by-eslint--plugin--chrome--extensions-black?logo=eslint&logoColor=white&labelColor=464646&style=for-the-badge"></a>
681
832
  ```
682
833
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-chrome-extensions",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
4
4
  "description": "ESLint rules for Chrome extensions",
5
5
  "type": "module",
6
6
  "engines": {
@@ -58,7 +58,8 @@
58
58
  "chromium"
59
59
  ],
60
60
  "peerDependencies": {
61
- "eslint": ">=9.0.0"
61
+ "eslint": ">=9.6.0",
62
+ "@eslint/json": ">=0.1.0"
62
63
  },
63
64
  "devDependencies": {
64
65
  "@e18e/eslint-plugin": "^0.8.1",
@@ -66,11 +67,11 @@
66
67
  "@eslint/json": "^2.1.0",
67
68
  "@eslint/markdown": "^8.0.3",
68
69
  "@stylistic/eslint-plugin": "^5.10.0",
69
- "eslint": "^10.11.0",
70
+ "eslint": "^10.12.0",
70
71
  "eslint-plugin-constructors": "^1.4.0",
71
72
  "eslint-plugin-eslint-plugin": "^7.6.2",
72
73
  "eslint-plugin-import-x": "^4.17.1",
73
- "eslint-plugin-n": "^18.4.0",
74
+ "eslint-plugin-n": "^18.4.1",
74
75
  "eslint-plugin-no-secrets": "^2.3.3",
75
76
  "eslint-plugin-no-unsanitized": "^4.1.5",
76
77
  "eslint-plugin-promise": "^7.3.0",
@@ -1,13 +1,32 @@
1
1
  import globals from 'globals'
2
2
 
3
- export const mv2OnlyKeys = new Set(['browser_action', 'page_action'])
4
- export const forbiddenCSPDirectives = [
3
+ const serviceWorkerGlobals = new Set(Object.keys(globals.serviceworker || {}))
4
+ export const domGlobals = new Set(Object.keys(globals.browser).filter(name => !serviceWorkerGlobals.has(name)))
5
+
6
+ export const forbiddenCSPdirectives = [
5
7
  `'unsafe-eval'`, `'unsafe-inline'`, `'unsafe-hashes'`, `'wasm-unsafe-eval'`, 'http://', 'https://']
6
8
 
7
- export const serviceWorkerKeepaliveMinMS = 30000
9
+ export const manifestKeyOrder = [
10
+ // platform-required
11
+ 'manifest_version', 'name', 'version',
12
+ // CWS-required
13
+ 'description', 'icons',
14
+ // optional (alphabetical, per docs)
15
+ 'action', 'background', 'chrome_settings_overrides', 'chrome_url_overrides',
16
+ 'commands', 'content_scripts', 'content_security_policy',
17
+ 'cross_origin_embedder_policy', 'cross_origin_opener_policy',
18
+ 'declarative_net_request', 'default_locale', 'devtools_page', 'export',
19
+ 'externally_connectable', 'homepage_url', 'host_permissions', 'import',
20
+ 'incognito', 'key', 'mime_types_handler', 'minimum_chrome_version',
21
+ 'oauth2', 'omnibox', 'optional_host_permissions', 'optional_permissions',
22
+ 'options_page', 'options_ui', 'permissions', 'requirements', 'sandbox',
23
+ 'short_name', 'side_panel', 'storage', 'tts_engine', 'update_url',
24
+ 'version_name', 'web_accessible_resources'
25
+ ]
8
26
 
9
- const serviceWorkerGlobals = new Set(Object.keys(globals.serviceworker || {}))
10
- export const domGlobals = new Set(Object.keys(globals.browser).filter(name => !serviceWorkerGlobals.has(name)))
27
+ export const mv2OnlyKeys = new Set(['browser_action', 'page_action'])
11
28
 
12
29
  export const remoteCodePatterns = [
13
30
  'eval', 'new-function', 'string-timer', 'dynamic-import', 'remote-script-src', 'execute-script-url']
31
+
32
+ export const serviceWorkerKeepaliveMinMS = 30000
package/src/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { bundleRules } from './utils.js'
2
+
3
+ import manifestKeyOrder from './rules/manifest-key-order.js'
1
4
  import noContentStyleLeak from './rules/no-content-style-leak.js'
2
5
  import noDeprecatedAPIs from './rules/no-deprecated-apis.js'
3
6
  import noInsecureMessaging from './rules/no-insecure-messaging.js'
@@ -8,19 +11,22 @@ import noUnusedPermissions from './rules/no-unused-permissions.js'
8
11
  import requireMV3 from './rules/require-mv3.js'
9
12
  import validMV3schema from './rules/valid-mv3-schema.js'
10
13
  import validServiceWorker from './rules/valid-service-worker.js'
14
+ import validVersionField from './rules/valid-version-field.js'
11
15
 
12
16
  const plugin = {
13
17
  rules: {
14
- 'require-mv3': requireMV3,
15
- 'valid-mv3-schema': validMV3schema,
16
- 'no-deprecated-apis': noDeprecatedAPIs,
17
- 'no-remote-code': noRemoteCode,
18
- 'valid-service-worker': validServiceWorker,
18
+ 'manifest-key-order': manifestKeyOrder,
19
19
  'no-content-style-leak': noContentStyleLeak,
20
+ 'no-deprecated-apis': noDeprecatedAPIs,
21
+ 'no-insecure-messaging': noInsecureMessaging,
20
22
  'no-invalid-permissions': noInvalidPermissions,
21
23
  'no-missing-permissions': noMissingPermissions,
24
+ 'no-remote-code': noRemoteCode,
22
25
  'no-unused-permissions': noUnusedPermissions,
23
- 'no-insecure-messaging': noInsecureMessaging
26
+ 'require-mv3': requireMV3,
27
+ 'valid-mv3-schema': validMV3schema,
28
+ 'valid-service-worker': validServiceWorker,
29
+ 'valid-version-field': validVersionField
24
30
  }
25
31
  }
26
32
 
@@ -30,28 +36,19 @@ for (const [ruleId, rule] of Object.entries(plugin.rules))
30
36
  plugin.configs = {
31
37
  recommended: {
32
38
  plugins: { 'chrome-extensions': plugin },
33
- rules: {
34
- 'chrome-extensions/require-mv3': 'error',
35
- 'chrome-extensions/valid-mv3-schema': 'error',
36
- 'chrome-extensions/no-deprecated-apis': 'error',
37
- 'chrome-extensions/no-remote-code': 'error',
38
- 'chrome-extensions/valid-service-worker': 'error',
39
- 'chrome-extensions/no-invalid-permissions': 'error',
40
- 'chrome-extensions/no-missing-permissions': 'error',
41
- 'chrome-extensions/no-unused-permissions': 'error',
42
- 'chrome-extensions/no-insecure-messaging': 'error'
43
- }
39
+ rules: bundleRules(plugin.rules, rule => rule.meta.docs.recommended, 'chrome-extensions')
44
40
  },
45
41
  strict: {
46
42
  plugins: { 'chrome-extensions': plugin },
47
- rules: Object.fromEntries(Object.keys(plugin.rules)
48
- .map(ruleId => [`chrome-extensions/${ruleId}`, 'error']))
43
+ rules: bundleRules(plugin.rules, () => true, 'chrome-extensions')
49
44
  },
50
45
  problem: {
51
46
  plugins: { 'chrome-extensions': plugin },
52
- rules: Object.fromEntries(Object.entries(plugin.rules)
53
- .filter(([, rule]) => rule.meta.type == 'problem')
54
- .map(([ruleId]) => [`chrome-extensions/${ruleId}`, 'error']))
47
+ rules: bundleRules(plugin.rules, rule => rule.meta.type == 'problem', 'chrome-extensions')
48
+ },
49
+ manifest: {
50
+ plugins: { 'chrome-extensions': plugin },
51
+ rules: bundleRules(plugin.rules, rule => rule.meta.docs.manifest, 'chrome-extensions')
55
52
  }
56
53
  }
57
54
 
@@ -0,0 +1,75 @@
1
+ import { manifestKeyOrder } from '../data/manifest.js'
2
+ import { getFilename } from '../utils.js'
3
+
4
+ const re = { manifestFilename: /manifest\.json$/ }
5
+
6
+ function keyOf(member) {
7
+ const keyNode = member.name || member.key
8
+ return keyNode?.value ?? keyNode?.name
9
+ }
10
+
11
+ function findViolation(members, keys) {
12
+ const ranks = new Map(keys.map((key, index) => [key, index])),
13
+ known = members.map(member => {
14
+ const key = keyOf(member)
15
+ return { member, key, rank: ranks.get(key) }
16
+ }).filter(item => item.rank !== undefined)
17
+ for (let i = 1; i < known.length; i++)
18
+ // eslint-disable-next-line security/detect-object-injection
19
+ if (known[i -1].rank > known[i].rank) return [known[i -1], known[i]]
20
+ }
21
+
22
+ export default {
23
+ meta: {
24
+ type: 'layout',
25
+ docs: {
26
+ description: 'Enforce an order for manifest keys',
27
+ category: 'Stylistic Issues',
28
+ recommended: false,
29
+ manifest: true
30
+ },
31
+ fixable: 'code',
32
+ schema: [{
33
+ type: 'object',
34
+ properties: {
35
+ order: {
36
+ type: 'array', items: { type: 'string' },
37
+ description: 'Manifest keys in preferred order; unspecified keys are ignored'
38
+ }
39
+ },
40
+ additionalProperties: false
41
+ }],
42
+ defaultOptions: [{ order: [] }],
43
+ messages: { wrongOrder: '`{{first}}` should appear before `{{second}}`' }
44
+ },
45
+
46
+ create(context) {
47
+ if (!re.manifestFilename.test(getFilename(context))) return {}
48
+ const custom = context.options[0]?.order ?? [],
49
+ master = custom.length ? manifestKeyOrder.filter(key => !custom.includes(key))
50
+ : manifestKeyOrder
51
+ return {
52
+ 'Document:exit'(document) {
53
+ const root = document.body ; if (root?.type != 'Object') return
54
+ const members = root.members || root.properties || []
55
+ const violation = findViolation(members, master) ?? (custom.length && findViolation(members, custom))
56
+ if (!violation) return
57
+ const [first, second] = violation
58
+ context.report({
59
+ node: second.member, messageId: 'wrongOrder',
60
+ data: { first: second.key, second: first.key },
61
+ fix(fixer) {
62
+ const sourceCode = context.sourceCode,
63
+ firstText = sourceCode.text.slice(first.member.range[0], first.member.range[1]),
64
+ secondText = sourceCode.text.slice(second.member.range[0], second.member.range[1]),
65
+ between = sourceCode.text.slice(first.member.range[1], second.member.range[0])
66
+ return fixer.replaceTextRange(
67
+ [first.member.range[0], second.member.range[1]],
68
+ secondText + between + firstText
69
+ )
70
+ }
71
+ })
72
+ }
73
+ }
74
+ }
75
+ }
@@ -10,7 +10,7 @@ export default {
10
10
  },
11
11
  schema: [],
12
12
  messages: {
13
- styleLeak: 'Inject styles via `chrome.scripting.insertCSS()` or `content_scripts.css` instead of appending to the host document.'
13
+ styleLeak: 'Inject styles into a `shadowRoot` instead of the host document.'
14
14
  }
15
15
  },
16
16
 
@@ -1,5 +1,7 @@
1
1
  import { isValidPermission } from '../data/permissions.js'
2
- import { readManifest } from '../utils.js'
2
+ import { getFilename, readManifest } from '../utils.js'
3
+
4
+ const re = { manifestFilename: /manifest\.json$/ }
3
5
 
4
6
  export default {
5
7
  meta: {
@@ -7,7 +9,8 @@ export default {
7
9
  docs: {
8
10
  description: 'Disallow invalid or unsupported manifest permission names',
9
11
  category: 'Possible Errors',
10
- recommended: true
12
+ recommended: true,
13
+ manifest: true
11
14
  },
12
15
  defaultOptions: [{ target: 'mv3', allowedPermissions: [] }],
13
16
  schema: [{
@@ -40,9 +43,27 @@ export default {
40
43
  const opts = context.options[0] || {},
41
44
  target = opts.target || 'mv3',
42
45
  allowed = new Set(Array.isArray(opts.allowedPermissions) ? opts.allowedPermissions
43
- : opts.allowedPermissions ? [opts.allowedPermissions] : []),
44
- manifest = readManifest(context)
45
- if (!manifest) return {}
46
+ : opts.allowedPermissions ? [opts.allowedPermissions] : [])
47
+ function checkPermission(node, permission) {
48
+ if (typeof permission != 'string') return
49
+ if (allowed.has(permission) || isValidPermission(permission, target)) return
50
+ context.report({ node, messageId: 'invalidPermission', data: { permission, target }})
51
+ }
52
+ if (re.manifestFilename.test(getFilename(context))) // lint it directly
53
+ return {
54
+ '*'(node) {
55
+ const keyNode = node.name || node.key ; if (!keyNode) return
56
+ const keyName = keyNode.value ?? keyNode.name
57
+ if (keyName != 'permissions' && keyName != 'optional_permissions') return
58
+ const arrNode = node.value ; if (arrNode?.type != 'Array') return
59
+ const elements = arrNode.elements || arrNode.items || []
60
+ for (const el of elements) {
61
+ const valueNode = el.value || el
62
+ checkPermission(valueNode, valueNode?.value)
63
+ }
64
+ }
65
+ }
66
+ const manifest = readManifest(context) ; if (!manifest) return {}
46
67
  const all = [...(manifest.permissions || []), ...(manifest.optional_permissions || [])]
47
68
  return {
48
69
  'Program:exit'() {
@@ -32,11 +32,11 @@ export default {
32
32
  additionalProperties: false
33
33
  }],
34
34
  messages: {
35
- eval: '`eval()` is forbidden by the MV3 CSP.',
36
- newFunction: '`new Function()` is forbidden by the MV3 CSP.',
37
- stringTimer: 'String-argument `{{fn}}` is forbidden by the MV3 CSP - pass a function reference.',
38
- dynamicImport: 'Remote dynamic `import()` is forbidden by the MV3 CSP.',
39
- remoteScriptSrc: 'Injecting remote `<script src>` is forbidden by the MV3 CSP - use `chrome.runtime.getURL()`.',
35
+ eval: '`eval()` is forbidden under MV3\'s CSP - refactor to use static code.',
36
+ newFunction: '`new Function()` is forbidden under MV3\'s CSP - refactor to use a real function.',
37
+ stringTimer: 'String-argument `{{fn}}` is forbidden under MV3\'s CSP - pass a function reference instead.',
38
+ dynamicImport: 'Remote dynamic `import()` is forbidden under MV3\'s CSP - import local modules w/ a relative path.',
39
+ remoteScriptSrc: 'Injecting remote `<script src>` is forbidden under MV3\'s CSP - use `chrome.runtime.getURL()` for bundled scripts.',
40
40
  executeScriptURL: '`chrome.scripting.executeScript` must use local `files`, not a remote URL.',
41
41
  executeScriptClosure: '`chrome.scripting.executeScript({ func })` captures outer-scope variables that fail after serialization - pass them via `args`.'
42
42
  }
@@ -1,15 +1,40 @@
1
- import { apiPermissionMap } from '../data/apis.js'
2
- import { readManifest, getChromeAPIcalls } from '../utils.js'
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { apiPermissionMap, findPermission } from '../data/apis.js'
4
+ import { findExtensionRoot, getFilename, readManifest } from '../utils.js'
3
5
 
4
- const mappedPermissions = new Set(apiPermissionMap.values())
6
+ const re = { manifestFilename: /manifest\.json$/ }
7
+
8
+ function collectUsedAPIs(manifest, root) {
9
+ const files = new Set()
10
+ if (manifest.background?.service_worker) files.add(manifest.background.service_worker)
11
+ for (const script of manifest.background?.scripts || []) files.add(script)
12
+ for (const contentScript of manifest.content_scripts || [])
13
+ for (const file of contentScript.js || []) files.add(file)
14
+ if (manifest.action?.default_popup) files.add(manifest.action.default_popup)
15
+ if (manifest.options_ui?.page) files.add(manifest.options_ui.page)
16
+ if (manifest.devtools_page) files.add(manifest.devtools_page)
17
+ const re = { /* eslint-disable-next-line security/detect-unsafe-regex */
18
+ chromeAPI: /\bchrome\.\w+(?:\.\w+)*/g }
19
+ const used = new Set()
20
+ for (const file of files) {
21
+ try {
22
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
23
+ const src = fs.readFileSync(path.resolve(root, file), 'utf8')
24
+ for (const match of src.matchAll(re.chromeAPI)) used.add(match[0])
25
+ } catch {} // eslint-disable-line no-empty
26
+ }
27
+ return used
28
+ }
5
29
 
6
30
  export default {
7
31
  meta: {
8
- type: 'suggestion',
32
+ type: 'problem',
9
33
  docs: {
10
- description: 'Flag manifest permissions whose APIs are never used in this file',
11
- category: 'Best Practices',
12
- recommended: true
34
+ description: 'Flag manifest permissions whose APIs are never used',
35
+ category: 'Possible Errors',
36
+ recommended: true,
37
+ manifest: true
13
38
  },
14
39
  schema: [],
15
40
  messages: {
@@ -18,20 +43,40 @@ export default {
18
43
  },
19
44
 
20
45
  create(context) {
46
+ if (!re.manifestFilename.test(getFilename(context))) return {}
21
47
  const manifest = readManifest(context) ; if (!manifest) return {}
48
+ const root = findExtensionRoot(context) ; if (!root) return {}
49
+ const usedAPIs = collectUsedAPIs(manifest, root)
50
+ const permissionNodes = new Map()
51
+ function isUsed(permission) {
52
+ let known = false
53
+ for (const [, perm] of apiPermissionMap)
54
+ if (perm == permission) { known = true ; break }
55
+ if (!known) return true // custom permission → not our business
56
+ for (const api of usedAPIs)
57
+ if (findPermission(api) == permission) return true
58
+ return false
59
+ }
22
60
  return {
23
- 'Program:exit'() {
24
- const declared = manifest.permissions || [] ; if (!declared.length) return
25
- const calls = getChromeAPIcalls(context.sourceCode)
26
- const used = new Set()
27
- for (const api of calls)
28
- for (const [prefix, permission] of apiPermissionMap)
29
- if (api == prefix || api.startsWith(`${prefix}.`)) { used.add(permission) ; break }
30
- for (const permission of declared) {
31
- if (!mappedPermissions.has(permission)) continue
32
- if (used.has(permission)) continue
33
- context.report({ loc: { line: 1, column: 0 },
34
- messageId: 'unusedPermission', data: { permission }})
61
+ '*'(node) {
62
+ const keyNode = node.name || node.key ; if (!keyNode) return
63
+ const keyName = keyNode.value ?? keyNode.name
64
+ if (keyName != 'permissions' && keyName != 'optional_permissions') return
65
+ const arrNode = node.value ; if (arrNode?.type != 'Array') return
66
+ const elements = arrNode.elements || arrNode.items || []
67
+ for (const el of elements) {
68
+ const valueNode = el.value || el
69
+ const perm = valueNode?.value
70
+ if (typeof perm != 'string') continue
71
+ if (!permissionNodes.has(perm)) permissionNodes.set(perm, [])
72
+ permissionNodes.get(perm).push(valueNode)
73
+ }
74
+ },
75
+ 'Document:exit'() {
76
+ for (const [permission, nodes] of permissionNodes) {
77
+ if (isUsed(permission)) continue
78
+ for (const n of nodes)
79
+ context.report({ node: n, messageId: 'unusedPermission', data: { permission }})
35
80
  }
36
81
  }
37
82
  }
@@ -1,4 +1,6 @@
1
- import { readManifest } from '../utils.js'
1
+ import { getFilename, readManifest } from '../utils.js'
2
+
3
+ const re = { manifestFilename: /manifest\.json$/ }
2
4
 
3
5
  export default {
4
6
  meta: {
@@ -6,7 +8,8 @@ export default {
6
8
  docs: {
7
9
  description: 'Require manifest_version 3',
8
10
  category: 'Possible Errors',
9
- recommended: true
11
+ recommended: true,
12
+ manifest: true
10
13
  },
11
14
  schema: [],
12
15
  messages: {
@@ -15,6 +18,21 @@ export default {
15
18
  },
16
19
 
17
20
  create(context) {
21
+ if (re.manifestFilename.test(getFilename(context))) { // lint it directly
22
+ let foundVersion = null, versionNode = null
23
+ return {
24
+ '*'(node) {
25
+ const keyNode = node.name || node.key ; if (!keyNode) return
26
+ if ((keyNode.value ?? keyNode.name) != 'manifest_version') return
27
+ foundVersion = node.value?.value
28
+ versionNode = node.value || keyNode
29
+ },
30
+ '*:exit'(node) {
31
+ if (node.type != 'Document' || foundVersion == 3) return
32
+ context.report({ node: versionNode || node, messageId: 'notMV3' })
33
+ }
34
+ }
35
+ }
18
36
  const manifest = readManifest(context) ; if (!manifest || manifest.manifest_version == 3) return {}
19
37
  return { Program(node) { context.report({ node, messageId: 'notMV3' }) }}
20
38
  }
@@ -1,5 +1,7 @@
1
- import { mv2OnlyKeys, forbiddenCSPDirectives } from '../data/manifest.js'
2
- import { readManifest } from '../utils.js'
1
+ import { mv2OnlyKeys, forbiddenCSPdirectives } from '../data/manifest.js'
2
+ import { getFilename, readManifest } from '../utils.js'
3
+
4
+ const re = { manifestFilename: /manifest\.json$/ }
3
5
 
4
6
  export default {
5
7
  meta: {
@@ -7,7 +9,8 @@ export default {
7
9
  docs: {
8
10
  description: 'Validate manifest against the MV3 schema',
9
11
  category: 'Possible Errors',
10
- recommended: true
12
+ recommended: true,
13
+ manifest: true
11
14
  },
12
15
  schema: [],
13
16
  messages: {
@@ -18,6 +21,51 @@ export default {
18
21
  },
19
22
 
20
23
  create(context) {
24
+ if (re.manifestFilename.test(getFilename(context))) { // lint it directly
25
+ const ancestors = [], pendingReports = []
26
+ let isMV3 = false
27
+ const keyOf = n => {
28
+ const k = n?.name || n?.key
29
+ return k?.value ?? k?.name
30
+ }
31
+ return {
32
+ '*'(node) {
33
+ const keyName = keyOf(node)
34
+ if (keyName) {
35
+ const isTopLevel = ancestors.length == 2
36
+ && ancestors[0].type == 'Document'
37
+ && ancestors[1].type == 'Object'
38
+ const inBackground = ancestors.some(a => keyOf(a) == 'background')
39
+ const inCSP = ancestors.some(a => keyOf(a) == 'content_security_policy')
40
+ if (keyName == 'manifest_version' && node.value?.value == 3) isMV3 = true
41
+ if (isTopLevel && mv2OnlyKeys.has(keyName))
42
+ pendingReports.push({ node, messageId: 'mv2Key',
43
+ data: { key: keyName, reason: 'MV2-only key' }})
44
+ if (inBackground && keyName == 'scripts')
45
+ pendingReports.push({ node, messageId: 'mv2Key',
46
+ data: { key: 'background.scripts', reason: 'use background.service_worker' }})
47
+ if (inBackground && keyName == 'persistent')
48
+ pendingReports.push({ node, messageId: 'mv2Key',
49
+ data: { key: 'background.persistent', reason: 'MV3 service workers are never persistent' }})
50
+ if (isTopLevel && keyName == 'content_security_policy' && node.value?.type == 'String')
51
+ pendingReports.push({ node: node.value, messageId: 'wrongCSPType' })
52
+ if (inCSP && keyName == 'extension_pages' && typeof node.value?.value == 'string')
53
+ for (const directive of forbiddenCSPdirectives)
54
+ if (node.value.value.includes(directive))
55
+ pendingReports.push({ node: node.value, messageId: 'unsafeCSP',
56
+ data: { directive }})
57
+ }
58
+ ancestors.push(node)
59
+ },
60
+ '*:exit'(node) {
61
+ ancestors.pop()
62
+ if (node.type == 'Document') {
63
+ if (!isMV3) return
64
+ for (const r of pendingReports) context.report(r)
65
+ }
66
+ }
67
+ }
68
+ }
21
69
  const manifest = readManifest(context) ; if (!manifest || manifest.manifest_version != 3) return {}
22
70
  return {
23
71
  Program(node) {
@@ -35,7 +83,7 @@ export default {
35
83
  context.report({ node, messageId: 'wrongCSPType' })
36
84
  const cspPages = csp?.extension_pages
37
85
  if (typeof cspPages == 'string')
38
- for (const directive of forbiddenCSPDirectives)
86
+ for (const directive of forbiddenCSPdirectives)
39
87
  if (cspPages.includes(directive))
40
88
  context.report({ node, messageId: 'unsafeCSP', data: { directive }})
41
89
  }
@@ -0,0 +1,95 @@
1
+ import { getFilename, readManifest } from '../utils.js'
2
+
3
+ const re = { /* eslint-disable security/detect-unsafe-regex */
4
+ chromeParts: /^\d+(?:\.\d+){0,3}$/,
5
+ date: /^\d{4}\.(?:0?[1-9]|1[0-2])\.(?:0?[1-9]|[12]\d|3[01])(?:\.\d+)?$/,
6
+ manifestFilename: /manifest\.json$/,
7
+ semver: /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/
8
+ } /* eslint-enable security/detect-unsafe-regex */
9
+
10
+ export default {
11
+ meta: {
12
+ type: 'problem',
13
+ docs: {
14
+ description: 'Validate the manifest version field format',
15
+ category: 'Possible Errors',
16
+ recommended: true,
17
+ manifest: true
18
+ },
19
+ schema: [{
20
+ type: 'object',
21
+ properties: {
22
+ versionType: {
23
+ type: 'string',
24
+ enum: ['chrome', 'semver', 'date'],
25
+ description: 'Version format to enforce (default: chrome)'
26
+ },
27
+ allowFutureDate: {
28
+ type: 'boolean',
29
+ description: 'Allow version dates later than the current date (default: false)'
30
+ },
31
+ timezone: {
32
+ type: 'string',
33
+ description: 'IANA timezone for determining the current date (when omitted, only year and month are checked)'
34
+ }
35
+ },
36
+ additionalProperties: false
37
+ }],
38
+ defaultOptions: [{}],
39
+ messages: {
40
+ invalidVersion: 'Invalid manifest `version`: must be a valid {{versionType}} version (expected format: {{example}})',
41
+ futureVersion: 'Invalid manifest `version`: date cannot be in the future'
42
+ }
43
+ },
44
+
45
+ create(context) {
46
+ const options = context.options[0] || {},
47
+ versionType = options.versionType || 'chrome',
48
+ allowFutureDate = options.allowFutureDate ?? false,
49
+ timezone = options.timezone,
50
+ example = versionType == 'semver' ? '1.2.3'
51
+ : versionType == 'date' ? 'YYYY.M.D or YYYY.M.D.N'
52
+ : '1.2.3 or 1.2.3.4'
53
+ const now = timezone
54
+ ? Object.fromEntries(new Intl.DateTimeFormat('en', {
55
+ timeZone: timezone, year: 'numeric', month: 'numeric', day: 'numeric'
56
+ }).formatToParts().filter(({ type }) => ['year', 'month', 'day'].includes(type))
57
+ .map(({ type, value }) => [type, Number(value)]))
58
+ : { year: new Date().getUTCFullYear(), month: new Date().getUTCMonth() +1 }
59
+ function validate(node, version) {
60
+ if (typeof version != 'string') return
61
+ const parts = version.split('.')
62
+ if (!re.chromeParts.test(version) || parts.some(part => Number(part) > 65535))
63
+ return context.report({ node, messageId: 'invalidVersion', data: { versionType, example }})
64
+ if (versionType == 'semver') {
65
+ if (!re.semver.test(version))
66
+ context.report({ node, messageId: 'invalidVersion', data: { versionType, example }})
67
+ } else if (versionType == 'date') {
68
+ if (!re.date.test(version))
69
+ context.report({ node, messageId: 'invalidVersion', data: { versionType, example }})
70
+ else {
71
+ const [year, month, day] = parts.map(Number)
72
+ if (!allowFutureDate && (
73
+ year > now.year
74
+ || year == now.year && month > now.month
75
+ || timezone && year == now.year && month == now.month && day > now.day
76
+ )) context.report({ node, messageId: 'futureVersion' })
77
+ }
78
+ }
79
+ }
80
+ if (re.manifestFilename.test(getFilename(context))) // lint it directly
81
+ return {
82
+ '*'(node) {
83
+ const keyNode = node.name || node.key ; if (!keyNode) return
84
+ const keyName = keyNode.value ?? keyNode.name ; if (keyName != 'version') return
85
+ const valueNode = node.value ; if (valueNode?.type != 'String') return
86
+ validate(valueNode, valueNode.value)
87
+ }
88
+ }
89
+ return {
90
+ Program(node) {
91
+ validate(node, readManifest(context)?.version)
92
+ }
93
+ }
94
+ }
95
+ }
package/src/utils.js CHANGED
@@ -4,13 +4,20 @@ import path from 'node:path'
4
4
  const re = { chromeAPI: /\bchrome(?:\.\w+)+/g }, // eslint-disable-line security/detect-unsafe-regex
5
5
  manifestCache = new Map()
6
6
 
7
+ export function bundleRules(rules, predicate, pluginName) {
8
+ return Object.fromEntries(Object.entries(rules)
9
+ .filter(([, rule]) => predicate(rule))
10
+ .map(([ruleId]) => [`${pluginName}/${ruleId}`, 'error']))
11
+ }
12
+
7
13
  export function findExtensionRoot(context) {
8
14
  let dir = path.dirname(getFilename(context))
9
- const root = path.parse(dir).root
10
- while (dir != root) {
15
+ while (dir) {
11
16
  // eslint-disable-next-line security/detect-non-literal-fs-filename
12
17
  if (fs.existsSync(path.join(dir, 'manifest.json'))) return dir
13
- dir = path.dirname(dir)
18
+ const parent = path.dirname(dir)
19
+ if (parent == dir) break // reached filesystem root or a stuck relative "."
20
+ dir = parent
14
21
  }
15
22
  return null
16
23
  }