eslint-plugin-chrome-extensions 1.0.0 → 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
- - "optional_permissions": ["history", "pageAction"]
278
- + "permissions": ["tabs"],
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
 
@@ -321,10 +405,10 @@ If you don't want permission validation at all. For custom permissions from a Ch
321
405
  ```diff
322
406
  // manifest.json
323
407
  {
324
- ! "permissions": []
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
  ```
@@ -334,11 +418,11 @@ If you don't want permission validation at all. For custom permissions from a Ch
334
418
  ```diff
335
419
  // manifest.json
336
420
  {
337
- - "permissions": []
338
- + "permissions": ["cookies", "history"]
421
+ - "permissions": []
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,46 +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')
395
- ! document.head.append(Object.assign(document.createElement('script'),
396
- ! { src: 'https://cdn.example.com/remote.js' }))
397
-
398
- const secret = 'abc'
399
- ! chrome.scripting.executeScript({ func: () => alert(secret), target: { tabId } })
479
+ ! chrome.scripting.executeScript({ target: { tabId }, func: () => doThing(value) })
400
480
  ```
401
481
 
402
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
403
483
 
404
484
  ```diff
485
+ - await import('https://cdn.example.com/lib.js')
486
+ + await import('./lib.js')
405
487
  - const run = eval('1 + 1')
406
488
  + const run = 1 + 1
407
489
  - setTimeout('doThing()', 1000)
408
490
  + setTimeout(doThing, 1000)
409
- - await import('https://cdn.example.com/lib.js')
410
- + await import('./lib.js')
411
- - document.head.append(Object.assign(document.createElement('script'),
412
- - { src: 'https://cdn.example.com/remote.js' }))
413
- + document.head.append(Object.assign(document.createElement('script'),
414
- + { src: chrome.runtime.getURL('inject.js') }))
415
-
416
- const secret = 'abc'
417
- - chrome.scripting.executeScript({ func: () => alert(secret), target: { tabId } })
418
- + chrome.scripting.executeScript({ func: (s) => alert(s), args: [secret], target: { tabId } })
491
+ - chrome.scripting.executeScript({ target: { tabId }, func: () => doThing(value) })
492
+ + chrome.scripting.executeScript({ target: { tabId }, func: doThing, args: [value] })
419
493
  ```
420
494
 
421
- #### When not to use it
422
-
423
- Never (MV3 CSP blocks remote code at runtime).
424
-
425
495
  #### Introduced in
426
496
 
427
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)
428
498
 
429
499
  #### See also
430
500
 
