eslint-plugin-greasemonkey 1.5.1 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-greasemonkey",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "ESLint rules for Greasemonkey/Tampermonkey userscripts",
5
5
  "type": "module",
6
6
  "engines": {
@@ -63,13 +63,13 @@
63
63
  "eslint": ">=9.0.0"
64
64
  },
65
65
  "devDependencies": {
66
- "@e18e/eslint-plugin": "^0.8.0",
66
+ "@e18e/eslint-plugin": "^0.8.1",
67
67
  "@eslint/js": "^10.0.1",
68
68
  "@eslint/json": "^2.1.0",
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.2.0",
72
+ "eslint-plugin-constructors": "^1.2.2",
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/data/apis.js CHANGED
@@ -32,5 +32,4 @@ export function supportsAPI({ manager, api }) {
32
32
  : api.startsWith('CAT') ? manager == 'scriptcat'
33
33
  : tmAPIs.has(api) ? manager == 'tampermonkey'
34
34
  : true
35
-
36
35
  }
@@ -1,3 +1,8 @@
1
- export default { // val = replacement
2
- rules: { 'require-meta-newline': 'require-blank-line-after-meta-block' } // 2026-9-13
1
+ // Delete a line to end support
2
+
3
+ export const rules = { // old: new
4
+ 'valid-grants': 'no-invalid-grants', // 2026-9-16 announced removal @ v2
5
+ 'require-meta-newline': 'require-blank-line-after-meta-block' // 2026-9-13 announced removal @ v2
3
6
  }
7
+
8
+ export default { rules }
@@ -6,17 +6,23 @@ export const universalHeaders = new Set([
6
6
  ])
7
7
 
8
8
  export const managerHeaders = new Map([
9
- ['inject-into', ['violentmonkey']],
10
- ['top-level-await', ['violentmonkey']],
11
- ['user-agent', ['tampermonkey']],
12
- ['run-in', ['tampermonkey']],
9
+ ['run-in', ['tampermonkey', 'scriptcat']],
10
+ ['nocompat', ['tampermonkey']],
13
11
  ['sandbox', ['tampermonkey']],
12
+ ['user-agent', ['tampermonkey']],
14
13
  ['webRequest', ['tampermonkey']],
15
- ['nocompat', ['tampermonkey']]
14
+ ['inject-into', ['violentmonkey', 'scriptcat']],
15
+ ['top-level-await', ['violentmonkey']],
16
+ ['background', ['scriptcat']],
17
+ ['crontab', ['scriptcat']],
18
+ ['definition', ['scriptcat']],
19
+ ['early-start', ['scriptcat']],
20
+ ['require-css', ['scriptcat']],
21
+ ['storageName', ['scriptcat']]
16
22
  ])
17
23
 
18
24
  export const localizedHeaders = ['name', 'description', 'antifeature']
19
- export const managers = ['tampermonkey', 'violentmonkey', 'greasemonkey']
25
+ export const managers = ['tampermonkey', 'violentmonkey', 'greasemonkey', 'scriptcat']
20
26
  export const validHeaders = new Map()
21
27
 
22
28
  validHeaders.set('all', new Set([...universalHeaders, ...managerHeaders.keys()]))
package/src/index.js CHANGED
@@ -1,8 +1,11 @@
1
- import aliases from './data/deprecations.js'
1
+ /* eslint-disable security/detect-object-injection -- false positives */
2
+
3
+ import { rules as oldRules } from './data/deprecations.js'
2
4
 
3
5
  import metaOrder from './rules/meta-order.js'
4
6
  import metaSpacing from './rules/meta-spacing.js'
5
7
  import noDuplicateKeys from './rules/no-duplicate-keys.js'
8
+ import noInvalidGrants from './rules/no-invalid-grants.js'
6
9
  import noInvalidHeaders from './rules/no-invalid-headers.js'
7
10
  import noMissingGrants from './rules/no-missing-grants.js'
8
11
  import noMissingResources from './rules/no-missing-resources.js'
@@ -18,13 +21,15 @@ import requireSRIhashing from './rules/require-sri-hashing.js'
18
21
  import requireUpdateDownloadPair from './rules/require-update-download-pair.js'
19
22
  import requireUserJsFilename from './rules/require-user-js-filename.js'
20
23
  import validFieldValues from './rules/valid-field-values.js'
21
- import validGrants from './rules/valid-grants.js'
24
+
25
+ import { initDeprecatedAlias } from './utils.js'
22
26
 
23
27
  const plugin = {
24
28
  rules: {
25
29
  'meta-order': metaOrder,
26
30
  'meta-spacing': metaSpacing,
27
31
  'no-duplicate-keys': noDuplicateKeys,
32
+ 'no-invalid-grants': noInvalidGrants,
28
33
  'no-invalid-headers': noInvalidHeaders,
29
34
  'no-missing-grants': noMissingGrants,
30
35
  'no-unused-grants': noUnusedGrants,
@@ -39,27 +44,15 @@ const plugin = {
39
44
  'require-sri-hashing': requireSRIhashing,
40
45
  'require-update-download-pair': requireUpdateDownloadPair,
41
46
  'require-user-js-filename': requireUserJsFilename,
42
- 'valid-field-values': validFieldValues,
43
- 'valid-grants': validGrants
47
+ 'valid-field-values': validFieldValues
44
48
  }
45
49
  }
46
50
 
47
- const docsURL = 'https://codeberg.org/adamlui/eslint-plugin-greasemonkey/#'
48
51
  for (const [ruleId, rule] of Object.entries(plugin.rules))
49
- rule.meta.docs.url = docsURL + ruleId
52
+ rule.meta.docs.url = `https://codeberg.org/adamlui/eslint-plugin-greasemonkey/#${ruleId}`
50
53
 
51
- // Generate deprecated aliases
52
- for (const [oldName, newName] of Object.entries(aliases.rules)) { /* eslint-disable security/detect-object-injection */
53
- const newRule = plugin.rules[newName]
54
- plugin.rules[oldName] = {
55
- ...newRule,
56
- meta: {
57
- ...newRule.meta,
58
- docs: { ...newRule.meta.docs, recommended: false },
59
- deprecated: { message: `Use \`${newName}\` instead.`, replacedBy: [{ rule: { name: newName }}] }
60
- }
61
- }
62
- } /* eslint-enable security/detect-object-injection */
54
+ for (const entry of Object.entries(oldRules))
55
+ initDeprecatedAlias(plugin, entry)
63
56
 
64
57
  plugin.configs = {
65
58
  recommended: {
@@ -67,6 +60,7 @@ plugin.configs = {
67
60
  rules: {
68
61
  'greasemonkey/meta-spacing': 'error',
69
62
  'greasemonkey/no-duplicate-keys': 'error',
63
+ 'greasemonkey/no-invalid-grants': 'error',
70
64
  'greasemonkey/no-invalid-headers': 'error',
71
65
  'greasemonkey/no-missing-grants': 'error',
72
66
  'greasemonkey/no-unused-grants': 'error',
@@ -78,24 +72,23 @@ plugin.configs = {
78
72
  'greasemonkey/require-required-fields': 'error',
79
73
  'greasemonkey/require-space-before-meta-key': 'error',
80
74
  'greasemonkey/require-user-js-filename': 'error',
81
- 'greasemonkey/valid-field-values': 'error',
82
- 'greasemonkey/valid-grants': 'error'
75
+ 'greasemonkey/valid-field-values': 'error'
83
76
  }
77
+ },
78
+ strict: {
79
+ plugins: { greasemonkey: plugin },
80
+ rules: Object.fromEntries(Object.keys(plugin.rules).map(ruleId => [`greasemonkey/${ruleId}`, 'error']))
84
81
  }
85
82
  }
86
83
 
87
- plugin.configs.strict = {
88
- plugins: { greasemonkey: plugin },
89
- rules: Object.fromEntries(Object.keys(plugin.rules).map(ruleId => [`greasemonkey/${ruleId}`, 'error']))
90
- }
91
-
92
84
  for (const manager of ['tampermonkey', 'violentmonkey', 'greasemonkey', 'scriptcat'])
93
- plugin.configs[manager] = { // eslint-disable-line security/detect-object-injection
85
+ plugin.configs[manager] = {
94
86
  plugins: { greasemonkey: plugin },
95
87
  rules: {
96
88
  ...plugin.configs.recommended.rules,
89
+ 'greasemonkey/no-invalid-grants': ['error', { target: manager }],
97
90
  'greasemonkey/no-invalid-headers': ['error', { target: manager }],
98
- 'greasemonkey/valid-grants': ['error', { target: manager }]
91
+ ...(manager == 'tampermonkey' && { 'greasemonkey/require-update-download-pair': 'error' })
99
92
  }
100
93
  }
101
94
 
@@ -12,8 +12,8 @@ function findViolation(entries, keys) {
12
12
  const key = entry.key.split(':', 1)[0]
13
13
  return { entry, key, rank: ranks.get(key) }
14
14
  }).filter(item => item.rank !== undefined)
15
- for (let i = 1; i < known.length; i++) // eslint-disable-next-line security/detect-object-injection
16
- if (known[i - 1].rank > known[i].rank) return [known[i - 1], known[i]]
15
+ for (let i = 1 ; i < known.length ; i++) // eslint-disable-next-line security/detect-object-injection
16
+ if (known[i -1].rank > known[i].rank) return [known[i -1], known[i]]
17
17
  }
18
18
 
19
19
  function sortEntries(entries, keys) {
@@ -21,7 +21,7 @@ function sortEntries(entries, keys) {
21
21
  return entries.toSorted((a, b) => {
22
22
  const aRank = ranks.get(a.key.split(':', 1)[0]),
23
23
  bRank = ranks.get(b.key.split(':', 1)[0])
24
- return (aRank ?? Infinity) - (bRank ?? Infinity)
24
+ return ( aRank ?? Infinity ) -( bRank ?? Infinity )
25
25
  })
26
26
  }
27
27
 
@@ -35,16 +35,31 @@ export default {
35
35
  },
36
36
  fixable: 'code',
37
37
  schema: [{
38
- type: 'array',
39
- items: { type: 'string' },
40
- description: 'Metadata keys in preferred order; unspecified keys are ignored'
38
+ oneOf: [
39
+ {
40
+ type: 'array', items: { type: 'string' },
41
+ description: 'Metadata keys in preferred order; unspecified keys are ignored'
42
+ },
43
+ {
44
+ type: 'object',
45
+ properties: {
46
+ order: {
47
+ type: 'array', items: { type: 'string' },
48
+ description: 'Metadata keys in preferred order; unspecified keys are ignored'
49
+ }
50
+ },
51
+ additionalProperties: false,
52
+ description: 'Options for metadata key ordering'
53
+ }
54
+ ]
41
55
  }],
42
56
  defaultOptions: [[]],
43
57
  messages: { wrongOrder: '@{{first}} should appear before @{{second}}' }
44
58
  },
45
59
 
46
60
  create(context) {
47
- const custom = context.options[0] ?? [],
61
+ const option = context.options[0] ?? [],
62
+ custom = Array.isArray(option) ? option : option.order ?? [],
48
63
  master = custom.length ? defaultOrder.filter(key => !custom.includes(key)) : defaultOrder
49
64
  return {
50
65
  Program() {
@@ -11,13 +11,32 @@ export default {
11
11
  recommended: true
12
12
  },
13
13
  fixable: 'whitespace',
14
- schema: [{ type: 'number', description: 'Minimum spaces after the longest key (default 3)' }],
14
+ schema: [{
15
+ oneOf: [
16
+ {
17
+ type: 'number',
18
+ description: 'Minimum spaces after the longest key (default 3)'
19
+ },
20
+ {
21
+ type: 'object',
22
+ properties: {
23
+ spaces: {
24
+ type: 'number',
25
+ description: 'Minimum spaces after the longest key (default 3)'
26
+ }
27
+ },
28
+ additionalProperties: false,
29
+ description: 'Options for metadata spacing'
30
+ }
31
+ ]
32
+ }],
15
33
  defaultOptions: [3],
16
34
  messages: { misaligned: 'Metadata values should be aligned at column {{column}}' }
17
35
  },
18
36
 
19
37
  create(context) {
20
- const minSpaces = context.options[0] ?? 3
38
+ const option = context.options[0] ?? 3,
39
+ minSpaces = typeof option == 'number' ? option : option.spaces ?? 3
21
40
  return {
22
41
  Program() {
23
42
  const sourceCode = context.sourceCode,
@@ -15,6 +15,10 @@ export default {
15
15
  type: 'array',
16
16
  items: { type: 'string' },
17
17
  description: 'Required metadata fields (default: name, version)'
18
+ },
19
+ requireMatchOrInclude: {
20
+ type: 'boolean',
21
+ description: 'Require a match or include metadata field (default: true)'
18
22
  }
19
23
  },
20
24
  additionalProperties: false
@@ -25,7 +29,8 @@ export default {
25
29
 
26
30
  create(context) {
27
31
  const options = context.options[0] || {},
28
- requiredFields = options.fields || ['name', 'version']
32
+ requiredFields = options.fields || ['name', 'version'],
33
+ requireMatchOrInclude = options.requireMatchOrInclude ?? true
29
34
  return {
30
35
  Program(node) {
31
36
  const metaEntries = extractMetadata(context.sourceCode),
@@ -33,7 +38,7 @@ export default {
33
38
  for (const field of requiredFields)
34
39
  if (!keys.has(field))
35
40
  context.report({ node, messageId: 'missingField', data: { field }})
36
- if (!metaEntries.some(entry => ['include', 'match'].includes(entry.key)))
41
+ if (requireMatchOrInclude && !['include', 'match'].some(field => keys.has(field)))
37
42
  context.report({ node, messageId: 'missingField', data: { field: 'match or include' }})
38
43
  }
39
44
  }
@@ -46,7 +46,7 @@ export default {
46
46
  node: entry.comment, messageId: 'invalidHash',
47
47
  data: {
48
48
  key: entry.key,
49
- detected: match ? ` Detected ${match[1].toLowerCase()};` : '',
49
+ detected: match ? ` Detected ${match[1].toLowerCase()}.` : '',
50
50
  expected: algorithm
51
51
  }
52
52
  })
@@ -28,6 +28,14 @@ export default {
28
28
  type: 'array',
29
29
  items: { type: 'string' },
30
30
  description: 'Allowed @grant values to check against; if empty, no grant validation'
31
+ },
32
+ allowFutureDate: {
33
+ type: 'boolean',
34
+ description: 'Allow @version dates later than the current date (default: false)'
35
+ },
36
+ timezone: {
37
+ type: 'string',
38
+ description: 'IANA timezone for determining the current date (when omitted, only year and month are checked)'
31
39
  }
32
40
  },
33
41
  additionalProperties: false
@@ -35,6 +43,7 @@ export default {
35
43
  defaultOptions: [{}],
36
44
  messages: {
37
45
  invalidVersion: 'Invalid @version: must be a valid {{versionType}} (expected format: {{example}})',
46
+ futureVersion: 'Invalid @version: date cannot be in the future',
38
47
  invalidURL: 'Invalid URL for @{{field}}',
39
48
  invalidGrant: 'Unknown @grant: "{{grant}}"'
40
49
  }
@@ -44,7 +53,15 @@ export default {
44
53
  const options = context.options[0] || {},
45
54
  versionType = options.versionType || 'semver',
46
55
  allowedGrants = options.allowedGrants || [],
56
+ allowFutureDate = options.allowFutureDate ?? false,
57
+ timezone = options.timezone,
47
58
  example = versionType == 'semver' ? '1.2.3' : 'YYYY.M.D or YYYY.MM.DD'
59
+ const now = timezone
60
+ ? Object.fromEntries(new Intl.DateTimeFormat('en', {
61
+ timeZone: timezone, year: 'numeric', month: 'numeric', day: 'numeric'
62
+ }).formatToParts().filter(({ type }) => ['year', 'month', 'day'].includes(type))
63
+ .map(({ type, value }) => [type, Number(value)]))
64
+ : { year: new Date().getUTCFullYear(), month: new Date().getUTCMonth() +1 }
48
65
  return {
49
66
  Program() {
50
67
  const metaEntries = extractMetadata(context.sourceCode)
@@ -66,9 +83,15 @@ export default {
66
83
  const isValid = versionType == 'semver' ? re.semVer.test(entry.value)
67
84
  : versionType == 'date' ? re.dateVer.test(entry.value)
68
85
  : false
86
+ const [year, month, day] = versionType == 'date' ? entry.value.split('.').map(Number) : []
69
87
  if (!isValid)
70
88
  context.report({
71
89
  node: entry.comment, messageId: 'invalidVersion', data: { versionType, example }})
90
+ else if (versionType == 'date' && !allowFutureDate && (
91
+ year > now.year || year == now.year && month > now.month
92
+ || timezone && year == now.year && month == now.month && day > now.day
93
+ ))
94
+ context.report({ node: entry.comment, messageId: 'futureVersion' })
72
95
  }
73
96
  }
74
97
  }
package/src/utils.js CHANGED
@@ -1,3 +1,5 @@
1
+ const re = { whitespace: /\s/ }
2
+
1
3
  export function extractMetadata(sourceCode) {
2
4
  const comments = sourceCode.getAllComments(),
3
5
  metaEntries = []
@@ -7,17 +9,55 @@ export function extractMetadata(sourceCode) {
7
9
  if (!inMetaBlock && text == '==UserScript==') inMetaBlock = true
8
10
  else if (inMetaBlock && text == '==/UserScript==') break
9
11
  else if (inMetaBlock && text.startsWith('@')) { // capture entry
10
- const spaceIdx = text.indexOf(' ')
11
- if (spaceIdx > 1) { // need space after key
12
- const key = text.slice(1, spaceIdx).trim(),
13
- value = text.slice(spaceIdx +1).trim()
14
- metaEntries.push({ key, value, comment, line: comment.loc.start.line })
15
- }
12
+ const spaceIdx = text.search(re.whitespace),
13
+ key = spaceIdx == -1 ? text.slice(1).trim() : text.slice(1, spaceIdx).trim(),
14
+ value = spaceIdx == -1 ? '' : text.slice(spaceIdx +1).trim()
15
+ if (key) metaEntries.push({ key, value, comment, line: comment.loc.start.line })
16
16
  }
17
17
  }
18
18
  return metaEntries
19
19
  }
20
20
 
21
+ export function initDeprecatedAlias(plugin, [oldName, alias]) { /* eslint-disable security/detect-object-injection */
22
+ const entries = typeof alias == 'string' ? [[alias, null]] // old: new
23
+ : Array.isArray(alias) ? alias.flatMap(entry =>
24
+ typeof entry == 'string' ? [[entry, null]] : Object.entries(entry)) // old: [new, ...]
25
+ : Object.entries(alias) // old: { new: { old-option: new-option }}
26
+ const newNames = entries.filter(([name]) => name != oldName).map(([name]) => name),
27
+ newRule = plugin.rules[newNames[0] || oldName]
28
+ plugin.rules[oldName] = {
29
+ ...newRule,
30
+ meta: {
31
+ ...newRule.meta,
32
+ schema: typeof alias == 'string' ? newRule.meta.schema : [{ type: 'object' }],
33
+ docs: { ...newRule.meta.docs, recommended: false },
34
+ deprecated: {
35
+ message: `Use ${newNames.map(name => `\`${name}\``).join(', ')} instead.`,
36
+ replacedBy: newNames.map(name => ({ rule: { name }}))
37
+ }
38
+ },
39
+ create(context) {
40
+ const oldOptions = context.options[0]
41
+ return Object.assign({}, ...entries.map(([name, mapping]) => {
42
+ const newRule = plugin.rules[name]
43
+ const options = mapping ? {
44
+ ...oldOptions,
45
+ ...Object.fromEntries(Object.entries(mapping).map(([oldKey, newKey]) => // old: new
46
+ [newKey, oldOptions?.[oldKey] ?? (newKey == 'reverseDefault' ? true : newKey)]))
47
+ } : oldOptions
48
+ const aliasContext = new Proxy({}, {
49
+ get(_, property) {
50
+ if (property == 'options') return [options]
51
+ const val = context[property]
52
+ return typeof val == 'function' ? val.bind(context) : val
53
+ }
54
+ })
55
+ return newRule.create(aliasContext)
56
+ }))
57
+ }
58
+ }
59
+ } /* eslint-enable security/detect-object-injection */
60
+
21
61
  export function resolveTemplate(node) {
22
62
  if (node.type == 'ConditionalExpression') {
23
63
  const branches = [node.consequent, node.alternate].map(resolveTemplate)