eslint-plugin-greasemonkey 1.2.0 → 1.4.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.
@@ -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
  })
@@ -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,32 +1,10 @@
1
1
  import { extractMetadata } from '../utils.js'
2
+ import { allAPIs, isAPIValidFor } from '../data/apis.js'
2
3
 
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
- ])
23
-
24
- const specialGrants = new Set([
25
- 'none', 'unsafeWindow', 'window.close', 'window.focus', 'window.onurlchange'
26
- ])
27
-
28
- const grants = new Set([...gm_legacy_apis, ...gm_modern_apis, ...sc_apis, ...specialGrants]),
29
- sortedGrants = new Map(Array.from(grants, grant => [grant.toLowerCase(), grant]))
4
+ const managers = ['tampermonkey', 'violentmonkey', 'greasemonkey', 'scriptcat'],
5
+ specialGrants = new Set(['none', 'unsafeWindow', 'window.close', 'window.focus', 'window.onurlchange']),
6
+ allGrants = new Set([...allAPIs, ...specialGrants]),
7
+ sortedGrants = new Map(Array.from(allGrants, grant => [grant.toLowerCase(), grant]))
30
8
 
31
9
  export default {
32
10
  meta: {
@@ -39,6 +17,13 @@ export default {
39
17
  schema: [{
40
18
  type: 'object',
41
19
  properties: {
20
+ target: {
21
+ anyOf: [
22
+ { type: 'string', enum: ['all', ...managers] },
23
+ { type: 'array', items: { type: 'string', enum: managers }, minItems: 1, uniqueItems: true }
24
+ ],
25
+ description: 'Manager(s) whose grants to validate against (default: all)'
26
+ },
42
27
  allowedGrants: {
43
28
  type: 'array',
44
29
  items: { type: 'string' },
@@ -50,28 +35,37 @@ export default {
50
35
  defaultOptions: [{}],
51
36
  messages: {
52
37
  invalidGrant: 'Invalid @grant: "{{grant}}"',
38
+ unsupportedGrant: '"@{{grant}}" is not supported by {{target}} (only by {{supporting}})',
53
39
  wrongCase: 'Invalid @grant: "{{grant}}" (did you mean "{{suggestion}}"?)'
54
40
  }
55
41
  },
56
42
 
57
43
  create(context) {
58
44
  const options = context.options[0] || {},
45
+ rawTarget = options.target ?? 'all',
46
+ targets = Array.isArray(rawTarget) ? rawTarget : [rawTarget],
59
47
  allowedGrants = options.allowedGrants || []
60
48
  return {
61
49
  Program() {
62
50
  const metaEntries = extractMetadata(context.sourceCode),
63
- grantEntries = metaEntries.filter(e => e.key == 'grant')
51
+ grantEntries = metaEntries.filter(entry => entry.key == 'grant')
64
52
  for (const grant of grantEntries) {
65
53
  const grantName = grant.value.trim()
66
- if (grants.has(grantName) || allowedGrants.includes(grantName)) continue
67
- 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 }})
54
+ if (specialGrants.has(grantName) || allowedGrants.includes(grantName)) continue
55
+ else if (!allGrants.has(grantName)) {
56
+ const suggestion = sortedGrants.get(grantName.toLowerCase())
57
+ if (suggestion && suggestion != grantName)
58
+ context.report({ node: grant.comment, messageId: 'wrongCase',
59
+ data: { grant: grantName, suggestion }})
60
+ else
61
+ context.report({ node: grant.comment, messageId: 'invalidGrant',
62
+ data: { grant: grantName }})
63
+ continue
64
+ } else if (!targets.includes('all') && !targets.some(m => isAPIValidFor(grantName, m))) {
65
+ const supporting = managers.filter(manager => isAPIValidFor(grantName, manager ))
66
+ context.report({ node: grant.comment, messageId: 'unsupportedGrant',
67
+ data: { grant: grantName, target: targets.join(', '), supporting: supporting.join(', ') }})
68
+ }
75
69
  }
76
70
  }
77
71
  }
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
+ }