eslint-plugin-chrome-extensions 2.8.0 → 2.9.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.
Files changed (36) hide show
  1. package/README.md +86 -88
  2. package/package.json +11 -7
  3. package/src/rules/css-spacing.js +9 -14
  4. package/src/rules/manifest-key-order.js +6 -5
  5. package/src/rules/manifest-spacing.js +5 -8
  6. package/src/rules/no-content-style-leak.js +4 -3
  7. package/src/rules/no-deprecated-apis.js +6 -6
  8. package/src/rules/no-global-keyframes.js +5 -8
  9. package/src/rules/no-host-page-selectors.js +12 -16
  10. package/src/rules/no-inline-event-handlers.js +7 -5
  11. package/src/rules/no-inline-scripts.js +10 -6
  12. package/src/rules/no-insecure-fetch.js +10 -6
  13. package/src/rules/no-insecure-messaging.js +8 -10
  14. package/src/rules/no-invalid-locales.js +7 -6
  15. package/src/rules/no-invalid-permissions.js +5 -5
  16. package/src/rules/no-missing-default-locale.js +5 -4
  17. package/src/rules/no-missing-i18n-keys.js +7 -5
  18. package/src/rules/no-missing-permissions.js +1 -1
  19. package/src/rules/no-missing-web-accessible-resources.js +10 -10
  20. package/src/rules/no-remote-code.js +30 -33
  21. package/src/rules/no-remote-objects.js +7 -6
  22. package/src/rules/no-remote-urls.js +11 -12
  23. package/src/rules/no-scrollbar-styling.js +6 -6
  24. package/src/rules/no-sensitive-permissions.js +6 -4
  25. package/src/rules/no-unrestricted-host-permissions.js +6 -7
  26. package/src/rules/no-unused-i18n-keys.js +5 -4
  27. package/src/rules/require-short-name-when-useful.js +3 -8
  28. package/src/rules/valid-filenames.js +9 -11
  29. package/src/rules/valid-license-file.js +7 -6
  30. package/src/rules/valid-manifest-icons.js +14 -20
  31. package/src/rules/valid-service-worker.js +8 -10
  32. package/src/rules/valid-url-fields.js +8 -13
  33. package/src/rules/valid-version-field.js +5 -6
  34. package/src/utils/extension.js +7 -3
  35. package/src/utils/init.js +5 -4
  36. package/src/utils/node.js +23 -1
@@ -4,6 +4,8 @@ import i18nUtils from '../utils/i18n.js'
4
4
  import i18nData from '../data/i18n.js'
5
5
  import regex from '../data/regex.js'
6
6
 
7
+ const defaultOptions = { ignoreKeys: [], scanExtensions: i18nData.defaultScanExtensions }
8
+
7
9
  export default {
8
10
  meta: {
9
11
  type: 'suggestion',
@@ -26,7 +28,7 @@ export default {
26
28
  },
27
29
  additionalProperties: false
28
30
  }],
29
- defaultOptions: [{ ignoreKeys: [], scanExtensions: i18nData.defaultScanExtensions }],
31
+ defaultOptions: [defaultOptions],
30
32
  messages: {
31
33
  unusedKey: '`{{key}}` in `_locales/{{locale}}/messages.json` is never referenced.'
32
34
  }