431
- - [`no-deprecated-apis`](#no-deprecated-apis)
501
+ - [`require-mv3`](#require-mv3)
432
502
  - [`valid-mv3-schema`](#valid-mv3-schema)
433
503
 
434
504
  <div align="center">
@@ -443,7 +513,7 @@ Never (MV3 CSP blocks remote code at runtime).
443
513
 
444
514
  <h3 id="no-unused-permissions">
445
515
  <code>no-unused-permissions</code>
446
- <a title="Only included in recommended + strict configs" href="#usage">
516
+ <a title="Included in recommended + strict configs" href="#usage">
447
517
  <img height=18 alt="[Recommended]" src="https://img.shields.io/badge/Recommended-f1f20a?style=flat-square" align="middle"></a>
448
518
  <a title="Copy rule URL" target="_blank"
449
519
  href="https://copy-to-clipboard.onrender.com/copy/https%3A%2F%2Fcodeberg.org%2Fadamlui%2Feslint-plugin-chrome-extensions%2F%23no-unused-permissions">
@@ -459,10 +529,10 @@ Never (MV3 CSP blocks remote code at runtime).
459
529
  ```diff
460
530
  // manifest.json
461
531
  {
462
- ! "permissions": ["tabs", "downloads"]
532
+ ! "permissions": ["tabs", "downloads"]
463
533
  }
464
534
 
465
- // background.js
535
+ // service-worker.js
466
536
  chrome.tabs.create({ url: 'https://example.com' })
467
537
  ```
468
538
 
@@ -471,11 +541,11 @@ Never (MV3 CSP blocks remote code at runtime).
471
541
  ```diff
472
542
  // manifest.json
473
543
  {
474
- - "permissions": ["tabs", "downloads"]
475
- + "permissions": ["tabs"]
544
+ - "permissions": ["tabs", "downloads"]
545
+ + "permissions": ["tabs"]
476
546
  }
477
547
 
478
- // background.js
548
+ // service-worker.js
479
549
  chrome.tabs.create({ url: 'https://example.com' })
480
550
  ```
481
551
 
@@ -518,23 +588,21 @@ If you access the granted API through an alias or computed property rather than
518
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
519
589
 
520
590
  ```diff
521
- // manifest.json
522
591
  {
523
- ! "manifest_version": 2,
524
- "name": "My Extension",
525
- "version": "1.0.0"
592
+ ! "manifest_version": 2,
593
+ "name": "My Extension",
594
+ "version": "1.0.0"
526
595
  }
527
596
  ```
528
597
 
529
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
530
599
 
531
600
  ```diff
532
- // manifest.json
533
601
  {
534
- - "manifest_version": 2,
535
- + "manifest_version": 3,
536
- "name": "My Extension",
537
- "version": "1.0.0"
602
+ - "manifest_version": 2,
603
+ + "manifest_version": 3,
604
+ "name": "My Extension",
605
+ "version": "1.0.0"
538
606
  }
539
607
  ```
540
608
 
@@ -577,11 +645,11 @@ If you intentionally target MV2 for a Chromium fork that still supports it.
577
645
 
578
646
  ```diff
579
647
  {
580
- "manifest_version": 3,
581
- ! "browser_action": {},
582
- ! "page_action": {},
583
- ! "background": { "scripts": ["bg.js"], "persistent": true },
584
- ! "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'"
585
653
  }
586
654
  ```
587
655
 
@@ -589,14 +657,14 @@ If you intentionally target MV2 for a Chromium fork that still supports it.
589
657
 
590
658
  ```diff
591
659
  {
592
- "manifest_version": 3,
593
- - "browser_action": {},
594
- - "page_action": {},
595
- - "background": { "scripts": ["bg.js"], "persistent": true },
596
- - "content_security_policy": "script-src 'self' 'unsafe-eval'"
597
- + "action": {},
598
- + "background": { "service_worker": "sw.js" },
599
- + "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'" }
600
668
  }
601
669
  ```
602
670
 
@@ -642,7 +710,7 @@ If you intentionally target MV2 for a Chromium fork that still supports it.
642
710
  ! (function init() { chrome.runtime.onMessage.addListener(handler) })()
643
711
  ! setInterval(() => {}, 5000)
644
712
  ! const loc = window.location
645
- ! localStorage.setItem('key', 'val')
713
+ ! localStorage.setItem('key', 'value')
646
714
  ```
647
715
 
648
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
@@ -668,7 +736,7 @@ If your background context isn't a service worker.
668
736
 
669
737
  #### See also
670
738
 
671
- - [`no-content-style-leak`](#no-content-style-leak)
739
+ - [`require-mv3`](#require-mv3)
672
740
  - [`no-remote-code`](#no-remote-code)
673
741
 
674
742
  <div align="center">
@@ -677,11 +745,81 @@ If your background context isn't a service worker.
677
745
 
678
746
  </div>
679
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
+
680
818
  </details>
681
819
 
682
820
  ---
683
821
 
684
- ## <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
685
823
 
686
824
  <a target="_blank" href="https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/#readme">
687
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>
@@ -689,7 +827,7 @@ If your background context isn't a service worker.
689
827
  #### HTML
690
828
 
691
829
  ```html
692
- <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">
693
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>
694
832
  ```
695
833
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-chrome-extensions",
3
- "version": "1.0.0",
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
  }