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 +243 -32
- package/package.json +3 -3
- package/src/apis.js +20 -0
- package/src/index.js +37 -1
- package/src/rules/no-duplicate-keys.js +2 -2
- package/src/rules/no-missing-grants.js +51 -0
- package/src/rules/no-unused-grants.js +16 -4
- package/src/rules/no-unused-resources.js +24 -13
- package/src/rules/prefer-match.js +24 -0
- package/src/rules/require-homepage-pair.js +43 -0
- package/src/rules/require-matching-localized-fields.js +6 -6
- package/src/rules/require-meta-newline.js +36 -0
- package/src/rules/require-required-fields.js +3 -3
- package/src/rules/require-space-before-meta-key.js +39 -0
- package/src/rules/require-update-download-pair.js +32 -0
- package/src/rules/require-user-js-filename.js +2 -2
- package/src/rules/valid-field-values.js +18 -20
- package/src/rules/valid-grants.js +7 -30
- package/src/utils.js +20 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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` (
|
|
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`, `@
|
|
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` (
|
|
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-
|
|
466
|
+
### `require-space-before-meta-key` (auto-fixable)
|
|
307
467
|
|
|
308
|
-
> Requires
|
|
468
|
+
> Requires exactly one space between `//` and metadata keys.
|
|
309
469
|
|
|
310
470
|
#### Incorrect code
|
|
311
471
|
|
|
312
472
|
```js
|
|
313
473
|
// ==UserScript==
|
|
314
|
-
|
|
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` (
|
|
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` (
|
|
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
|
|
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
|
[](https://codeberg.org/adamlui/eslint-plugin-greasemonkey/#readme)
|
|
434
643
|
```
|
|
435
644
|
|
|
436
|
-
|
|
645
|
+
---
|
|
437
646
|
|
|
438
|
-
|
|
647
|
+
## License
|
|
439
648
|
|
|
440
|
-
<
|
|
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
|
-
|
|
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] / [
|
|
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.
|
|
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": "
|
|
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.
|
|
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
|
|
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(':') &&
|
|
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
|
|
16
|
+
const usedGrants = new Set()
|
|
17
17
|
return {
|
|
18
|
-
Identifier(node) {
|
|
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(
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 (
|
|
31
|
-
|
|
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
|
|
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(
|
|
33
|
-
descEntries = metaEntries.filter(
|
|
34
|
-
nameLangs = new Set(nameEntries.map(
|
|
35
|
-
descLangs = new Set(descEntries.map(
|
|
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(
|
|
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,
|
|
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', '
|
|
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(
|
|
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: {
|
|
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: '
|
|
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
|
-
|
|
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 == '
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
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
|
|
67
|
-
|
|
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([...
|
|
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(
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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==')
|
|
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
|
+
}
|