@@ -38,9 +40,8 @@ export default {
38
40
  const manifest = extension.manifest.read(context) ; if (!manifest?.default_locale) return {}
39
41
  const root = extension.findRoot(context) ; if (!root) return {}
40
42
  const i18n = i18nUtils.readDefaultLocaleMessages(manifest, root) ; if (!i18n) return {}
41
- const opts = context.options[0] || {},
42
- ignored = new Set(opts.ignoreKeys || []),
43
- scanExtensions = opts.scanExtensions || i18nData.defaultScanExtensions
43
+ const { ignoreKeys, scanExtensions } = { ...defaultOptions, ...context.options[0] },
44
+ ignored = new Set(ignoreKeys)
44
45
  function check() {
45
46
  const { used, patterns } = i18nUtils.collectUsedKeys(root, scanExtensions)
46
47
  for (const key of Object.keys(i18n.messages)) {
@@ -1,7 +1,7 @@
1
1
  import extension from '../utils/extension.js'
2
2
  import regex from '../data/regex.js'
3
3
 
4
- const defaults = { maxNameLength: 12 },
4
+ const defaultOptions = { maxNameLength: 12, always: false, checkRedundant: false },
5
5
  re = { i18nPlaceholder: /^__MSG_.+__$/ }
6
6
 
7
7
  function isI18n(value) { return typeof value == 'string' && re.i18nPlaceholder.test(value) }
@@ -32,9 +32,7 @@ export default {
32
32
  },
33
33
  additionalProperties: false
34
34
  }],
35
- defaultOptions: [{
36
- maxNameLength: defaults.maxNameLength, always: false, checkRedundant: false
37
- }],
35
+ defaultOptions: [defaultOptions],
38
36
  messages: {
39
37
  missingShortName: '`name` exceeds {{max}} chars. Declare `short_name` to control truncation.',
40
38
  shortNameTooLong: '`short_name` exceeds the recommended {{max}}-character limit (found {{length}}).',
@@ -44,10 +42,7 @@ export default {
44
42
 
45
43
  create(context) {
46
44
  if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
47
- const opts = context.options[0] || {},
48
- maxNameLength = opts.maxNameLength ?? defaults.maxNameLength,
49
- always = opts.always ?? false,
50
- checkRedundant = opts.checkRedundant ?? false
45
+ const { maxNameLength, always, checkRedundant } = { ...defaultOptions, ...context.options[0] }
51
46
  let manifest
52
47
  try { manifest = JSON.parse(context.sourceCode.text) } catch { return {} }
53
48
  if (!manifest || typeof manifest != 'object') return {}
@@ -4,6 +4,11 @@ import extension from '../utils/extension.js'
4
4
  import fs from 'node:fs'
5
5
  import path from 'node:path'
6
6
 
7
+ const defaultOptions = {
8
+ noReservedPrefix: true, noSpecialChars: true, checkFileExists: true, enforceCaseSensitivity: true,
9
+ maxFullPathLength: 260
10
+ }
11
+
7
12
  const re = { invalidChars: /[^\w\-.]/ }
8
13
 
9
14
  const refKeys = [
@@ -85,10 +90,7 @@ export default {
85
90
  },
86
91
  additionalProperties: false
87
92
  }],
88
- defaultOptions: [{
89
- noReservedPrefix: true, noSpecialChars: true, checkFileExists: true, enforceCaseSensitivity: true,
90
- maxFullPathLength: 260
91
- }],
93
+ defaultOptions: [defaultOptions],
92
94
  messages: {
93
95
  reservedPrefix: '`{{name}}` starts with reserved prefix `_` (only `_locales` is allowed).',
94
96
  specialChars: '`{{name}}` contains characters outside `a-z`, `0-9`, `-`, `_`, and `.`.',
@@ -99,13 +101,9 @@ export default {
99
101
  },
100
102
 
101
103
  create(context) {
102
- const opts = context.options[0] || {},
103
- noReservedPrefix = opts.noReservedPrefix ?? true,
104
- noSpecialChars = opts.noSpecialChars ?? true,
105
- checkFileExists = opts.checkFileExists ?? true,
106
- enforceCaseSensitivity = opts.enforceCaseSensitivity ?? true,
107
- maxFullPathLength = opts.maxFullPathLength ?? 260,
108
- filename = context.filename
104
+ const { noReservedPrefix, noSpecialChars, checkFileExists, enforceCaseSensitivity, maxFullPathLength }
105
+ = { ...defaultOptions, ...context.options[0] }
106
+ const { filename } = context
109
107
  if (!filename || filename == '<input>') return {}
110
108
  const basename = path.basename(filename),
111
109
  extRoot = extension.findRoot(context) || path.dirname(filename),
@@ -4,6 +4,8 @@ import path from 'node:path'
4
4
 
5
5
  import regex from '../data/regex.js'
6
6
 
7
+ const defaultOptions = { requireSPDX: false, allowedLicenses: [] }
8
+
7
9
  function validateContent(context, node, { requireSPDX, allowedLicenses }) {
8
10
  const text = context.sourceCode.text,
9
11
  id = license.spdx.parse(text)
@@ -29,7 +31,7 @@ export default {
29
31
  description: 'Validates SPDX identifiers in license files',
30
32
  license: true
31
33
  },
32
- defaultOptions: [{ requireSPDX: false, allowedLicenses: [] }],
34
+ defaultOptions: [defaultOptions],
33
35
  schema: [{
34
36
  type: 'object',
35
37
  properties: {
@@ -52,11 +54,10 @@ export default {
52
54
  },
53
55
 
54
56
  create(context) {
55
- const opts = context.options[0],
56
- { requireSPDX } = opts,
57
- allowedLicenses = array.to(opts.allowedLicenses),
58
- { filename } = context,
59
- basename = path.basename(filename)
57
+ const { requireSPDX, allowedLicenses: rawAllowedLicenses } = { ...defaultOptions, ...context.options[0] },
58
+ allowedLicenses = array.to(rawAllowedLicenses),
59
+ { filename } = context,
60
+ basename = path.basename(filename)
60
61
  if (!regex.filename.license.test(basename) || regex.filename.script.test(basename))
61
62
  return {}
62
63
  return { Program: node => validateContent(context, node, { requireSPDX, allowedLicenses })}
@@ -4,13 +4,14 @@ import path from 'node:path'
4
4
 
5
5
  import regex from '../data/regex.js'
6
6
 
7
- const re = { svg: /\.svg$/i },
7
+ const defaultOptions = { requireRecommendedSizes: true, checkIconExists: true, noSVG: true },
8
+ re = { svg: /\.svg$/i },
8
9
  sizes = { recommended: ['16', '32', '48', '128'], required: '128' }
9
10
 
10
- function validateIcons(context, icons, root, opts, loc) {
11
- const { requireRecommended, checkFiles, checkSVG } = opts,
11
+ function validateIcons(context, icons, root, options, loc) {
12
+ const { requireRecommendedSizes, checkIconExists, noSVG } = options,
12
13
  declaredSizes = new Set(Object.keys(icons))
13
- if (requireRecommended) {
14
+ if (requireRecommendedSizes) {
14
15
  for (const size of sizes.recommended)
15
16
  if (!declaredSizes.has(size))
16
17
  context.report({ loc, messageId: 'missingSize', data: { size }})
@@ -18,9 +19,9 @@ function validateIcons(context, icons, root, opts, loc) {
18
19
  context.report({ loc, messageId: 'missingRequiredSize' })
19
20
  for (const file of Object.values(icons)) {
20
21
  if (typeof file != 'string') continue
21
- if (checkSVG && re.svg.test(file))
22
+ if (noSVG && re.svg.test(file))
22
23
  context.report({ loc, messageId: 'svgNotSupported' })
23
- else if (checkFiles && root // oxlint-disable-next-line security/detect-non-literal-fs-filename
24
+ else if (checkIconExists && root // oxlint-disable-next-line security/detect-non-literal-fs-filename
24
25
  && !fs.existsSync(path.resolve(root, file))
25
26
  ) context.report({ loc, messageId: 'missingFile', data: { file }})
26
27
  }
@@ -35,11 +36,7 @@ export default {
35
36
  recommended: true,
36
37
  manifest: true
37
38
  },
38
- defaultOptions: [{
39
- requireRecommendedSizes: true,
40
- checkIconExists: true,
41
- noSVG: true
42
- }],
39
+ defaultOptions: [defaultOptions],
43
40
  schema: [{
44
41
  type: 'object',
45
42
  properties: {
@@ -69,12 +66,9 @@ export default {
69
66
  create(context) {
70
67
  const filename = extension.file.getName(context)
71
68
  if (!regex.filename.manifest.test(filename) && !regex.filename.script.test(filename)) return {}
72
- const opts = context.options[0] || {},
73
- requireRecommended = opts.requireRecommendedSizes !== false,
74
- checkFiles = opts.checkIconExists !== false,
75
- checkSVG = opts.noSVG !== false,
76
- root = checkFiles ? extension.findRoot(context) : null,
77
- resolved = { requireRecommended, checkFiles, checkSVG }
69
+ const { requireRecommendedSizes, checkIconExists, noSVG } = { ...defaultOptions, ...context.options[0] },
70
+ root = checkIconExists ? extension.findRoot(context) : null,
71
+ resolved = { requireRecommendedSizes, checkIconExists, noSVG }
78
72
  if (!regex.filename.manifest.test(filename)) {
79
73
  const manifest = extension.manifest.read(context) ; if (!manifest) return {}
80
74
  const icons = manifest.icons
@@ -96,13 +90,13 @@ export default {
96
90
  file = fileNode?.value
97
91
  if (typeof size == 'string') declaredSizes.add(size)
98
92
  if (typeof file != 'string') continue
99
- if (checkSVG && re.svg.test(file))
93
+ if (noSVG && re.svg.test(file))
100
94
  context.report({ node: fileNode, messageId: 'svgNotSupported' })
101
- else if (checkFiles && root // oxlint-disable-next-line security/detect-non-literal-fs-filename
95
+ else if (checkIconExists && root // oxlint-disable-next-line security/detect-non-literal-fs-filename
102
96
  && !fs.existsSync(path.resolve(root, file))
103
97
  ) context.report({ node: fileNode, messageId: 'missingFile', data: { file }})
104
98
  }
105
- if (requireRecommended) {
99
+ if (requireRecommendedSizes) {
106
100
  for (const size of sizes.recommended)
107
101
  if (!declaredSizes.has(size))
108
102
  context.report({ node: iconsObj, messageId: 'missingSize', data: { size }})
@@ -3,6 +3,8 @@ import nodeUtils from '../utils/node.js'
3
3
 
4
4
  import manifestData from '../data/manifest.js'
5
5
 
6
+ const defaultOptions = { checkListenerTopLevel: true, checkKeepaliveTimer: true, checkDOMglobals: true }
7
+
6
8
  export default {
7
9
  meta: {
8
10
  type: 'problem',
@@ -23,11 +25,7 @@ export default {
23
25
  },
24
26
  additionalProperties: false
25
27
  }],
26
- defaultOptions: [{
27
- checkListenerTopLevel: true,
28
- checkKeepaliveTimer: true,
29
- checkDOMglobals: true
30
- }],
28
+ defaultOptions: [defaultOptions],
31
29
  messages: {
32
30
  listenerNotTopLevel: '`chrome.*.addListener()` must be registered at module top level'
33
31
  + ' so it attaches on service worker wake-up.',
@@ -37,8 +35,9 @@ export default {
37
35
  },
38
36
 
39
37
  create(context) {
40
- const { checkListenerTopLevel, checkKeepaliveTimer, checkDOMglobals } = context.options[0],
41
- manifest = extension.manifest.read(context)
38
+ const { checkListenerTopLevel, checkKeepaliveTimer, checkDOMglobals }
39
+ = { ...defaultOptions, ...context.options[0] }
40
+ const manifest = extension.manifest.read(context)
42
41
  if (!manifest || !extension.file.isServiceWorker(context, manifest)) return {}
43
42
  return {
44
43
  CallExpression(node) {
@@ -50,9 +49,8 @@ export default {
50
49
  if (checkKeepaliveTimer
51
50
  && node.callee.type == 'Identifier'
52
51
  && ['setTimeout', 'setInterval'].includes(node.callee.name)
53
- && node.arguments[1]?.type == 'Literal'
54
- && typeof node.arguments[1].value == 'number'
55
- && node.arguments[1].value < 30000
52
+ && nodeUtils.getNumberLiteral(node.arguments[1]) !== null
53
+ && nodeUtils.getNumberLiteral(node.arguments[1]) < 30000
56
54
  && !nodeUtils.isInsideNestedFunction(node)
57
55
  ) context.report({ node, messageId: 'keepaliveTimer', data: { fn: node.callee.name }})
58
56
  },
@@ -3,6 +3,9 @@ import extension from '../utils/extension.js'
3
3
  import manifestData from '../data/manifest.js'
4
4
  import regex from '../data/regex.js'
5
5
 
6
+ const defaultOptions = {
7
+ checkURLfields: true, checkMatchPatterns: true, allowedSchemes: ['https', 'file'], allowAllURLs: false }
8
+
6
9
  const re = {
7
10
  absoluteURL: /^[a-z][a-z0-9+.-]*:\/\/[^\s/]+/i,
8
11
  match: /^(\*|https?|file|ftp):\/\/([^/]*)(\/.*)$/,
@@ -86,10 +89,7 @@ export default {
86
89
  },
87
90
  additionalProperties: false
88
91
  }],
89
- defaultOptions: [{
90
- checkURLfields: true, checkMatchPatterns: true,
91
- allowedSchemes: ['https', 'file'], allowAllURLs: false
92
- }],
92
+ defaultOptions: [defaultOptions],
93
93
  messages: {
94
94
  invalidURL: '`{{field}}` has an invalid URL: "{{value}}".',
95
95
  invalidMatchPattern: '`{{field}}` has an invalid match pattern: "{{value}}".',
@@ -105,23 +105,18 @@ export default {
105
105
  create(context) {
106
106
  const filename = extension.file.getName(context)
107
107
  if (!regex.filename.manifest.test(filename) && !regex.filename.script.test(filename)) return {}
108
- const opts = context.options[0] || {},
109
- checkURLfields = opts.checkURLfields ?? true,
110
- checkMatchPatterns = opts.checkMatchPatterns ?? true,
111
- allowedSchemes = opts.allowedSchemes ?? ['https', 'file'],
112
- allowAllURLs = opts.allowAllURLs ?? false
113
- if (!checkURLfields && !checkMatchPatterns) return {}
114
- const resolved = { checkURLfields, checkMatchPatterns, allowedSchemes, allowAllURLs }
108
+ const options = { ...defaultOptions, ...context.options[0] }
109
+ if (!options.checkURLfields && !options.checkMatchPatterns) return {}
115
110
  if (!regex.filename.manifest.test(filename)) {
116
111
  const manifest = extension.manifest.read(context) ; if (!manifest) return {}
117
- validateManifest(context, manifest, resolved)
112
+ validateManifest(context, manifest, options)
118
113
  return {}
119
114
  }
120
115
  return {
121
116
  'Document:exit'() {
122
117
  let manifest
123
118
  try { manifest = JSON.parse(context.sourceCode.text) } catch { return }
124
- validateManifest(context, manifest, resolved)
119
+ validateManifest(context, manifest, options)
125
120
  }
126
121
  }
127
122
  }
@@ -1,6 +1,8 @@
1
1
  import extension from '../utils/extension.js'
2
2
  import regex from '../data/regex.js'
3
3
 
4
+ const defaultOptions = { versionType: 'chrome', allowFutureDate: false }
5
+
4
6
  const re = { // oxlint-disable security/detect-unsafe-regex
5
7
  chromeParts: /^\d+(?:\.\d+){0,3}$/,
6
8
  date: /^\d{4}\.(?:0?[1-9]|1[0-2])\.(?:0?[1-9]|[12]\d|3[01])(?:\.\d+)?$/,
@@ -36,7 +38,7 @@ export default {
36
38
  },
37
39
  additionalProperties: false
38
40
  }],
39
- defaultOptions: [{}],
41
+ defaultOptions: [defaultOptions],
40
42
  messages: {
41
43
  invalidVersion: 'Invalid manifest `version`: must be a valid {{versionType}} version '
42
44
  + '(expected format: {{example}}).',
@@ -45,11 +47,8 @@ export default {
45
47
  },
46
48
 
47
49
  create(context) {
48
- const options = context.options[0] || {},
49
- versionType = options.versionType || 'chrome',
50
- allowFutureDate = options.allowFutureDate ?? false,
51
- timezone = options.timezone,
52
- example = versionType == 'semver' ? '1.2.3'
50
+ const { versionType, allowFutureDate, timezone } = { ...defaultOptions, ...context.options[0] }
51
+ const example = versionType == 'semver' ? '1.2.3'
53
52
  : versionType == 'date' ? 'YYYY.M.D or YYYY.M.D.N'
54
53
  : '1.2.3 or 1.2.3.4'
55
54
  const now = timezone
@@ -8,6 +8,9 @@ import regex from '../data/regex.js'
8
8
 
9
9
  const manifestCache = new Map()
10
10
 
11
+ function stripScriptExt(filePath) { return filePath.replace(regex.filename.script, '') }
12
+ function sameScriptPath(a, b) { return a === b || stripScriptExt(a) === stripScriptExt(b) }
13
+
11
14
  const utils = {
12
15
  file: {
13
16
  getName(context) { return context.filename || context.getFilename() },
@@ -17,13 +20,14 @@ const utils = {
17
20
  if (!scripts.length) return false
18
21
  const root = utils.findRoot(context) ; if (!root) return false
19
22
  const filename = path.resolve(utils.file.getName(context))
20
- return scripts.some(script => filename == path.resolve(root, script))
23
+ return scripts.some(script => sameScriptPath(filename, path.resolve(root, script)))
21
24
  },
22
25
 
23
26
  isServiceWorker(context, manifest) {
24
- const worker = manifest?.background?.service_worker ; if (!worker) return false
27
+ const worker = manifest?.background?.service_worker
28
+ if (!worker) return false
25
29
  const root = utils.findRoot(context) ; if (!root) return false
26
- return path.resolve(utils.file.getName(context)) == path.resolve(root, worker)
30
+ return sameScriptPath(path.resolve(utils.file.getName(context)), path.resolve(root, worker))
27
31
  }
28
32
  },
29
33
 
package/src/utils/init.js CHANGED
@@ -4,6 +4,7 @@ import json from '@eslint/json'
4
4
  import pkg from './pkg.js'
5
5
  import rulesUtils from './rules.js'
6
6
  import text from './text.js'
7
+ import tsParser from '@typescript-eslint/parser'
7
8
 
8
9
  import cssSpacing from '../rules/css-spacing.js'
9
10
  import manifestKeyOrder from '../rules/manifest-key-order.js'
@@ -51,10 +52,10 @@ export default {
51
52
  rules = plugin.rules,
52
53
  createConfig = predicate => ({ plugins, rules: rulesUtils.bundle(rules, predicate, namespace) })
53
54
  plugin.configs = {
54
- recommended: createConfig(rule => rule.meta.docs.recommended),
55
- strict: createConfig(() => true),
56
- problem: createConfig(rule => rule.meta.type == 'problem'),
57
- security: createConfig(rule => rule.meta.docs.security),
55
+ recommended: { ...createConfig(rule => rule.meta.docs.recommended), languageOptions: { parser: tsParser }},
56
+ strict: { ...createConfig(() => true), languageOptions: { parser: tsParser }},
57
+ problem: { ...createConfig(rule => rule.meta.type == 'problem'), languageOptions: { parser: tsParser }},
58
+ security: { ...createConfig(rule => rule.meta.docs.security), languageOptions: { parser: tsParser }},
58
59
  manifest: {
59
60
  ...createConfig(rule => rule.meta.docs.manifest), language: 'json/json', plugins: { ...plugins, json }},
60
61
  license: {
package/src/utils/node.js CHANGED
@@ -1,4 +1,9 @@
1
1
  export default {
2
+ getLiteral(node) {
3
+ const inner = this.unwrapTS(node)
4
+ return inner?.type == 'Literal' ? inner : null
5
+ },
6
+
2
7
  getMemberPath(node) {
3
8
  const parts = [] ; let currentNode = node
4
9
  while (currentNode?.type == 'MemberExpression') {
@@ -11,6 +16,16 @@ export default {
11
16
  return parts.join('.')
12
17
  },
13
18
 
19
+ getNumberLiteral(node) {
20
+ const literal = this.getLiteral(node)
21
+ return typeof literal?.value == 'number' ? literal.value : null
22
+ },
23
+
24
+ getStringLiteral(node) {
25
+ const literal = this.getLiteral(node)
26
+ return typeof literal?.value == 'string' ? literal.value : null
27
+ },
28
+
14
29
  isInsideNestedFunction(node) {
15
30
  let ancestor = node.parent
16
31
  while (ancestor) {
@@ -21,5 +36,12 @@ export default {
21
36
  return false
22
37
  },
23
38
 
24
- plainType(node, prefix) { return node?.type?.replace(prefix, '') ?? '' }
39
+ plainType(node, prefix) { return node?.type?.replace(prefix, '') ?? '' },
40
+
41
+ unwrapTS(node) {
42
+ while (node
43
+ && ['TSAsExpression', 'TSTypeAssertion', 'TSNonNullExpression', 'TSSatisfiesExpression'].includes(node.type)
44
+ ) node = node.expression
45
+ return node
46
+ }
25
47
  }