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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-greasemonkey",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "ESLint rules for Greasemonkey/Tampermonkey userscripts",
5
5
  "type": "module",
6
6
  "engines": {
@@ -35,12 +35,12 @@
35
35
  "types": "src/index.d.ts",
36
36
  "scripts": {
37
37
  "prepare": "husky",
38
- "lint": "eslint . --cache",
39
- "lint:all": "eslint .",
40
- "lint:fix": "eslint . --fix --cache",
41
- "lint:fix-all": "eslint . --fix",
38
+ "lint": "eslint . --cache && yamllint .",
39
+ "lint:all": "eslint . && yamllint .",
40
+ "lint:fix": "eslint . --fix --cache && yamllint .",
41
+ "lint:fix-all": "eslint . --fix && yamllint .",
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.2.0",
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",
@@ -79,6 +79,7 @@
79
79
  "eslint-plugin-regexp": "^3.3.0",
80
80
  "eslint-plugin-security": "^4.0.1",
81
81
  "globals": "^17.12.0",
82
- "husky": "^9.1.7"
82
+ "husky": "^9.1.7",
83
+ "yamllint-js": "^0.2.4"
83
84
  }
84
85
  }
@@ -0,0 +1,35 @@
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.getTab', 'GM.getTabs', 'GM.getValue', 'GM.getValues',
12
+ 'GM.info', 'GM.listValues', 'GM.log', 'GM.notification', 'GM.openInTab', 'GM.registerMenuCommand',
13
+ 'GM.removeValueChangeListener', 'GM.saveTab', 'GM.setClipboard', 'GM.setValue', 'GM.setValues',
14
+ 'GM.unregisterMenuCommand', 'GM.webRequest', 'GM.xmlHttpRequest'
15
+ ])
16
+
17
+ export const scAPIs = new Set([
18
+ 'CAT.agent.conversation', 'CAT.agent.dom', 'CAT.agent.mcp', 'CAT.agent.model',
19
+ 'CAT.agent.opfs', 'CAT.agent.skills', 'CAT.agent.task',
20
+ 'CAT_fileStorage', 'CAT_scriptLoaded', 'CAT_userConfig'
21
+ ])
22
+
23
+ const tmAPIs = new Set([
24
+ 'GM_getTab', 'GM_getTabs', 'GM_saveTab', 'GM_webRequest',
25
+ 'GM.getTab', 'GM.getTabs', 'GM.saveTab', 'GM.webRequest'
26
+ ])
27
+
28
+ export const allAPIs = new Set([...gmLegacyAPIs, ...gmModernAPIs, ...scAPIs])
29
+
30
+ export function isAPIValidFor(api, manager) {
31
+ if (!allAPIs.has(api)) return false
32
+ if (api.startsWith('CAT')) return manager == 'scriptcat'
33
+ if (tmAPIs.has(api)) return manager == 'tampermonkey'
34
+ return true
35
+ }
@@ -0,0 +1,3 @@
1
+ export default { // val = replacement
2
+ rules: { 'require-meta-newline': 'require-blank-line-after-meta-block' }
3
+ }
@@ -0,0 +1,30 @@
1
+ export const universalHeaders = new Set([
2
+ 'antifeature', 'author', 'compatible', 'connect', 'contributionAmount', 'contributionURL', 'copyright',
3
+ 'defaulticon', 'description', 'downloadURL', 'exclude', 'exclude-match', 'grant', 'homepage', 'homepageURL',
4
+ 'icon', 'icon64', 'include', 'license', 'match', 'name', 'namespace', 'noframes', 'require', 'resource',
5
+ 'run-at', 'source', 'supportURL', 'unwrap', 'updateURL', 'version', 'website'
6
+ ])
7
+
8
+ export const managerHeaders = new Map([
9
+ ['inject-into', ['violentmonkey']],
10
+ ['top-level-await', ['violentmonkey']],
11
+ ['user-agent', ['tampermonkey']],
12
+ ['run-in', ['tampermonkey']],
13
+ ['sandbox', ['tampermonkey']],
14
+ ['webRequest', ['tampermonkey']],
15
+ ['nocompat', ['tampermonkey']],
16
+ ['domain', ['scriptish']]
17
+ ])
18
+
19
+ export const localizedHeaders = ['name', 'description', 'antifeature']
20
+ export const managers = ['tampermonkey', 'violentmonkey', 'greasemonkey', 'scriptish']
21
+ export const validHeaders = new Map()
22
+
23
+ validHeaders.set('all', new Set([...universalHeaders, ...managerHeaders.keys()]))
24
+
25
+ for (const manager of managers) {
26
+ const specific = [...managerHeaders]
27
+ .filter(([, supportedBy]) => supportedBy.includes(manager))
28
+ .map(([header]) => header)
29
+ validHeaders.set(manager, new Set([...universalHeaders, ...specific]))
30
+ }
package/src/index.js CHANGED
@@ -1,9 +1,19 @@
1
+ import aliases from './data/deprecations.js'
2
+
1
3
  import metaSpacing from './rules/meta-spacing.js'
