eslint-plugin-greasemonkey 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-greasemonkey",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "ESLint rules for Greasemonkey/Tampermonkey userscripts",
5
5
  "type": "module",
6
6
  "engines": {
@@ -12,7 +12,21 @@
12
12
  "url": "https://codeberg.org/adamlui"
13
13
  },
14
14
  "homepage": "https://codeberg.org/adamlui/eslint-plugin-greasemonkey/#readme",
15
+ "bugs": {
16
+ "url": "https://codeberg.org/adamlui/eslint-plugin-greasemonkey/issues",
17
+ "email": "support@adamlui.com"
18
+ },
15
19
  "license": "MIT",
20
+ "funding": [
21
+ {
22
+ "type": "ko-fi",
23
+ "url": "https://ko-fi.com/adamlui"
24
+ },
25
+ {
26
+ "type": "paypal",
27
+ "url": "https://paypal.me/adamlui"
28
+ }
29
+ ],
16
30
  "main": "src/index.js",
17
31
  "files": [
18
32
  "docs/",
@@ -26,21 +40,28 @@
26
40
  "lint:fix": "eslint . --fix --cache",
27
41
  "lint:fix-all": "eslint . --fix",
28
42
  "test": "node tests/run.js",
29
- "demo": "eslint --config demo/eslint.config.js demo/example.user.js",
43
+ "demo": "node demo/run.js",
30
44
  "bump:patch": "bash utils/bump.sh patch",
31
45
  "bump:minor": "bash utils/bump.sh minor",
32
46
  "bump:feat": "npm run bump:minor",
33
47
  "bump:major": "bash utils/bump.sh major"
34
48
  },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://codeberg.org/adamlui/eslint-plugin-greasemonkey.git"
52
+ },
35
53
  "keywords": [
36
- "greasemonkey",
37
- "tampermonkey",
38
- "userscript",
39
54
  "eslint",
40
55
  "eslint-plugin",
41
56
  "eslintplugin",
42
- "metadata"
57
+ "greasemonkey",
58
+ "metadata",
59
+ "tampermonkey",
60
+ "userscript"
43
61
  ],
62
+ "peerDependencies": {
63
+ "eslint": ">=9.0.0"
64
+ },
44
65
  "devDependencies": {
45
66
  "@e18e/eslint-plugin": "^0.8.0",
46
67
  "@eslint/js": "^10.0.1",
@@ -48,17 +69,16 @@
48
69
  "@eslint/markdown": "^8.0.3",
49
70
  "@stylistic/eslint-plugin": "^5.10.0",
50
71
  "eslint": "^10.10.0",
51
- "eslint-plugin-constructors": "^1.0.2",
72
+ "eslint-plugin-constructors": "^1.1.1",
52
73
  "eslint-plugin-eslint-plugin": "^7.6.2",
53
74
  "eslint-plugin-import-x": "^4.17.1",
75
+ "eslint-plugin-n": "^18.3.0",
54
76
  "eslint-plugin-no-secrets": "^2.3.3",
55
77
  "eslint-plugin-no-unsanitized": "^4.1.5",
56
78
  "eslint-plugin-promise": "^7.3.0",
57
79
  "eslint-plugin-regexp": "^3.3.0",
80
+ "eslint-plugin-security": "^4.0.1",
58
81
  "globals": "^17.12.0",
59
82
  "husky": "^9.1.7"
60
- },
61
- "peerDependencies": {
62
- "eslint": ">=9.0.0"
63
83
  }
64
84
  }
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,18 +1,36 @@
1
1
  import metaSpacing from './rules/meta-spacing.js'
2
+ import noDuplicateKeys from './rules/no-duplicate-keys.js'
3
+ import noMissingGrants from './rules/no-missing-grants.js'
2
4
  import noUnusedGrants from './rules/no-unused-grants.js'
3
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'
4
8
  import requireMatchingLocalizedFields from './rules/require-matching-localized-fields.js'
