eslint-plugin-greasemonkey 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,8 @@ ESLint rules for Greasemonkey userscripts.
10
10
 
11
11
  <img src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/c9ac2985bb/assets/images/screenshots/vs-code/squiggles.png">
12
12
 
13
+ ---
14
+
13
15
  ## Installation
14
16
 
15
17
  From your project root:
@@ -18,24 +20,32 @@ From your project root:
18
20
  npm i -D eslint-plugin-greasemonkey
19
21
  ```
20
22
 
23
+ ---
24
+
21
25
  ## Usage
22
26
 
23
- Add `greasemonkey` to your flat config:
27
+ Add `greasemonkey` [rules][epg-rules] to your `eslint.config.*js`:
24
28
 
25
29
  ```js
30
+ ...
26
31
  import greasemonkey from 'eslint-plugin-greasemonkey'
27
32
 
28
33
  export default [
34
+ // other configs
29
35
  {
30
36
  files: ['**/*.user.js'],
31
37
  plugins: { greasemonkey },
32
- rules: {
38
+ rules: { // recommended set w/ default options
33
39
  'greasemonkey/meta-spacing': ['error', 3],
34
40
  'greasemonkey/no-duplicate-keys': 'error',
41
+ 'greasemonkey/no-missing-grants': 'error',
35
42
  'greasemonkey/no-unused-grants': 'error',
36
43
  'greasemonkey/no-unused-resources': 'error',
44
+ 'greasemonkey/prefer-match': 'error',
37
45
  'greasemonkey/require-matching-localized-fields': 'error',
46
+ 'greasemonkey/require-meta-newline': 'error',
38
47
  'greasemonkey/require-required-fields': 'error',
48
+ 'greasemonkey/require-space-before-meta-key': 'error',
39
49
  'greasemonkey/require-user-js-filename': 'error',
40
50
  'greasemonkey/valid-field-values': ['error', { versionType: 'semver' }],
41
51
  'greasemonkey/valid-grants': ['error', { allowedGrants: [] }]
@@ -44,40 +54,54 @@ export default [
44
54
  ]
45
55
  ```
46
56
 
47
- **Note:** Rule config is either a severity string (`'off'`, `'warn'`, `'error'`) or an array `[severity, options]` when the rule accepts options.
48
-
49
- To use the bundled recommended config (enables all 8 rules at `'error'`):
57
+ ...or use a bundled config:
50
58
 
51
59
  ```js
60
+ ...
52
61
  import greasemonkey from 'eslint-plugin-greasemonkey'
53
62
 
54
63
  export default [
64
+ // other configs
55
65
  {
56
66
  files: ['**/*.user.js'],
57
- ...greasemonkey.configs.recommended
67
+ plugins: { greasemonkey }
68
+ rules: {
69
+ ...greasemonkey.configs.recommended.rules, // or .strict.rules
70
+ 'greasemonkey/no-unused-resources': 'off', // selectively disable rule
71
+ 'greasemonkey/meta-spacing': ['error', 2] // selectively tweak rule
72
+ }
58
73
  }
59
74
  ]
60
75
  ```
61
76
 
77
+ **Note:** Rule config is either a severity string (`'off'`, `'warn'`, `'error'`) or an array `[severity, options]` when the rule accepts options.
78
+
79
+ ---
80
+
62
81
  ## Supported rules
63
82
 
64
83
  | ID | Description | Auto-fixable | Recommended |
65
84
  | --- | --- | :-: | :-: |
66
- | [`meta-spacing`](#meta-spacing) | Aligns metadata values to the column after the longest key | ✅ | ✅ |
85
+ | [`meta-spacing`](#meta-spacing-auto-fixable) | Aligns metadata values to the column after the longest key | ✅ | ✅ |
67
86
  | [`no-duplicate-keys`](#no-duplicate-keys) | Disallows duplicate metadata keys | ❌ | ✅ |
87
+ | [`no-missing-grants`](#no-missing-grants) | Flags API calls w/o a matching `@grant` | ❌ | ✅ |
68
88
  | [`no-unused-grants`](#no-unused-grants) | Flags `@grant` entries whose APIs are never used | ❌ | ✅ |
69
89
  | [`no-unused-resources`](#no-unused-resources) | Flags `@resource` entries that are never referenced | ❌ | ✅ |
90
+ | [`prefer-match`](#prefer-match) | Prefers `@match` over `@include` | ❌ | ✅ |
91
+ | [`require-homepage-pair`](#require-homepage-pair-auto-fixable) | Requires `@homepage` and `@homepageURL` to be used together | ✅ | ❌ |
70
92
  | [`require-matching-localized-fields`](#require-matching-localized-fields) | Requires matching localized `@name:xx` / `@description:xx` pairs | ❌ | ✅ |
93
+ | [`require-meta-newline`](#require-meta-newline-auto-fixable) | Requires a newline between the metadata and code | ✅ | ✅ |
71
94
  | [`require-required-fields`](#require-required-fields) | Requires essential metadata fields | ❌ | ✅ |
95
+ | [`require-space-before-meta-key`](#require-space-before-meta-key-auto-fixable) | Requires exactly one space between `//` and metadata keys | ✅ | ✅ |
96
+ | [`require-update-download-pair`](#require-update-download-pair) | Requires `@updateURL` and `@downloadURL` to be used together | ❌ | ❌ |
72
97
  | [`require-user-js-filename`](#require-user-js-filename) | Requires userscript filenames end in `.user.js` | ❌ | ✅ |
73
98
  | [`valid-field-values`](#valid-field-values) | Validates `@version` and URL fields | ❌ | ✅ |
74
99
  | [`valid-grants`](#valid-grants) | Disallows invalid `@grant` values | ❌ | ✅ |
75
-
76
100
  ---
77
101
 
78
- ### `meta-spacing`
102
+ ### `meta-spacing` (auto-fixable)
79
103
 
80
- > Aligns metadata values to the column after the longest key. The longest `@resource` name (if present) is included in the column calculation so resource URLs align with other values.
104
+ > Aligns metadata values to the column after the longest key.
81
105
 
82
106
  #### Options
83
107
 
@@ -153,6 +177,43 @@ If you intentionally repeat single-value keys.
153
177
 
154
178
  ---
155
179
 
180
+ ### `no-missing-grants`
181
+
182
+ > Flags API calls (e.g. `GM_setValue`, `GM.setValue`, `CAT.agent.dom`) used w/o a matching `@grant` declaration.
183
+
184
+ #### Incorrect code
185
+
186
+ ```js
187
+ // ==UserScript==
188
+ // @name My Script
189
+ // ==/UserScript==
190
+
191
+ GM_setValue('key', 'value')
192
+ ```
193
+
194
+ #### Correct code
195
+
196
+ ```js
197
+ // ==UserScript==
198
+ // @name My Script
199
+ // @grant GM_setValue
200
+ // ==/UserScript==
201
+
202
+ GM_setValue('key', 'value')
203
+ ```
204
+
205
+ #### When not to use it
206
+
207
+ If you use APIs through aliases or wrappers that obscure the original call site.
208
+
209
+ <div align="center">
210
+
211
+ [Back to index ↑][epg-rules]
212
+
213
+ </div>
214
+
215
+ ---
216
+
156
217
  ### `no-unused-grants`
157
218
 
158
219
  > Flags `@grant` entries whose APIs are never used in the script.
@@ -179,7 +240,7 @@ GM_setValue('key', 'value')
179
240
 
180
241
  #### When not to use it
181
242
 
182
- If you declare grants for APIs you use indirectly (e.g. via dynamic variable names or `eval`).
243
+ If you access the granted API through an alias or computed property rather than calling it directly.
183
244
 
184
245
  <div align="center">
185
246
 
@@ -213,7 +274,72 @@ GM_getResourceText('myCSS')
213
274
 
214
275
  #### When not to use it
215
276
 
216
- If you reference resources indirectly (e.g., building the resource name dynamically).
277
+ If you pass the resource name through a variable or alias rather than using it literally.
278
+
279
+ <div align="center">
280
+
281
+ [Back to index ↑][epg-rules]
282
+
283
+ </div>
284
+
285
+ ---
286
+
287
+ ### `prefer-match`
288
+
289
+ > Prefers `@match` over `@include` (deprecated in Chrome MV3).
290
+
291
+ #### Incorrect code
292
+
293
+ ```js
294
+ // ==UserScript==
295
+ // @include https://example.com/*
296
+ // ==/UserScript==
297
+ ```
298
+
299
+ #### Correct code
300
+
301
+ ```js
302
+ // ==UserScript==
303
+ // @match https://example.com/*
304
+ // ==/UserScript==
305
+ ```
306
+
307
+ #### When not to use it
308
+
309
+ If you use `@include` in non-MV3 environments, or rely on regex syntax that `@match` cannot express.
310
+
311
+ <div align="center">
312
+
313
+ [Back to index ↑][epg-rules]
314
+
315
+ </div>
316
+
317
+ ---
318
+
319
+ ### `require-homepage-pair` (auto-fixable)
320
+
321
+ > Requires `@homepage` and `@homepageURL` to be used together.
322
+
323
+ #### Incorrect code
324
+
325
+ ```js
326
+ // ==UserScript==
327
+ // @homepage https://example.com
328
+ // ==/UserScript==
329
+ ```
330
+
331
+ #### Correct code
332
+
333
+ ```js
334
+ // ==UserScript==
335
+ // @homepage https://example.com
336
+ // @homepageURL https://example.com
337
+ // ==/UserScript==
338
+ ```
339
+
340
+ #### When not to use it
341
+
342
+ If your target manager does not fall back to `@namespace` when a homepage key is missing (which usually produces a broken URL).
217
343
 
218
344
  <div align="center">
219
345
 
@@ -229,7 +355,7 @@ If you reference resources indirectly (e.g., building the resource name dynamica
229
355
 
230
356
  #### Options
231
357
 
232
- - `ignoreLanguages` (string[], default `[]`) – language codes to skip, e.g. `['fr', 'de']`
358
+ - `ignoreLanguages` (default `[]`) – language codes to skip, e.g. `['fr', 'de']`
233
359
 
234
360
  #### Incorrect code
235
361
 
@@ -264,13 +390,48 @@ If you intentionally provide localized names without descriptions (or vice versa
264
390
 
265
391
  ---
266
392
 
393
+ ### `require-meta-newline` (auto-fixable)
394
+
395
+ > Requires a newline between the closing `// ==/UserScript==` comment and the first line of code.
396
+
397
+ #### Incorrect code
398
+
399
+ ```js
400
+ // ==UserScript==
401
+ // @name My Script
402
+ // ==/UserScript==
403
+ console.log('hi')
404
+ ```
405
+
406
+ #### Correct code
407
+
408
+ ```js
409
+ // ==UserScript==
410
+ // @name My Script
411
+ // ==/UserScript==
412
+
413
+ console.log('hi')
414
+ ```
415
+
416
+ #### When not to use it
417
+
418
+ If you don't care about visual separation between metadata and code.
419
+
420
+ <div align="center">
421
+
422
+ [Back to index ↑][epg-rules]
423
+
424
+ </div>
425
+
426
+ ---
427
+
267
428
  ### `require-required-fields`
268
429
 
269
- > Requires essential metadata fields (`@name`, `@namespace`, `@version`) and at least one `@match` or `@include`.
430
+ > Requires essential metadata fields (`@name`, `@version` by default) and at least one `@match` or `@include`.
270
431
 
271
432
  #### Options
272
433
 
273
- - `fields` (string[], default `['name', 'namespace', 'version']`) – override the required field list
434
+ - `fields` (default `['name', 'version']`) – override the required field list
274
435
 
275
436
  #### Incorrect code
276
437
 
@@ -285,7 +446,6 @@ If you intentionally provide localized names without descriptions (or vice versa
285
446
  ```js
286
447
  // ==UserScript==
287
448
  // @name My Script
288
- // @namespace example.com
289
449
  // @version 1.0.0
290
450
  // @match https://example.com/*
291
451
  // ==/UserScript==
@@ -303,17 +463,17 @@ If you use a subset of metadata fields that doesn't match the convention.
303
463
 
304
464
  ---
305
465
 
306
- ### `require-user-js-filename`
466
+ ### `require-space-before-meta-key` (auto-fixable)
307
467
 
308
- > Requires that files containing a userscript metadata block end in `.user.js`.
468
+ > Requires exactly one space between `//` and metadata keys.
309
469
 
310
470
  #### Incorrect code
311
471
 
312
472
  ```js
313
473
  // ==UserScript==
314
- // @name My Script
474
+ //@name My Script
475
+ // @version 1.0.0
315
476
  // ==/UserScript==
316
- // (file: script.js)
317
477
  ```
318
478
 
319
479
  #### Correct code
@@ -321,12 +481,61 @@ If you use a subset of metadata fields that doesn't match the convention.
321
481
  ```js
322
482
  // ==UserScript==
323
483
  // @name My Script
484
+ // @version 1.0.0
485
+ // ==/UserScript==
486
+ ```
487
+
488
+ #### When not to use it
489
+
490
+ If you intentionally use a different prefix style.
491
+
492
+ <div align="center">
493
+
494
+ [Back to index ↑][epg-rules]
495
+
496
+ </div>
497
+
498
+ ---
499
+
500
+ ### `require-update-download-pair`
501
+
502
+ > Requires `@updateURL` and `@downloadURL` to be used together.
503
+
504
+ #### Incorrect code
505
+
506
+ ```js
507
+ // ==UserScript==
508
+ // @downloadURL https://example.com/script.user.js
509
+ // ==/UserScript==
510
+ ```
511
+
512
+ #### Correct code
513
+
514
+ ```js
515
+ // ==UserScript==
516
+ // @downloadURL https://example.com/script.user.js
517
+ // @updateURL https://example.com/script.meta.js
324
518
  // ==/UserScript==
325
- // (file: script.user.js)
326
519
  ```
327
520
 
328
521
  #### When not to use it
329
522
 
523
+ If you don't target Tampermonkey (where `@updateURL` avoids re-downloading the full script on every update check).
524
+
525
+ <div align="center">
526
+
527
+ [Back to index ↑][epg-rules]
528
+
529
+ </div>
530
+
531
+ ---
532
+
533
+ ### `require-user-js-filename`
534
+
535
+ > Requires that files containing a userscript metadata block end in `.user.js`.
536
+
537
+ #### When not to use it
538
+
330
539
  If you intentionally name your userscripts something other than `*.user.js`.
331
540
 
332
541
  <div align="center">
@@ -343,7 +552,7 @@ If you intentionally name your userscripts something other than `*.user.js`.
343
552
 
344
553
  #### Options
345
554
 
346
- - `versionType` (`'semver'` | `'date'`, default `'semver'`) – version format to enforce
555
+ - `versionType` (default `'semver'`) – version format to enforce (`'semver'`, `'date'`)
347
556
 
348
557
  #### Incorrect code
349
558
 
@@ -381,7 +590,7 @@ If you use a custom versioning scheme or URLs that aren't standard.
381
590
 
382
591
  #### Options
383
592
 
384
- - `allowedGrants` (string[], default `[]`) – additional `@grant` values to allow beyond the built-in list (covering Tampermonkey, Violentmonkey and ScriptCat)
593
+ - `allowedGrants` (default `[]`) – additional `@grant` values to allow beyond the built-in list (covering Tampermonkey, Violentmonkey and ScriptCat)
385
594
 
386
595
  #### Incorrect code
387
596
 
@@ -405,7 +614,7 @@ If you use a custom versioning scheme or URLs that aren't standard.
405
614
 
406
615
  #### When not to use it
407
616
 
408
- If you don't want `@grant` validation at all. For individual unlisted grants, prefer the `allowedGrants` option over disabling the rule.
617
+ If you don't want `@grant` validation at all. For individual uncovered grants, prefer whitelisting via `allowedGrants` over disabling the rule.
409
618
 
410
619
  <div align="center">
411
620
 
@@ -433,15 +642,15 @@ If you don't want `@grant` validation at all. For individual unlisted grants, pr
433
642
  [![Linted by eslint-plugin-greasemonkey](https://img.shields.io/badge/Linted_by-eslint--plugin--greasemonkey-4b32c3?logo=eslint&logoColor=white&labelColor=464646&style=for-the-badge)](https://codeberg.org/adamlui/eslint-plugin-greasemonkey/#readme)
434
643
  ```
435
644
 
436
- ## License
645
+ ---
437
646
 
438
- <details>
647
+ ## License
439
648
 
440
- <summary>
649
+ [<img height=62 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/9e820e5d51/assets/images/logos/mit-license/red-pixelated.png">][epg-license]
441
650
 
442
- Copyright © 2026 [Adam Lui][adamlui-cb] under the [MIT license](./docs/LICENSE.md).
651
+ <details>
443
652
 
444
- </summary><br>
653
+ <summary>Copyright © 2026 <a href="https://codeberg.org/adamlui">Adam Lui</a> under the <a href="https://codeberg.org/adamlui/eslint-plugin-greasemonkey/src/branch/main/docs/LICENSE.md">MIT license</a>.</summary><br>
445
654
 
446
655
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
447
656
 
@@ -451,6 +660,8 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
451
660
 
452
661
  </details>
453
662
 
663
+ ---
664
+
454
665
  ## Related
455
666
 
456
667
  <!-- eslint-plugin-constructors -->
@@ -463,7 +674,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
463
674
 
464
675
  > [<img width=555 src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-constructors/6eeba7b2cb/assets/images/screenshots/vs-code/squiggles.png">][epc-readme]
465
676
 
466
- > [**Install**][epc-install] / [Readme][epc-readme] / [Rules][epc-rules]
677
+ > [**Install**][epc-install] / [Readme][epc-readme] / [Supported rules][epc-rules]
467
678
 
468
679
  </details>
469
680
 
@@ -474,19 +685,19 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
474
685
  <!-- Footer -->
475
686
 
476
687
  <img height=10 width="100%" src="https://cdn.staticdelivr.com/gl/adamlui/ai-web-extensions/d11d2ee/assets/images/separators/gradient-aqua.png">
477
- <br><br>
478
688
 
479
689
  <div align="center">
480
690
 
691
+ <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.staticdelivr.com/gl/adamlui/js-utils/6b0d399/assets/images/icons/tag/white/icon16.svg"><img height=14 src="https://cdn.staticdelivr.com/gl/adamlui/js-utils/6b0d399/assets/images/icons/tag/dark-gray/icon16.svg"></picture>
481
692
  [**Latest releases**][epg-releases] / [Report a bug][epg-bugs] / [More packages by author][adamlui-npmx] / [Back to top ↑](#top)
482
693
 
483
694
  </div>
484
695
 
485
696
  <!-- Links -->
486
697
 
487
- [adamlui-cb]: https://codeberg.org/adamlui
488
698
  [adamlui-npmx]: https://npmx.dev/org/adamlui
489
699
  [epg-bugs]: https://codeberg.org/adamlui/eslint-plugin-greasemonkey/issues/new
700
+ [epg-license]: https://codeberg.org/adamlui/eslint-plugin-constructors/src/branch/main/docs/LICENSE.md
490
701
  [epg-npm]: https://www.npmjs.com/package/eslint-plugin-greasemonkey
491
702
  [epg-releases]: https://codeberg.org/adamlui/eslint-plugin-greasemonkey/releases
492
703
  [epg-rules]: #supported-rules
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-greasemonkey",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "ESLint rules for Greasemonkey/Tampermonkey userscripts",
5
5
  "type": "module",
6
6
  "engines": {
@@ -40,7 +40,7 @@
40
40
  "lint:fix": "eslint . --fix --cache",
41
41
  "lint:fix-all": "eslint . --fix",
42
42
  "test": "node tests/run.js",
43
- "demo": "eslint --config demo/eslint.config.js demo/example.user.js",
43
+ "demo": "node demo/run.js",
44
44
  "bump:patch": "bash utils/bump.sh patch",
45
45
  "bump:minor": "bash utils/bump.sh minor",
46
46
  "bump:feat": "npm run bump:minor",
@@ -69,7 +69,7 @@
69
69
  "@eslint/markdown": "^8.0.3",
70
70
  "@stylistic/eslint-plugin": "^5.10.0",
71
71
  "eslint": "^10.10.0",
72
- "eslint-plugin-constructors": "^1.0.3",
72
+ "eslint-plugin-constructors": "^1.1.1",
73
73
  "eslint-plugin-eslint-plugin": "^7.6.2",
74
74
  "eslint-plugin-import-x": "^4.17.1",
75
75
  "eslint-plugin-n": "^18.3.0",
package/src/apis.js ADDED
@@ -0,0 +1,20 @@
1
+ export const gmLegacyAPIs = new Set([ /* eslint-disable no-secrets/no-secrets */
2
+ 'GM_addElement', 'GM_addStyle', 'GM_addValueChangeListener', 'GM_deleteValue', 'GM_download',
3
+ 'GM_getResourceText', 'GM_getResourceURL', 'GM_getTab', 'GM_getTabs', 'GM_getValue', 'GM_getValues',
4
+ 'GM_info', 'GM_listValues', 'GM_log', 'GM_notification', 'GM_openInTab', 'GM_registerMenuCommand',
5
+ 'GM_removeValueChangeListener', 'GM_saveTab', 'GM_setClipboard', 'GM_setValue', 'GM_setValues',
6
+ 'GM_unregisterMenuCommand', 'GM_webRequest', 'GM_xmlhttpRequest'
7
+ ]) /* eslint-enable no-secrets/no-secrets */
8
+
9
+ export const gmModernAPIs = new Set([
10
+ 'GM.addElement', 'GM.addStyle', 'GM.addValueChangeListener', 'GM.deleteValue', 'GM.download',
11
+ 'GM.getResourceText', 'GM.getResourceUrl', 'GM.getValue', 'GM.info', 'GM.listValues', 'GM.log',
12
+ 'GM.notification', 'GM.openInTab', 'GM.registerMenuCommand', 'GM.removeValueChangeListener',
13
+ 'GM.setClipboard', 'GM.setValue', 'GM.unregisterMenuCommand', 'GM.xmlHttpRequest'
14
+ ])
15
+
16
+ export const scAPIs = new Set([
17
+ 'CAT.agent.conversation', 'CAT.agent.dom', 'CAT.agent.mcp', 'CAT.agent.model',
18
+ 'CAT.agent.opfs', 'CAT.agent.skills', 'CAT.agent.task',
19
+ 'CAT_fileStorage', 'CAT_scriptLoaded', 'CAT_userConfig'
20
+ ])
package/src/index.js CHANGED
@@ -1,9 +1,15 @@
1
1
  import metaSpacing from './rules/meta-spacing.js'
2
2
  import noDuplicateKeys from './rules/no-duplicate-keys.js'
3
+ import noMissingGrants from './rules/no-missing-grants.js'
3
4
  import noUnusedGrants from './rules/no-unused-grants.js'
4
5
  import noUnusedResources from './rules/no-unused-resources.js'
6
+ import preferMatch from './rules/prefer-match.js'
7
+ import requireHomepagePair from './rules/require-homepage-pair.js'
5
8
  import requireMatchingLocalizedFields from './rules/require-matching-localized-fields.js'
9
+ import requireMetaNewline from './rules/require-meta-newline.js'
6
10
  import requireRequiredFields from './rules/require-required-fields.js'
11
+ import requireSpaceBeforeMetaKey from './rules/require-space-before-meta-key.js'
12
+ import requireUpdateDownloadPair from './rules/require-update-download-pair.js'
7
13
  import requireUserJsFilename from './rules/require-user-js-filename.js'
8
14
  import validFieldValues from './rules/valid-field-values.js'
9
15
  import validGrants from './rules/valid-grants.js'
@@ -12,10 +18,16 @@ const plugin = {
12
18
  rules: {
13
19
  'meta-spacing': metaSpacing,
14
20
  'no-duplicate-keys': noDuplicateKeys,
21
+ 'no-missing-grants': noMissingGrants,
15
22
  'no-unused-grants': noUnusedGrants,
16
23
  'no-unused-resources': noUnusedResources,
24
+ 'prefer-match': preferMatch,
25
+ 'require-homepage-pair': requireHomepagePair,
17
26
  'require-matching-localized-fields': requireMatchingLocalizedFields,
27
+ 'require-meta-newline': requireMetaNewline,
18
28
  'require-required-fields': requireRequiredFields,
29
+ 'require-space-before-meta-key': requireSpaceBeforeMetaKey,
30
+ 'require-update-download-pair': requireUpdateDownloadPair,
19
31
  'require-user-js-filename': requireUserJsFilename,
20
32
  'valid-field-values': validFieldValues,
21
33
  'valid-grants': validGrants
@@ -28,10 +40,34 @@ plugin.configs = {
28
40
  rules: {
29
41
  'greasemonkey/meta-spacing': 'error',
30
42
  'greasemonkey/no-duplicate-keys': 'error',
43
+ 'greasemonkey/no-missing-grants': 'error',
31
44
  'greasemonkey/no-unused-grants': 'error',
32
45
  'greasemonkey/no-unused-resources': 'error',
46
+ 'greasemonkey/prefer-match': 'error',
33
47
  'greasemonkey/require-matching-localized-fields': 'error',
48
+ 'greasemonkey/require-meta-newline': 'error',
34
49
  'greasemonkey/require-required-fields': 'error',
50
+ 'greasemonkey/require-space-before-meta-key': 'error',
51
+ 'greasemonkey/require-user-js-filename': 'error',
52
+ 'greasemonkey/valid-field-values': 'error',
53
+ 'greasemonkey/valid-grants': 'error'
54
+ }
55
+ },
56
+ strict: {
57
+ plugins: { greasemonkey: plugin },
58
+ rules: {
59
+ 'greasemonkey/meta-spacing': 'error',
60
+ 'greasemonkey/no-duplicate-keys': 'error',
61
+ 'greasemonkey/no-missing-grants': 'error',
62
+ 'greasemonkey/no-unused-grants': 'error',
63
+ 'greasemonkey/no-unused-resources': 'error',
64
+ 'greasemonkey/prefer-match': 'error',
65
+ 'greasemonkey/require-homepage-pair': 'error',
66
+ 'greasemonkey/require-matching-localized-fields': 'error',
67
+ 'greasemonkey/require-meta-newline': 'error',
68
+ 'greasemonkey/require-required-fields': 'error',
69
+ 'greasemonkey/require-space-before-meta-key': 'error',
70
+ 'greasemonkey/require-update-download-pair': 'error',
35
71
  'greasemonkey/require-user-js-filename': 'error',
36
72
  'greasemonkey/valid-field-values': 'error',
37
73
  'greasemonkey/valid-grants': 'error'
@@ -39,4 +75,4 @@ plugin.configs = {
39
75
  }
40
76
  }
41
77
 
42
- export default plugin
78
+ export { plugin as default, plugin as greasemonkey }
@@ -1,6 +1,6 @@
1
1
  import { extractMetadata } from '../utils.js'
2
2
 
3
- const MULTI_VALUE_KEYS = new Set([
3
+ const multiValKeys = new Set([
4
4
  'compatible', 'connect', 'description', 'exclude', 'exclude-match', 'grant',
5
5
  'homepageURL', 'include', 'license', 'match', 'require', 'resource', 'supportURL'
6
6
  ])
@@ -24,7 +24,7 @@ export default {
24
24
  seen = new Set()
25
25
  for (const entry of metaEntries) {
26
26
  const baseKey = entry.key.split(':')[0]
27
- if (!entry.key.includes(':') && MULTI_VALUE_KEYS.has(baseKey)) continue
27
+ if (!entry.key.includes(':') && multiValKeys.has(baseKey)) continue
28
28
  if (seen.has(entry.key))
29
29
  context.report({ node: entry.comment, messageId: 'dupeKey', data: { key: entry.key }})
30
30
  else seen.add(entry.key)
@@ -0,0 +1,51 @@
1
+ import { extractMetadata } from '../utils.js'
2
+ import { gmLegacyAPIs, gmModernAPIs, scAPIs } from '../apis.js'
3
+
4
+ const apis = new Set([...gmLegacyAPIs, ...gmModernAPIs, ...scAPIs]),
5
+ grantlessAPIs = new Set(['GM_info', 'GM.info']) // always available, no grant required
6
+
7
+ export default {
8
+ meta: {
9
+ type: 'problem',
10
+ docs: {
11
+ description: 'Disallow API calls without a matching @grant',
12
+ category: 'Possible Errors',
13
+ recommended: true
14
+ },
15
+ schema: [],
16
+ messages: { missingGrant: 'Missing @grant: "{{api}}"' }
17
+ },
18
+
19
+ create(context) {
20
+ const usedAPIs = new Map()
21
+ function record(name, node) { if (apis.has(name) && !usedAPIs.has(name)) usedAPIs.set(name, node) }
22
+ return {
23
+ Identifier(node) { record(node.name, node) },
24
+ MemberExpression(node) {
25
+ const path = []
26
+ let current = node
27
+ while (current.type == 'MemberExpression') {
28
+ if (current.property.type != 'Identifier') return
29
+ path.unshift(current.property.name)
30
+ current = current.object
31
+ }
32
+ if (current.type == 'Identifier') {
33
+ path.unshift(current.name)
34
+ record(path.join('.'), node)
35
+ }
36
+ },
37
+ 'Program:exit'() {
38
+ if (!usedAPIs.size) return
39
+ const declared = new Set(
40
+ extractMetadata(context.sourceCode)
41
+ .filter(entry => entry.key == 'grant')
42
+ .map(entry => entry.value.trim())
43
+ )
44
+ for (const [api, node] of usedAPIs) {
45
+ if (grantlessAPIs.has(api) || declared.has(api)) continue
46
+ context.report({ node, messageId: 'missingGrant', data: { api }})
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
@@ -13,18 +13,30 @@ export default {
13
13
  },
14
14
 
15
15
  create(context) {
16
- const usedIdentifiers = new Set()
16
+ const usedGrants = new Set()
17
17
  return {
18
- Identifier(node) { usedIdentifiers.add(node.name) },
18
+ Identifier(node) { usedGrants.add(node.name) },
19
+ MemberExpression(node) {
20
+ const path = [] ; let current = node
21
+ while (current.type == 'MemberExpression') {
22
+ if (current.property.type != 'Identifier') return
23
+ path.unshift(current.property.name)
24
+ current = current.object
25
+ }
26
+ if (current.type == 'Identifier') {
27
+ path.unshift(current.name)
28
+ usedGrants.add(path.join('.'))
29
+ }
30
+ },
19
31
  'Program:exit'() {
20
32
  const sourceCode = context.sourceCode,
21
33
  metaEntries = extractMetadata(sourceCode),
22
- grants = metaEntries.filter(e => e.key == 'grant')
34
+ grants = metaEntries.filter(entry => entry.key == 'grant')
23
35
  if (!grants.length) return
24
36
  for (const grant of grants) {
25
37
  const grantName = grant.value.trim()
26
38
  if (['none', 'unsafeWindow'].includes(grantName)) continue
27
- if (!usedIdentifiers.has(grantName))
39
+ if (!usedGrants.has(grantName))
28
40
  context.report({ node: grant.comment, messageId: 'unusedGrant', data: { grant: grantName }})
29
41
  }
30
42
  }
@@ -1,6 +1,7 @@
1
- import { extractMetadata } from '../utils.js'
1
+ import { extractMetadata, resolveTemplate } from '../utils.js'
2
2
 
3
- const re = { resourceGetter: /GM_getResource(?:Text|URL)\s*\(\s*['"]([^'"]+)['"]\s*\)/g, whitespace: /\s+/ }
3
+ const resourceGetterNames = new Set(['GM_getResourceText', 'GM_getResourceURL']),
4
+ re = { specialChars: /[.*+?^${}()|[\]\\]/g, whitespace: /\s+/ }
4
5
 
5
6
  export default {
6
7
  meta: {
@@ -15,20 +16,30 @@ export default {
15
16
  },
16
17
 
17
18
  create(context) {
19
+ const usedResources = new Set(),
20
+ usedPatterns = []
18
21
  return {
19
- Program() {
20
- const sourceCode = context.sourceCode,
21
- metaEntries = extractMetadata(sourceCode),
22
- resources = metaEntries.filter(e => e.key == 'resource')
22
+ CallExpression(node) {
23
+ if (!resourceGetterNames.has(node.callee.name)) return
24
+ const arg = node.arguments[0]
25
+ if (arg?.type == 'Literal' && typeof arg.value == 'string')
26
+ usedResources.add(arg.value)
27
+ else if (arg?.type == 'TemplateLiteral') {
28
+ const resolved = resolveTemplate(arg)
29
+ if (resolved)
30
+ for (const name of resolved) usedResources.add(name)
31
+ else usedPatterns.push(new RegExp( // eslint-disable-line security/detect-non-literal-regexp
32
+ `^${arg.quasis.map(quasi => quasi.value.raw.replace(re.specialChars, '\\$&')).join('.*')}$`))
33
+ }
34
+ },
35
+ 'Program:exit'() {
36
+ const metaEntries = extractMetadata(context.sourceCode),
37
+ resources = metaEntries.filter(entry => entry.key == 'resource')
23
38
  if (!resources.length) return
24
- const text = sourceCode.getText(),
25
- usedResources = new Set()
26
- let match
27
- while ((match = re.resourceGetter.exec(text)) !== null) usedResources.add(match[1])
28
39
  for (const res of resources) {
29
- const name = res.value.split(re.whitespace)[0]
30
- if (!usedResources.has(name))
31
- context.report({ node: res.comment, messageId: 'unusedResource', data: { name }})
40
+ const name = res.value.trim().split(re.whitespace)[0]
41
+ if (usedResources.has(name) || usedPatterns.some(patt => patt.test(name))) continue
42
+ context.report({ node: res.comment, messageId: 'unusedResource', data: { name }})
32
43
  }
33
44
  }
34
45
  }
@@ -0,0 +1,24 @@
1
+ import { extractMetadata } from '../utils.js'
2
+
3
+ export default {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: {
7
+ description: 'Prefer @match over @include (see https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline)',
8
+ category: 'Best Practices',
9
+ recommended: true
10
+ },
11
+ schema: [],
12
+ messages: { preferMatch: 'Use @match instead of @include (deprecated in Chrome MV3)' }
13
+ },
14
+
15
+ create(context) {
16
+ return {
17
+ Program() {
18
+ for (const entry of extractMetadata(context.sourceCode))
19
+ if (entry.key == 'include')
20
+ context.report({ node: entry.comment, messageId: 'preferMatch' })
21
+ }
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,43 @@
1
+ import { extractMetadata } from '../utils.js'
2
+
3
+ export default {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: {
7
+ description: 'Require @homepage and @homepageURL to be used together',
8
+ category: 'Best Practices',
9
+ recommended: false
10
+ },
11
+ fixable: 'code',
12
+ schema: [],
13
+ messages: { missingPair: 'Missing @{{missing}} (required when @{{present}} is used)' }
14
+ },
15
+
16
+ create(context) {
17
+ return {
18
+ Program() {
19
+ const metaEntries = extractMetadata(context.sourceCode),
20
+ homepageEntry = metaEntries.find(entry => entry.key == 'homepage'),
21
+ homepageURLEntry = metaEntries.find(entry => entry.key == 'homepageURL')
22
+ if (homepageEntry && !homepageURLEntry)
23
+ context.report({
24
+ node: homepageEntry.comment, messageId: 'missingPair',
25
+ data: { missing: 'homepageURL', present: 'homepage' },
26
+ fix(fixer) {
27
+ return fixer.insertTextAfter(homepageEntry.comment,
28
+ `\n// @homepageURL ${homepageEntry.value}`)
29
+ }
30
+ })
31
+ else if (homepageURLEntry && !homepageEntry)
32
+ context.report({
33
+ node: homepageURLEntry.comment, messageId: 'missingPair',
34
+ data: { missing: 'homepage', present: 'homepageURL' },
35
+ fix(fixer) {
36
+ return fixer.insertTextAfter(homepageURLEntry.comment,
37
+ `\n// @homepage ${homepageURLEntry.value}`)
38
+ }
39
+ })
40
+ }
41
+ }
42
+ }
43
+ }
@@ -14,7 +14,7 @@ export default {
14
14
  ignoreLanguages: {
15
15
  type: 'array',
16
16
  items: { type: 'string' },
17
- description: 'Language codes to ignore (e.g., ["fr", "de"])'
17
+ description: 'Language codes to ignore (e.g. ["fr", "de"])'
18
18
  }
19
19
  },
20
20
  additionalProperties: false
@@ -29,10 +29,10 @@ export default {
29
29
  return {
30
30
  Program() {
31
31
  const metaEntries = extractMetadata(context.sourceCode),
32
- nameEntries = metaEntries.filter(e => e.key.startsWith('name:')),
33
- descEntries = metaEntries.filter(e => e.key.startsWith('description:')),
34
- nameLangs = new Set(nameEntries.map(e => e.key.split(':')[1])),
35
- descLangs = new Set(descEntries.map(e => e.key.split(':')[1]))
32
+ nameEntries = metaEntries.filter(entry => entry.key.startsWith('name:')),
33
+ descEntries = metaEntries.filter(entry => entry.key.startsWith('description:')),
34
+ nameLangs = new Set(nameEntries.map(entry => entry.key.split(':')[1])),
35
+ descLangs = new Set(descEntries.map(entry => entry.key.split(':')[1]))
36
36
  for (const lang of nameLangs)
37
37
  if (!ignoreLanguages.includes(lang) && !descLangs.has(lang))
38
38
  context.report({
@@ -43,7 +43,7 @@ export default {
43
43
  for (const lang of descLangs)
44
44
  if (!ignoreLanguages.includes(lang) && !nameLangs.has(lang))
45
45
  context.report({
46
- node: descEntries.find(e => e.key == `description:${lang}`).comment,
46
+ node: descEntries.find(entry => entry.key == `description:${lang}`).comment,
47
47
  messageId: 'missingPair',
48
48
  data: { missing: `name:${lang}`, present: `description:${lang}` }
49
49
  })
@@ -0,0 +1,36 @@
1
+ import { extractMetadata } from '../utils.js'
2
+
3
+ export default {
4
+ meta: {
5
+ type: 'layout',
6
+ docs: {
7
+ description: 'Require a newline between the metadata and code',
8
+ category: 'Stylistic Issues',
9
+ recommended: true
10
+ },
11
+ fixable: 'whitespace',
12
+ schema: [],
13
+ messages: { missingNewline: 'Missing blank line after metadata block' }
14
+ },
15
+
16
+ create(context) {
17
+ return {
18
+ Program() {
19
+ const sourceCode = context.sourceCode,
20
+ comments = sourceCode.getAllComments(),
21
+ metaEntries = extractMetadata(sourceCode)
22
+ if (!metaEntries.length) return
23
+ const closingComment = comments.find(comment => comment.value.trim() == '==/UserScript==')
24
+ if (!closingComment) return
25
+ const tokenAfter = sourceCode.getTokenAfter(closingComment)
26
+ if (!tokenAfter) return
27
+ if (tokenAfter.loc.start.line - closingComment.loc.end.line < 2)
28
+ context.report({
29
+ node: closingComment,
30
+ messageId: 'missingNewline',
31
+ fix(fixer) { return fixer.insertTextAfter(closingComment, '\n') }
32
+ })
33
+ }
34
+ }
35
+ }
36
+ }
@@ -14,7 +14,7 @@ export default {
14
14
  fields: {
15
15
  type: 'array',
16
16
  items: { type: 'string' },
17
- description: 'Required metadata fields (default: name, namespace, version)'
17
+ description: 'Required metadata fields (default: name, version)'
18
18
  }
19
19
  },
20
20
  additionalProperties: false
@@ -25,11 +25,11 @@ export default {
25
25
 
26
26
  create(context) {
27
27
  const options = context.options[0] || {},
28
- requiredFields = options.fields || ['name', 'namespace', 'version']
28
+ requiredFields = options.fields || ['name', 'version']
29
29
  return {
30
30
  Program(node) {
31
31
  const metaEntries = extractMetadata(context.sourceCode),
32
- keys = new Set(metaEntries.map(e => e.key))
32
+ keys = new Set(metaEntries.map(entry => entry.key))
33
33
  for (const field of requiredFields)
34
34
  if (!keys.has(field))
35
35
  context.report({ node, messageId: 'missingField', data: { field }})
@@ -0,0 +1,39 @@
1
+ import { extractMetadata } from '../utils.js'
2
+
3
+ const re = { leadingWhitespace: /^\s*/ }
4
+
5
+ export default {
6
+ meta: {
7
+ type: 'layout',
8
+ docs: {
9
+ description: 'Require exactly one space between // and metadata keys',
10
+ category: 'Stylistic Issues',
11
+ recommended: true
12
+ },
13
+ fixable: 'whitespace',
14
+ schema: [],
15
+ messages: { badPrefix: '`{{actual}}` should be `// @{{key}}`' }
16
+ },
17
+
18
+ create(context) {
19
+ return {
20
+ Program() {
21
+ const sourceCode = context.sourceCode,
22
+ metaEntries = extractMetadata(sourceCode)
23
+ for (const entry of metaEntries) {
24
+ const commentText = sourceCode.getText(entry.comment),
25
+ afterSlashes = commentText.slice(2)
26
+ if (afterSlashes.startsWith(' @')) continue
27
+ const rest = afterSlashes.replace(re.leadingWhitespace, ''),
28
+ keyIdx = commentText.indexOf(`@${entry.key}`),
29
+ actual = commentText.slice(0, keyIdx +1 + entry.key.length),
30
+ fixedText = `// ${rest}`
31
+ context.report({
32
+ node: entry.comment, messageId: 'badPrefix', data: { actual, key: entry.key },
33
+ fix(fixer) { return fixer.replaceText(entry.comment, fixedText) }
34
+ })
35
+ }
36
+ }
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,32 @@
1
+ import { extractMetadata } from '../utils.js'
2
+
3
+ export default {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: {
7
+ description: 'Require @updateURL and @downloadURL to be used together',
8
+ category: 'Best Practices',
9
+ recommended: false
10
+ },
11
+ schema: [],
12
+ messages: { missingPair: 'Missing @{{missing}} (required when @{{present}} is used)' }
13
+ },
14
+
15
+ create(context) {
16
+ return {
17
+ Program() {
18
+ const metaEntries = extractMetadata(context.sourceCode),
19
+ updateEntry = metaEntries.find(entry => entry.key == 'updateURL'),
20
+ downloadEntry = metaEntries.find(entry => entry.key == 'downloadURL')
21
+ if (updateEntry && !downloadEntry)
22
+ context.report({
23
+ node: updateEntry.comment, messageId: 'missingPair',
24
+ data: { missing: 'downloadURL', present: 'updateURL' }})
25
+ else if (downloadEntry && !updateEntry)
26
+ context.report({
27
+ node: downloadEntry.comment, messageId: 'missingPair',
28
+ data: { missing: 'updateURL', present: 'downloadURL' }})
29
+ }
30
+ }
31
+ }
32
+ }
@@ -9,7 +9,7 @@ export default {
9
9
  recommended: true
10
10
  },
11
11
  schema: [],
12
- messages: { requireUserJs: 'Userscript filename should end in ".user.js"' }
12
+ messages: { requireUserJS: 'Userscript filename should end in ".user.js"' }
13
13
  },
14
14
 
15
15
  create(context) {
@@ -18,7 +18,7 @@ export default {
18
18
  const metaEntries = extractMetadata(context.sourceCode)
19
19
  if (!metaEntries.length) return
20
20
  if (!context.filename.endsWith('.user.js'))
21
- context.report({ node, messageId: 'requireUserJs' })
21
+ context.report({ node, messageId: 'requireUserJS' })
22
22
  }
23
23
  }
24
24
  }
@@ -3,10 +3,11 @@ import { extractMetadata } from '../utils.js'
3
3
  const re = { /* eslint-disable security/detect-unsafe-regex */
4
4
  semVer: /^\d+\.\d+\.\d+(?:-[\da-z.-]+)?(?:\+[\da-z.-]+)?$/i,
5
5
  dateVer: /^\d{4}\.(?:0?[1-9]|1[0-2])\.(?:0?[1-9]|[12]\d|3[01])(?:\.\d+)?$/,
6
- urlSuffix: /url$/i,
7
6
  whitespace: /\s+/
8
7
  } /* eslint-enable security/detect-unsafe-regex */
9
8
 
9
+ const urlKeys = ['homepage', 'icon', 'icon64', 'require', 'source', 'website']
10
+
10
11
  export default {
11
12
  meta: {
12
13
  type: 'problem',
@@ -42,36 +43,33 @@ export default {
42
43
  create(context) {
43
44
  const options = context.options[0] || {},
44
45
  versionType = options.versionType || 'semver',
45
- allowedGrants = options.allowedGrants || []
46
- const example = versionType == 'semver' ? '1.2.3' : 'YYYY.M.D or YYYY.MM.DD'
46
+ allowedGrants = options.allowedGrants || [],
47
+ example = versionType == 'semver' ? '1.2.3' : 'YYYY.M.D or YYYY.MM.DD'
47
48
  return {
48
49
  Program() {
49
50
  const metaEntries = extractMetadata(context.sourceCode)
50
51
  for (const entry of metaEntries) {
51
- if (entry.key == 'version') {
52
- const isValid = versionType == 'semver' ? re.semVer.test(entry.value)
53
- : versionType == 'date' ? re.dateVer.test(entry.value)
54
- : false
55
- if (!isValid)
56
- context.report({
57
- node: entry.comment, messageId: 'invalidVersion', data: { versionType, example }})
58
- }
59
- if (['homepage', 'icon', 'icon64', 'require', 'source', 'website'].includes(entry.key)
60
- || re.urlSuffix.test(entry.key)
61
- )
62
- try { new URL(entry.value) }
52
+ if (entry.key == 'grant' && allowedGrants.length && !allowedGrants.includes(entry.value))
53
+ context.report({ node: entry.comment, messageId: 'invalidGrant', data: { grant: entry.value }})
54
+ else if (entry.key == 'resource') {
55
+ const url = entry.value.trim().split(re.whitespace)[1]
56
+ if (url) try { new URL(url) }
63
57
  catch {
64
58
  context.report({ node: entry.comment, messageId: 'invalidURL', data: { field: entry.key }})
65
59
  }
66
- if (entry.key == 'resource') {
67
- const url = entry.value.trim().split(re.whitespace)[1]
68
- if (url) try { new URL(url) }
60
+ } else if (urlKeys.includes(entry.key) || entry.key.toLowerCase().endsWith('url'))
61
+ try { new URL(entry.value) }
69
62
  catch {
70
63
  context.report({ node: entry.comment, messageId: 'invalidURL', data: { field: entry.key }})
71
64
  }
65
+ else if (entry.key == 'version') {
66
+ const isValid = versionType == 'semver' ? re.semVer.test(entry.value)
67
+ : versionType == 'date' ? re.dateVer.test(entry.value)
68
+ : false
69
+ if (!isValid)
70
+ context.report({
71
+ node: entry.comment, messageId: 'invalidVersion', data: { versionType, example }})
72
72
  }
73
- if (entry.key == 'grant' && allowedGrants.length && !allowedGrants.includes(entry.value))
74
- context.report({ node: entry.comment, messageId: 'invalidGrant', data: { grant: entry.value }})
75
73
  }
76
74
  }
77
75
  }
@@ -1,31 +1,11 @@
1
1
  import { extractMetadata } from '../utils.js'
2
-
3
- const gm_legacy_apis = new Set([ /* eslint-disable no-secrets/no-secrets */
4
- 'GM_addElement', 'GM_addStyle', 'GM_addValueChangeListener', 'GM_deleteValue', 'GM_download',
5
- 'GM_getResourceText', 'GM_getResourceURL', 'GM_getTab', 'GM_getTabs', 'GM_getValue', 'GM_getValues',
6
- 'GM_info', 'GM_listValues', 'GM_log', 'GM_notification', 'GM_openInTab', 'GM_registerMenuCommand',
7
- 'GM_removeValueChangeListener', 'GM_saveTab', 'GM_setClipboard', 'GM_setValue', 'GM_setValues',
8
- 'GM_unregisterMenuCommand', 'GM_webRequest', 'GM_xmlhttpRequest'
9
- ]) /* eslint-enable no-secrets/no-secrets */
10
-
11
- const gm_modern_apis = new Set([
12
- 'GM.addElement', 'GM.addStyle', 'GM.addValueChangeListener', 'GM.deleteValue', 'GM.download',
13
- 'GM.getResourceText', 'GM.getResourceUrl', 'GM.getValue', 'GM.info', 'GM.listValues', 'GM.log',
14
- 'GM.notification', 'GM.openInTab', 'GM.registerMenuCommand', 'GM.removeValueChangeListener',
15
- 'GM.setClipboard', 'GM.setValue', 'GM.unregisterMenuCommand', 'GM.xmlHttpRequest'
16
- ])
17
-
18
- const sc_apis = new Set([
19
- 'CAT.agent.conversation', 'CAT.agent.dom', 'CAT.agent.mcp', 'CAT.agent.model',
20
- 'CAT.agent.opfs', 'CAT.agent.skills', 'CAT.agent.task',
21
- 'CAT_fileStorage', 'CAT_scriptLoaded', 'CAT_userConfig'
22
- ])
2
+ import { gmLegacyAPIs, gmModernAPIs, scAPIs } from '../apis.js'
23
3
 
24
4
  const specialGrants = new Set([
25
5
  'none', 'unsafeWindow', 'window.close', 'window.focus', 'window.onurlchange'
26
6
  ])
27
7
 
28
- const grants = new Set([...gm_legacy_apis, ...gm_modern_apis, ...sc_apis, ...specialGrants]),
8
+ const grants = new Set([...gmLegacyAPIs, ...gmModernAPIs, ...scAPIs, ...specialGrants]),
29
9
  sortedGrants = new Map(Array.from(grants, grant => [grant.toLowerCase(), grant]))
30
10
 
31
11
  export default {
@@ -60,18 +40,15 @@ export default {
60
40
  return {
61
41
  Program() {
62
42
  const metaEntries = extractMetadata(context.sourceCode),
63
- grantEntries = metaEntries.filter(e => e.key == 'grant')
43
+ grantEntries = metaEntries.filter(entry => entry.key == 'grant')
64
44
  for (const grant of grantEntries) {
65
45
  const grantName = grant.value.trim()
66
46
  if (grants.has(grantName) || allowedGrants.includes(grantName)) continue
67
47
  const suggestion = sortedGrants.get(grantName.toLowerCase())
68
- if (suggestion)
69
- context.report({
70
- node: grant.comment, messageId: 'wrongCase',
71
- data: { grant: grantName, suggestion }})
72
- else
73
- context.report({
74
- node: grant.comment, messageId: 'invalidGrant', data: { grant: grantName }})
48
+ context.report(suggestion
49
+ ? { node: grant.comment, messageId: 'wrongCase', data: { grant: grantName, suggestion }}
50
+ : { node: grant.comment, messageId: 'invalidGrant', data: { grant: grantName }}
51
+ )
75
52
  }
76
53
  }
77
54
  }
package/src/utils.js CHANGED
@@ -4,7 +4,7 @@ export function extractMetadata(sourceCode) {
4
4
  let inMetaBlock
5
5
  for (const comment of comments) {
6
6
  const text = comment.value.trim()
7
- if (!inMetaBlock && text == '==UserScript==') { inMetaBlock = true ; continue }
7
+ if (!inMetaBlock && text == '==UserScript==') inMetaBlock = true
8
8
  else if (inMetaBlock && text == '==/UserScript==') break
9
9
  else if (inMetaBlock && text.startsWith('@')) { // capture entry
10
10
  const spaceIdx = text.indexOf(' ')
@@ -17,3 +17,22 @@ export function extractMetadata(sourceCode) {
17
17
  }
18
18
  return metaEntries
19
19
  }
20
+
21
+ export function resolveTemplate(node) {
22
+ if (node.type == 'ConditionalExpression') {
23
+ const branches = [node.consequent, node.alternate].map(resolveTemplate)
24
+ return branches.every(Boolean) ? branches.flat() : null
25
+ } else if (node.type == 'Literal' && ['number', 'string'].includes(typeof node.value))
26
+ return [String(node.value)]
27
+ else if (node.type == 'TemplateLiteral') // build every combo of quasis + resolved expressions
28
+ return node.quasis.reduce((combos, quasi, idx) => {
29
+ if (!combos) return null
30
+ const withQuasi = combos.map(combo => combo + quasi.value.raw)
31
+ if (idx >= node.expressions.length) return withQuasi
32
+ const vals = // eslint-disable-next-line security/detect-object-injection -- AST array indices are bounded
33
+ resolveTemplate(node.expressions[idx])
34
+ return vals ? withQuasi.flatMap(combo => vals.map(val => combo + val)) : null
35
+ }, [''])
36
+ else
37
+ return null
38
+ }