2
4
  import noDuplicateKeys from './rules/no-duplicate-keys.js'
5
+ import noInvalidHeaders from './rules/no-invalid-headers.js'
6
+ import noMissingGrants from './rules/no-missing-grants.js'
7
+ import noMissingResources from './rules/no-missing-resources.js'
3
8
  import noUnusedGrants from './rules/no-unused-grants.js'
4
9
  import noUnusedResources from './rules/no-unused-resources.js'
10
+ import preferMatch from './rules/prefer-match.js'
11
+ import requireBlankLineAfterMetaBlock from './rules/require-blank-line-after-meta-block.js'
12
+ import requireHomepagePair from './rules/require-homepage-pair.js'
5
13
  import requireMatchingLocalizedFields from './rules/require-matching-localized-fields.js'
6
14
  import requireRequiredFields from './rules/require-required-fields.js'
15
+ import requireSpaceBeforeMetaKey from './rules/require-space-before-meta-key.js'
16
+ import requireUpdateDownloadPair from './rules/require-update-download-pair.js'
7
17
  import requireUserJsFilename from './rules/require-user-js-filename.js'
8
18
  import validFieldValues from './rules/valid-field-values.js'
9
19
  import validGrants from './rules/valid-grants.js'
@@ -12,26 +22,79 @@ const plugin = {
12
22
  rules: {
13
23
  'meta-spacing': metaSpacing,
14
24
  'no-duplicate-keys': noDuplicateKeys,
25
+ 'no-invalid-headers': noInvalidHeaders,
26
+ 'no-missing-grants': noMissingGrants,
15
27
  'no-unused-grants': noUnusedGrants,
28
+ 'no-missing-resources': noMissingResources,
16
29
  'no-unused-resources': noUnusedResources,
30
+ 'prefer-match': preferMatch,
31
+ 'require-blank-line-after-meta-block': requireBlankLineAfterMetaBlock,
32
+ 'require-homepage-pair': requireHomepagePair,
17
33
  'require-matching-localized-fields': requireMatchingLocalizedFields,
18
34
  'require-required-fields': requireRequiredFields,
35
+ 'require-space-before-meta-key': requireSpaceBeforeMetaKey,
36
+ 'require-update-download-pair': requireUpdateDownloadPair,
19
37
  'require-user-js-filename': requireUserJsFilename,
20
38
  'valid-field-values': validFieldValues,
21
39
  'valid-grants': validGrants
22
40
  }
23
41
  }
24
42
 