9
+ import requireMetaNewline from './rules/require-meta-newline.js'
5
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'
13
+ import requireUserJsFilename from './rules/require-user-js-filename.js'
6
14
  import validFieldValues from './rules/valid-field-values.js'
15
+ import validGrants from './rules/valid-grants.js'
7
16
 
8
17
  const plugin = {
9
18
  rules: {
10
19
  'meta-spacing': metaSpacing,
20
+ 'no-duplicate-keys': noDuplicateKeys,
21
+ 'no-missing-grants': noMissingGrants,
22
+ 'no-unused-grants': noUnusedGrants,
23
+ 'no-unused-resources': noUnusedResources,
24
+ 'prefer-match': preferMatch,
25
+ 'require-homepage-pair': requireHomepagePair,
11
26
  'require-matching-localized-fields': requireMatchingLocalizedFields,
27
+ 'require-meta-newline': requireMetaNewline,
12
28
  'require-required-fields': requireRequiredFields,
29
+ 'require-space-before-meta-key': requireSpaceBeforeMetaKey,
30
+ 'require-update-download-pair': requireUpdateDownloadPair,
31
+ 'require-user-js-filename': requireUserJsFilename,
13
32
  'valid-field-values': validFieldValues,
14
- 'no-unused-resources': noUnusedResources,
15
- 'no-unused-grants': noUnusedGrants
33
+ 'valid-grants': validGrants
16
34
  }
17
35
  }
18
36
 
@@ -21,13 +39,40 @@ plugin.configs = {
21
39
  plugins: { greasemonkey: plugin },
22
40
  rules: {
23
41
  'greasemonkey/meta-spacing': 'error',
42
+ 'greasemonkey/no-duplicate-keys': 'error',
43
+ 'greasemonkey/no-missing-grants': 'error',
44
+ 'greasemonkey/no-unused-grants': 'error',
45
+ 'greasemonkey/no-unused-resources': 'error',
46
+ 'greasemonkey/prefer-match': 'error',
24
47
  'greasemonkey/require-matching-localized-fields': 'error',
48
+ 'greasemonkey/require-meta-newline': 'error',
25
49
  'greasemonkey/require-required-fields': 'error',
50
+ 'greasemonkey/require-space-before-meta-key': 'error',
51
+ 'greasemonkey/require-user-js-filename': 'error',
26
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',
27
63
  'greasemonkey/no-unused-resources': 'error',
28
- 'greasemonkey/no-unused-grants': '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',
71
+ 'greasemonkey/require-user-js-filename': 'error',
72
+ 'greasemonkey/valid-field-values': 'error',
73
+ 'greasemonkey/valid-grants': 'error'
29
74
  }
30
75
  }
31
76
  }
32
77
 