43
+ const docsURL = 'https://codeberg.org/adamlui/eslint-plugin-greasemonkey/#'
44
+ for (const [ruleId, rule] of Object.entries(plugin.rules))
45
+ rule.meta.docs.url = docsURL + ruleId
46
+
47
+ // Generate deprecated aliases
48
+ for (const [oldName, newName] of Object.entries(aliases.rules)) { /* eslint-disable security/detect-object-injection */
49
+ const newRule = plugin.rules[newName]
50
+ plugin.rules[oldName] = {
51
+ ...newRule,
52
+ meta: {
53
+ ...newRule.meta,
54
+ docs: { ...newRule.meta.docs, recommended: false },
55
+ deprecated: { message: `Use \`${newName}\` instead.`, replacedBy: [{ rule: { name: newName }}] }
56
+ }
57
+ }
58
+ } /* eslint-enable security/detect-object-injection */
59
+
25
60
  plugin.configs = {
26
61
  recommended: {
27
62
  plugins: { greasemonkey: plugin },
28
63
  rules: {
29
64
  'greasemonkey/meta-spacing': 'error',
30
65
  'greasemonkey/no-duplicate-keys': 'error',
66
+ 'greasemonkey/no-invalid-headers': 'error',
67
+ 'greasemonkey/no-missing-grants': 'error',
68
+ 'greasemonkey/no-unused-grants': 'error',
69
+ 'greasemonkey/no-missing-resources': 'error',
70
+ 'greasemonkey/no-unused-resources': 'error',
71
+ 'greasemonkey/prefer-match': 'error',
72
+ 'greasemonkey/require-blank-line-after-meta-block': 'error',
73
+ 'greasemonkey/require-matching-localized-fields': 'error',
74
+ 'greasemonkey/require-required-fields': 'error',
75
+ 'greasemonkey/require-space-before-meta-key': 'error',
76
+ 'greasemonkey/require-user-js-filename': 'error',
77
+ 'greasemonkey/valid-field-values': 'error',
78
+ 'greasemonkey/valid-grants': 'error'
79
+ }
80
+ },
81
+ strict: {
82
+ plugins: { greasemonkey: plugin },
83
+ rules: {
84
+ 'greasemonkey/meta-spacing': 'error',
85
+ 'greasemonkey/no-duplicate-keys': 'error',
86
+ 'greasemonkey/no-invalid-headers': 'error',
87
+ 'greasemonkey/no-missing-grants': 'error',
31
88
  'greasemonkey/no-unused-grants': 'error',
89
+ 'greasemonkey/no-missing-resources': 'error',
32
90
  'greasemonkey/no-unused-resources': 'error',
91
+ 'greasemonkey/prefer-match': 'error',
92
+ 'greasemonkey/require-blank-line-after-meta-block': 'error',
93
+ 'greasemonkey/require-homepage-pair': 'error',
33
94
  'greasemonkey/require-matching-localized-fields': 'error',
34
95
  'greasemonkey/require-required-fields': 'error',
96
+ 'greasemonkey/require-space-before-meta-key': 'error',
97
+ 'greasemonkey/require-update-download-pair': 'error',
35
98
  'greasemonkey/require-user-js-filename': 'error',
36
99
  'greasemonkey/valid-field-values': 'error',
37
100
  'greasemonkey/valid-grants': 'error'
@@ -39,4 +102,4 @@ plugin.configs = {
39
102
  }
40
103
  }
41
104
 
42
- export default plugin
105
+ 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,80 @@
1
+ import { extractMetadata } from '../utils.js'
2
+ import { managerHeaders, managers, localizedHeaders, validHeaders } from '../data/headers.js'
3
+
4
+ function parseAllowedPattern(entry) {
5
+ if (typeof entry != 'string' || !entry.startsWith('/')) return null
6
+ const lastSlash = entry.lastIndexOf('/')
7
+ if (lastSlash == 0) return null
8
+ try { /* eslint-disable-next-line security/detect-non-literal-regexp */
9
+ return new RegExp(entry.slice(1, lastSlash), entry.slice(lastSlash +1))
10
+ } catch { return null }
11
+ }
12
+
13
+ export default {
14
+ meta: {
15
+ type: 'problem',
16
+ docs: {
17
+ description: 'Disallow invalid metadata headers',
18
+ category: 'Possible Errors',
19
+ recommended: true
20
+ },
21
+ schema: [{
22
+ type: 'object',
23
+ properties: {
24
+ target: {
25
+ anyOf: [
26
+ { type: 'string', enum: ['all', ...managers] },
27
+ { type: 'array', items: { type: 'string', enum: managers }, minItems: 1, uniqueItems: true }
28
+ ],
29
+ description: 'Manager(s) whose headers to validate against (default: all)'
30
+ },
31
+ allowedHeaders: {
32
+ anyOf: [{ type: 'string' }, { type: 'array' }, {}],
33
+ description: 'Additional headers: string, RegExp, or array mixing both'
34
+ }
35
+ },
36
+ additionalProperties: false
37
+ }],
38
+ defaultOptions: [{ allowedHeaders: [] }],
39
+ messages: {
40
+ invalidHeader: 'Invalid header: "@{{header}}"',
41
+ unsupportedHeader: '"@{{header}}" is not supported by {{target}} (only by {{supporting}})'
42
+ }
43
+ },
44
+
45
+ create(context) {
46
+ const options = context.options[0] || {},
47
+ rawTarget = options.target ?? 'all',
48
+ targets = Array.isArray(rawTarget) ? rawTarget : [rawTarget],
49
+ rawAllowed = options.allowedHeaders ?? [],
50
+ allowedList = Array.isArray(rawAllowed) ? rawAllowed : [rawAllowed],
51
+ allowedExact = new Set(),
52
+ allowedRegexes = [],
53
+ validSet = targets.includes('all') ? validHeaders.get('all')
54
+ : new Set(targets.flatMap(target => [...validHeaders.get(target)]))
55
+ for (const entry of allowedList) {
56
+ if (entry instanceof RegExp) allowedRegexes.push(entry)
57
+ else if (typeof entry == 'string') {
58
+ const parsed = parseAllowedPattern(entry)
59
+ if (parsed) allowedRegexes.push(parsed)
60
+ else allowedExact.add(entry)
61
+ }
62
+ }
63
+ return {
64
+ Program() {
65
+ const metaEntries = extractMetadata(context.sourceCode)
66
+ for (const entry of metaEntries) {
67
+ if (allowedExact.has(entry.key)) continue
68
+ if (allowedRegexes.some(re => re.test(entry.key))) continue
69
+ if (localizedHeaders.includes(entry.key.split(':')[0])) continue
70
+ if (validSet.has(entry.key)) continue
71
+ const supporting = managerHeaders.get(entry.key)
72
+ if (supporting)
73
+ context.report({ node: entry.comment, messageId: 'unsupportedHeader',
74
+ data: { header: entry.key, target: targets.join(', '), supporting: supporting.join(', ') }})
75
+ else context.report({ node: entry.comment, messageId: 'invalidHeader', data: { header: entry.key }})
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,51 @@
1
+ import { extractMetadata } from '../utils.js'
2
+ import { gmLegacyAPIs, gmModernAPIs, scAPIs } from '../data/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
+ }
@@ -0,0 +1,61 @@
1
+ import { extractMetadata, resolveTemplate } from '../utils.js'
2
+
3
+ const resourceGetterNames = new Set(['GM_getResourceText', 'GM_getResourceURL']),
4
+ re = { specialChars: /[.*+?^${}()|[\]\\]/g, whitespace: /\s+/ }
5
+
6
+ export default {
7
+ meta: {
8
+ type: 'problem',
9
+ docs: {
10
+ description: 'Disallow @resource references without a matching declaration',
11
+ category: 'Possible Errors',
12
+ recommended: true
13
+ },
14
+ schema: [],
15
+ messages: {
16
+ missingResource: 'Missing @resource: "{{name}}"',
17
+ noMatchingResource: 'No declared @resource matches `{{pattern}}`'
18
+ }
19
+ },
20
+
21
+ create(context) {
22
+ const usedRefs = []
23
+ function record(node, arg) {
24
+ if (arg?.type == 'Literal' && typeof arg.value == 'string')
25
+ usedRefs.push({ names: [arg.value], pattern: null, node })
26
+ else if (arg?.type == 'TemplateLiteral') {
27
+ const resolved = resolveTemplate(arg)
28
+ if (resolved) usedRefs.push({ names: resolved, pattern: null, node })
29
+ else usedRefs.push({
30
+ names: null,
31
+ pattern: new RegExp( // eslint-disable-line security/detect-non-literal-regexp
32
+ `^${arg.quasis.map(q => q.value.raw.replace(re.specialChars, '\\$&')).join('.*')}$`),
33
+ node
34
+ })
35
+ }
36
+ }
37
+ return {
38
+ CallExpression(node) {
39
+ if (!resourceGetterNames.has(node.callee.name)) return
40
+ record(node, node.arguments[0])
41
+ },
42
+ 'Program:exit'() {
43
+ if (!usedRefs.length) return
44
+ const declared = new Set(
45
+ extractMetadata(context.sourceCode)
46
+ .filter(entry => entry.key == 'resource')
47
+ .map(entry => entry.value.trim().split(re.whitespace)[0])
48
+ )
49
+ for (const ref of usedRefs) {
50
+ if (ref.names) {
51
+ for (const name of ref.names)
52
+ if (!declared.has(name))
53
+ context.report({ node: ref.node, messageId: 'missingResource', data: { name }})
54
+ } else if (![...declared].some(name => ref.pattern.test(name)))
55
+ context.report({
56
+ node: ref.node, messageId: 'noMatchingResource', data: { pattern: ref.pattern.source }})
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
@@ -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,33 @@
1
+ export default {
2
+ meta: {
3
+ type: 'layout',
4
+ docs: {
5
+ description: 'Require a blank line between the metadata and code',
6
+ category: 'Stylistic Issues',
7
+ recommended: true
8
+ },
9
+ fixable: 'whitespace',
10
+ schema: [],
11
+ messages: { missingBlankLine: 'Missing blank line after metadata block' }
12
+ },
13
+
14
+ create(context) {
15
+ return {
16
+ Program() {
17
+ const sourceCode = context.sourceCode,
18
+ comments = sourceCode.getAllComments(),
19
+ hasOpening = comments.some(comment => comment.value.trim() == '==UserScript=='),
20
+ closingComment = comments.find(comment => comment.value.trim() == '==/UserScript==')
21
+ if (!hasOpening || !closingComment) return
22
+ const tokenAfter = sourceCode.getTokenAfter(closingComment)
23
+ if (!tokenAfter) return
24
+ if (tokenAfter.loc.start.line - closingComment.loc.end.line < 2)
25
+ context.report({
26
+ node: closingComment,
27
+ messageId: 'missingBlankLine',
28
+ fix(fixer) { return fixer.insertTextAfter(closingComment, '\n') }
29
+ })
30
+ }
31
+ }
32
+ }
33
+ }
@@ -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.insertTextBefore(homepageURLEntry.comment,
37
+ `// @homepage ${homepageURLEntry.value}\n`)
38
+ }
39
+ })
40
+ }
41
+ }
42
+ }
43
+ }