33
- export default plugin
78
+ export { plugin as default, plugin as greasemonkey }
@@ -24,30 +24,21 @@ export default {
24
24
  metaEntries = extractMetadata(sourceCode)
25
25
  if (!metaEntries.length) return
26
26
  let targetCol = metaEntries.reduce((max, entry) => Math.max(max, entry.key.length), 0) + minSpaces +4
27
- for (const entry of metaEntries) {
27
+ for (const entry of metaEntries)
28
28
  if (entry.key == 'resource') {
29
29
  const name = entry.value.trim().split(re.whitespace)[0],
30
- requiredColumn = 4 + entry.key.length +1 + name.length + minSpaces
31
- if (requiredColumn > targetCol) targetCol = requiredColumn
30
+ requiredCol = 4 + entry.key.length +1 + name.length + minSpaces
31
+ if (requiredCol > targetCol) targetCol = requiredCol
32
32
  }
33
- }
34
33
  for (const entry of metaEntries) {
35
34
  const commentText = sourceCode.getText(entry.comment),
36
- keyIdx = commentText.indexOf(entry.key) + entry.key.length
37
- let fixedText
38
- if (entry.key == 'resource') {
39
- const parts = entry.value.trim().split(re.whitespace)
40
- if (parts.length >= 2) {
41
- const name = parts[0],
42
- url = parts.slice(1).join(' '),
43
- spacesAfterName = targetCol - keyIdx -1 - name.length
44
- fixedText = commentText.slice(0, keyIdx) + ' ' + name + ' '.repeat(spacesAfterName) + url
45
- } else
46
- fixedText = commentText.slice(0, keyIdx) + ' '.repeat(targetCol - keyIdx)
47
- + entry.value.trim()
48
- } else
49
- fixedText = commentText.slice(0, keyIdx) + ' '.repeat(targetCol - keyIdx)
50
- + entry.value.trim()
35
+ keyIdx = commentText.indexOf(entry.key) + entry.key.length,
36
+ parts = entry.value.trim().split(re.whitespace),
37
+ named = entry.key == 'resource' && parts.length >= 2,
38
+ prefix = named ? ` ${parts[0]} ` : '',
39
+ suffix = named ? parts.slice(1).join(' ') : entry.value.trim(),
40
+ fixedText = commentText.slice(0, keyIdx) + prefix
41
+ + ' '.repeat(targetCol - keyIdx - prefix.length) + suffix
51
42
  if (commentText !== fixedText)
52
43
  context.report({
53
44
  node: entry.comment, messageId: 'misaligned', data: { column: targetCol },
@@ -0,0 +1,35 @@
1
+ import { extractMetadata } from '../utils.js'
2
+
3
+ const multiValKeys = new Set([
4
+ 'compatible', 'connect', 'description', 'exclude', 'exclude-match', 'grant',
5
+ 'homepageURL', 'include', 'license', 'match', 'require', 'resource', 'supportURL'
6
+ ])
7
+
8
+ export default {
9
+ meta: {
10
+ type: 'problem',
11
+ docs: {
12
+ description: 'Disallow duplicate metadata keys',
13
+ category: 'Possible Errors',
14
+ recommended: true
15
+ },
16
+ schema: [],
17
+ messages: { dupeKey: 'Duplicate @{{key}}' }
18
+ },
19
+
20
+ create(context) {
21
+ return {
22
+ Program() {
23
+ const metaEntries = extractMetadata(context.sourceCode),
24
+ seen = new Set()
25
+ for (const entry of metaEntries) {
26
+ const baseKey = entry.key.split(':')[0]
27
+ if (!entry.key.includes(':') && multiValKeys.has(baseKey)) continue
28
+ if (seen.has(entry.key))
29
+ context.report({ node: entry.comment, messageId: 'dupeKey', data: { key: entry.key }})
30
+ else seen.add(entry.key)
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,51 @@
1
+ import { extractMetadata } from '../utils.js'
2
+ import { gmLegacyAPIs, gmModernAPIs, scAPIs } from '../apis.js'
3
+
4
+ const apis = new Set([...gmLegacyAPIs, ...gmModernAPIs, ...scAPIs]),
5
+ grantlessAPIs = new Set(['GM_info', 'GM.info']) // always available, no grant required
6
+
7
+ export default {
8
+ meta: {
9
+ type: 'problem',
10
+ docs: {
11
+ description: 'Disallow API calls without a matching @grant',
12
+ category: 'Possible Errors',
13
+ recommended: true
14
+ },
15
+ schema: [],
16
+ messages: { missingGrant: 'Missing @grant: "{{api}}"' }
17
+ },
18
+
19
+ create(context) {
20
+ const usedAPIs = new Map()
21
+ function record(name, node) { if (apis.has(name) && !usedAPIs.has(name)) usedAPIs.set(name, node) }
22
+ return {
23
+ Identifier(node) { record(node.name, node) },
24
+ MemberExpression(node) {
25
+ const path = []
26
+ let current = node
27
+ while (current.type == 'MemberExpression') {
28
+ if (current.property.type != 'Identifier') return
29
+ path.unshift(current.property.name)
30
+ current = current.object
31
+ }
32
+ if (current.type == 'Identifier') {
33
+ path.unshift(current.name)
34
+ record(path.join('.'), node)
35
+ }
36
+ },
37
+ 'Program:exit'() {
38
+ if (!usedAPIs.size) return
39
+ const declared = new Set(
40
+ extractMetadata(context.sourceCode)
41
+ .filter(entry => entry.key == 'grant')
42
+ .map(entry => entry.value.trim())
43
+ )
44
+ for (const [api, node] of usedAPIs) {
45
+ if (grantlessAPIs.has(api) || declared.has(api)) continue
46
+ context.report({ node, messageId: 'missingGrant', data: { api }})
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
@@ -13,18 +13,30 @@ export default {
13
13
  },
14
14
 
15
15
  create(context) {
16
- const usedIdentifiers = new Set()
16
+ const usedGrants = new Set()
17
17
  return {
18
- Identifier(node) { usedIdentifiers.add(node.name) },
18
+ Identifier(node) { usedGrants.add(node.name) },
19
+ MemberExpression(node) {
20
+ const path = [] ; let current = node
21
+ while (current.type == 'MemberExpression') {
22
+ if (current.property.type != 'Identifier') return
23
+ path.unshift(current.property.name)
24
+ current = current.object
25
+ }
26
+ if (current.type == 'Identifier') {
27
+ path.unshift(current.name)
28
+ usedGrants.add(path.join('.'))
29
+ }
30
+ },
19
31
  'Program:exit'() {
20
32
  const sourceCode = context.sourceCode,
21
33
  metaEntries = extractMetadata(sourceCode),
22
- grants = metaEntries.filter(e => e.key == 'grant')
34
+ grants = metaEntries.filter(entry => entry.key == 'grant')
23
35
  if (!grants.length) return
24
36
  for (const grant of grants) {
25
37
  const grantName = grant.value.trim()
26
38
  if (['none', 'unsafeWindow'].includes(grantName)) continue
27
- if (!usedIdentifiers.has(grantName))
39
+ if (!usedGrants.has(grantName))
28
40
  context.report({ node: grant.comment, messageId: 'unusedGrant', data: { grant: grantName }})
29
41
  }
30
42
  }
@@ -1,6 +1,7 @@
1
- import { extractMetadata } from '../utils.js'
1
+ import { extractMetadata, resolveTemplate } from '../utils.js'
2
2
 
3
- const reWhitespace = /\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,21 +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
- regex = /GM_getResource(?:Text|URL)\s*\(\s*['"]([^'"]+)['"]\s*\)/g
27
- let match
28
- while ((match = regex.exec(text)) !== null) usedResources.add(match[1])
29
39
  for (const res of resources) {
30
- const name = res.value.split(reWhitespace)[0]
31
- if (!usedResources.has(name))
32
- 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 }})
33
43
  }
34
44
  }
35
45
  }
@@ -0,0 +1,24 @@
1
+ import { extractMetadata } from '../utils.js'
2
+
3
+ export default {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: {
7
+ description: 'Prefer @match over @include (see https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline)',
8
+ category: 'Best Practices',
9
+ recommended: true
10
+ },
11
+ schema: [],
12
+ messages: { preferMatch: 'Use @match instead of @include (deprecated in Chrome MV3)' }
13
+ },
14
+
15
+ create(context) {
16
+ return {
17
+ Program() {
18
+ for (const entry of extractMetadata(context.sourceCode))
19
+ if (entry.key == 'include')
20
+ context.report({ node: entry.comment, messageId: 'preferMatch' })
21
+ }
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,43 @@
1
+ import { extractMetadata } from '../utils.js'
2
+
3
+ export default {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: {
7
+ description: 'Require @homepage and @homepageURL to be used together',
8
+ category: 'Best Practices',
9
+ recommended: false
10
+ },
11
+ fixable: 'code',
12
+ schema: [],
13
+ messages: { missingPair: 'Missing @{{missing}} (required when @{{present}} is used)' }
14
+ },
15
+
16
+ create(context) {
17
+ return {
18
+ Program() {
19
+ const metaEntries = extractMetadata(context.sourceCode),
20
+ homepageEntry = metaEntries.find(entry => entry.key == 'homepage'),
21
+ homepageURLEntry = metaEntries.find(entry => entry.key == 'homepageURL')
22
+ if (homepageEntry && !homepageURLEntry)
23
+ context.report({
24
+ node: homepageEntry.comment, messageId: 'missingPair',
25
+ data: { missing: 'homepageURL', present: 'homepage' },
26
+ fix(fixer) {
27
+ return fixer.insertTextAfter(homepageEntry.comment,
28
+ `\n// @homepageURL ${homepageEntry.value}`)
29
+ }
30
+ })
31
+ else if (homepageURLEntry && !homepageEntry)
32
+ context.report({
33
+ node: homepageURLEntry.comment, messageId: 'missingPair',
34
+ data: { missing: 'homepage', present: 'homepageURL' },
35
+ fix(fixer) {
36
+ return fixer.insertTextAfter(homepageURLEntry.comment,
37
+ `\n// @homepage ${homepageURLEntry.value}`)
38
+ }
39
+ })
40
+ }
41
+ }
42
+ }
43
+ }
@@ -14,7 +14,7 @@ export default {
14
14
  ignoreLanguages: {
15
15
  type: 'array',
16
16
  items: { type: 'string' },
17
- description: 'Language codes to ignore (e.g., ["fr", "de"])'
17
+ description: 'Language codes to ignore (e.g. ["fr", "de"])'
18
18
  }
19
19
  },
20
20
  additionalProperties: false
@@ -29,10 +29,10 @@ export default {
29
29
  return {
30
30
  Program() {
31
31
  const metaEntries = extractMetadata(context.sourceCode),
32
- nameEntries = metaEntries.filter(e => e.key.startsWith('name:')),
33
- descEntries = metaEntries.filter(e => e.key.startsWith('description:')),
34
- nameLangs = new Set(nameEntries.map(e => e.key.split(':')[1])),
35
- descLangs = new Set(descEntries.map(e => e.key.split(':')[1]))
32
+ nameEntries = metaEntries.filter(entry => entry.key.startsWith('name:')),
33
+ descEntries = metaEntries.filter(entry => entry.key.startsWith('description:')),
34
+ nameLangs = new Set(nameEntries.map(entry => entry.key.split(':')[1])),
35
+ descLangs = new Set(descEntries.map(entry => entry.key.split(':')[1]))
36
36
  for (const lang of nameLangs)
37
37
  if (!ignoreLanguages.includes(lang) && !descLangs.has(lang))
38
38
  context.report({
@@ -43,7 +43,7 @@ export default {
43
43
  for (const lang of descLangs)
44
44
  if (!ignoreLanguages.includes(lang) && !nameLangs.has(lang))
45
45
  context.report({
46
- node: descEntries.find(e => e.key == `description:${lang}`).comment,
46
+ node: descEntries.find(entry => entry.key == `description:${lang}`).comment,
47
47
  messageId: 'missingPair',
48
48
  data: { missing: `name:${lang}`, present: `description:${lang}` }
49
49
  })
@@ -0,0 +1,36 @@
1
+ import { extractMetadata } from '../utils.js'
2
+
3
+ export default {
4
+ meta: {
5
+ type: 'layout',
6
+ docs: {
7
+ description: 'Require a newline between the metadata and code',
8
+ category: 'Stylistic Issues',
9
+ recommended: true
10
+ },
11
+ fixable: 'whitespace',
12
+ schema: [],
13
+ messages: { missingNewline: 'Missing blank line after metadata block' }
14
+ },
15
+
16
+ create(context) {
17
+ return {
18
+ Program() {
19
+ const sourceCode = context.sourceCode,
20
+ comments = sourceCode.getAllComments(),
21
+ metaEntries = extractMetadata(sourceCode)
22
+ if (!metaEntries.length) return
23
+ const closingComment = comments.find(comment => comment.value.trim() == '==/UserScript==')
24
+ if (!closingComment) return
25
+ const tokenAfter = sourceCode.getTokenAfter(closingComment)
26
+ if (!tokenAfter) return
27
+ if (tokenAfter.loc.start.line - closingComment.loc.end.line < 2)
28
+ context.report({
29
+ node: closingComment,
30
+ messageId: 'missingNewline',
31
+ fix(fixer) { return fixer.insertTextAfter(closingComment, '\n') }
32
+ })
33
+ }
34
+ }
35
+ }
36
+ }
@@ -14,7 +14,7 @@ export default {
14
14
  fields: {
15
15
  type: 'array',
16
16
  items: { type: 'string' },
17
- description: 'Required metadata fields (default: name, namespace, version)'
17
+ description: 'Required metadata fields (default: name, version)'
18
18
  }
19
19
  },
20
20
  additionalProperties: false
@@ -25,11 +25,11 @@ export default {
25
25
 
26
26
  create(context) {
27
27
  const options = context.options[0] || {},
28
- requiredFields = options.fields || ['name', 'namespace', 'version']
28
+ requiredFields = options.fields || ['name', 'version']
29
29
  return {
30
30
  Program(node) {
31
31
  const metaEntries = extractMetadata(context.sourceCode),
32
- keys = new Set(metaEntries.map(e => e.key))
32
+ keys = new Set(metaEntries.map(entry => entry.key))
33
33
  for (const field of requiredFields)
34
34
  if (!keys.has(field))
35
35
  context.report({ node, messageId: 'missingField', data: { field }})
@@ -0,0 +1,39 @@
1
+ import { extractMetadata } from '../utils.js'
2
+
3
+ const re = { leadingWhitespace: /^\s*/ }
4
+
5
+ export default {
6
+ meta: {
7
+ type: 'layout',
8
+ docs: {
9
+ description: 'Require exactly one space between // and metadata keys',
10
+ category: 'Stylistic Issues',
11
+ recommended: true
12
+ },
13
+ fixable: 'whitespace',
14
+ schema: [],
15
+ messages: { badPrefix: '`{{actual}}` should be `// @{{key}}`' }
16
+ },
17
+
18
+ create(context) {
19
+ return {
20
+ Program() {
21
+ const sourceCode = context.sourceCode,
22
+ metaEntries = extractMetadata(sourceCode)
23
+ for (const entry of metaEntries) {
24
+ const commentText = sourceCode.getText(entry.comment),
25
+ afterSlashes = commentText.slice(2)
26
+ if (afterSlashes.startsWith(' @')) continue
27
+ const rest = afterSlashes.replace(re.leadingWhitespace, ''),
28
+ keyIdx = commentText.indexOf(`@${entry.key}`),
29
+ actual = commentText.slice(0, keyIdx +1 + entry.key.length),
30
+ fixedText = `// ${rest}`
31
+ context.report({
32
+ node: entry.comment, messageId: 'badPrefix', data: { actual, key: entry.key },
33
+ fix(fixer) { return fixer.replaceText(entry.comment, fixedText) }
34
+ })
35
+ }
36
+ }
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,32 @@
1
+ import { extractMetadata } from '../utils.js'
2
+
3
+ export default {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: {
7
+ description: 'Require @updateURL and @downloadURL to be used together',
8
+ category: 'Best Practices',
9
+ recommended: false
10
+ },
11
+ schema: [],
12
+ messages: { missingPair: 'Missing @{{missing}} (required when @{{present}} is used)' }
13
+ },
14
+
15
+ create(context) {
16
+ return {
17
+ Program() {
18
+ const metaEntries = extractMetadata(context.sourceCode),
19
+ updateEntry = metaEntries.find(entry => entry.key == 'updateURL'),
20
+ downloadEntry = metaEntries.find(entry => entry.key == 'downloadURL')
21
+ if (updateEntry && !downloadEntry)
22
+ context.report({
23
+ node: updateEntry.comment, messageId: 'missingPair',
24
+ data: { missing: 'downloadURL', present: 'updateURL' }})
25
+ else if (downloadEntry && !updateEntry)
26
+ context.report({
27
+ node: downloadEntry.comment, messageId: 'missingPair',
28
+ data: { missing: 'updateURL', present: 'downloadURL' }})
29
+ }
30
+ }
31
+ }
32
+ }