eslint-plugin-chrome-extensions 1.0.1 → 2.1.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.
@@ -1,15 +1,40 @@
1
- import { apiPermissionMap } from '../data/apis.js'
2
- import { readManifest, getChromeAPIcalls } from '../utils.js'
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { apiPermissionMap, findPermission } from '../data/apis.js'
4
+ import { findExtensionRoot, getFilename, readManifest } from '../utils.js'
3
5
 
4
- const mappedPermissions = new Set(apiPermissionMap.values())
6
+ const re = { manifestFilename: /manifest\.json$/ }
7
+
8
+ function collectUsedAPIs(manifest, root) {
9
+ const files = new Set()
10
+ if (manifest.background?.service_worker) files.add(manifest.background.service_worker)
11
+ for (const script of manifest.background?.scripts || []) files.add(script)
12
+ for (const contentScript of manifest.content_scripts || [])
13
+ for (const file of contentScript.js || []) files.add(file)
14
+ if (manifest.action?.default_popup) files.add(manifest.action.default_popup)
15
+ if (manifest.options_ui?.page) files.add(manifest.options_ui.page)
16
+ if (manifest.devtools_page) files.add(manifest.devtools_page)
17
+ const re = { /* eslint-disable-next-line security/detect-unsafe-regex */
18
+ chromeAPI: /\bchrome\.\w+(?:\.\w+)*/g }
19
+ const used = new Set()
20
+ for (const file of files) {
21
+ try {
22
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
23
+ const src = fs.readFileSync(path.resolve(root, file), 'utf8')
24
+ for (const match of src.matchAll(re.chromeAPI)) used.add(match[0])
25
+ } catch {} // eslint-disable-line no-empty
26
+ }
27
+ return used
28
+ }
5
29
 
6
30
  export default {
7
31
  meta: {
8
- type: 'suggestion',
32
+ type: 'problem',
9
33
  docs: {
10
- description: 'Flag manifest permissions whose APIs are never used in this file',
11
- category: 'Best Practices',
12
- recommended: true
34
+ description: 'Flag manifest permissions whose APIs are never used',
35
+ category: 'Possible Errors',
36
+ recommended: false,
37
+ manifest: true
13
38
  },
14
39
  schema: [],
15
40
  messages: {
@@ -18,20 +43,40 @@ export default {
18
43
  },
19
44
 
20
45
  create(context) {
46
+ if (!re.manifestFilename.test(getFilename(context))) return {}
21
47
  const manifest = readManifest(context) ; if (!manifest) return {}
48
+ const root = findExtensionRoot(context) ; if (!root) return {}
49
+ const usedAPIs = collectUsedAPIs(manifest, root)
50
+ const permissionNodes = new Map()
51
+ function isUsed(permission) {
52
+ let known = false
53
+ for (const [, perm] of apiPermissionMap)
54
+ if (perm == permission) { known = true ; break }
55
+ if (!known) return true // custom permission → not our business
56
+ for (const api of usedAPIs)
57
+ if (findPermission(api) == permission) return true
58
+ return false
59
+ }
22
60
  return {
23
- 'Program:exit'() {
24
- const declared = manifest.permissions || [] ; if (!declared.length) return
25
- const calls = getChromeAPIcalls(context.sourceCode)
26
- const used = new Set()
27
- for (const api of calls)
28
- for (const [prefix, permission] of apiPermissionMap)
29
- if (api == prefix || api.startsWith(`${prefix}.`)) { used.add(permission) ; break }
30
- for (const permission of declared) {
31
- if (!mappedPermissions.has(permission)) continue
32
- if (used.has(permission)) continue
33
- context.report({ loc: { line: 1, column: 0 },
34
- messageId: 'unusedPermission', data: { permission }})
61
+ '*'(node) {
62
+ const keyNode = node.name || node.key ; if (!keyNode) return
63
+ const keyName = keyNode.value ?? keyNode.name
64
+ if (keyName != 'permissions' && keyName != 'optional_permissions') return
65
+ const arrNode = node.value ; if (arrNode?.type != 'Array') return
66
+ const elements = arrNode.elements || arrNode.items || []
67
+ for (const el of elements) {
68
+ const valueNode = el.value || el
69
+ const perm = valueNode?.value
70
+ if (typeof perm != 'string') continue
71
+ if (!permissionNodes.has(perm)) permissionNodes.set(perm, [])
72
+ permissionNodes.get(perm).push(valueNode)
73
+ }
74
+ },
75
+ 'Document:exit'() {
76
+ for (const [permission, nodes] of permissionNodes) {
77
+ if (isUsed(permission)) continue
78
+ for (const n of nodes)
79
+ context.report({ node: n, messageId: 'unusedPermission', data: { permission }})
35
80
  }
36
81
  }
37
82
  }
@@ -1,4 +1,6 @@
1
- import { readManifest } from '../utils.js'
1
+ import { getFilename, readManifest } from '../utils.js'
2
+
3
+ const re = { manifestFilename: /manifest\.json$/ }
2
4
 
3
5
  export default {
4
6
  meta: {
@@ -6,15 +8,31 @@ export default {
6
8
  docs: {
7
9
  description: 'Require manifest_version 3',
8
10
  category: 'Possible Errors',
9
- recommended: true
11
+ recommended: false,
12
+ manifest: true
10
13
  },
11
14
  schema: [],
12
15
  messages: {
13
- notMV3: 'manifest.json must declare `"manifest_version": 3` (MV2 rejected by the Chrome Web Store).'
16
+ notMV3: 'manifest.json must declare `"manifest_version": 3`.'
14
17
  }
15
18
  },
16
19
 
17
20
  create(context) {
21
+ if (re.manifestFilename.test(getFilename(context))) { // lint it directly
22
+ let foundVersion = null, versionNode = null
23
+ return {
24
+ '*'(node) {
25
+ const keyNode = node.name || node.key ; if (!keyNode) return
26
+ if ((keyNode.value ?? keyNode.name) != 'manifest_version') return
27
+ foundVersion = node.value?.value
28
+ versionNode = node.value || keyNode
29
+ },
30
+ '*:exit'(node) {
31
+ if (node.type != 'Document' || foundVersion == 3) return
32
+ context.report({ node: versionNode || node, messageId: 'notMV3' })
33
+ }
34
+ }
35
+ }
18
36
  const manifest = readManifest(context) ; if (!manifest || manifest.manifest_version == 3) return {}
19
37
  return { Program(node) { context.report({ node, messageId: 'notMV3' }) }}
20
38
  }
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { findExtensionRoot, getFilename } from '../utils.js'
5
+
6
+ const re = { manifestFilename: /manifest\.json$/, svg: /\.svg$/i },
7
+ sizes = { recommended: ['16', '32', '48', '128'], required: '128' }
8
+
9
+ export default {
10
+ meta: {
11
+ type: 'problem',
12
+ docs: {
13
+ description: 'Validate manifest icons: sizes, existing files, no SVG',
14
+ category: 'Possible Errors',
15
+ recommended: false,
16
+ manifest: true
17
+ },
18
+ defaultOptions: [{
19
+ requireRecommendedSizes: true,
20
+ checkIconExists: true,
21
+ noSVG: true
22
+ }],
23
+ schema: [{
24
+ type: 'object',
25
+ properties: {
26
+ requireRecommendedSizes: {
27
+ type: 'boolean',
28
+ description: 'Require all recommended sizes (16, 32, 48, 128)'
29
+ },
30
+ checkIconExists: {
31
+ type: 'boolean',
32
+ description: 'Flag icon paths that do not resolve to a file'
33
+ },
34
+ noSVG: {
35
+ type: 'boolean',
36
+ description: 'Flag SVG icon paths (unsupported by Chrome)'
37
+ }
38
+ },
39
+ additionalProperties: false
40
+ }],
41
+ messages: {
42
+ missingSize: 'Recommended icon size "{{size}}" is missing (16, 32, 48, 128).',
43
+ missingRequiredSize: 'Icons must include a 128x128 icon.',
44
+ missingFile: 'Icon file "{{file}}" does not exist in the extension root.',
45
+ svgNotSupported: 'SVG icons are not supported by Chrome. Use PNG instead.'
46
+ }
47
+ },
48
+
49
+ create(context) {
50
+ if (!re.manifestFilename.test(getFilename(context))) return {}
51
+ const opts = context.options[0] || {},
52
+ requireRecommended = opts.requireRecommendedSizes !== false,
53
+ checkFiles = opts.checkIconExists !== false,
54
+ checkSVG = opts.noSVG !== false
55
+ const root = checkFiles ? findExtensionRoot(context) : null
56
+ return {
57
+ '*'(node) {
58
+ const keyNode = node.name || node.key ; if (!keyNode) return
59
+ const keyName = keyNode.value ?? keyNode.name ; if (keyName != 'icons') return
60
+ const iconsObj = node.value ; if (iconsObj?.type != 'Object') return
61
+ const members = iconsObj.members || iconsObj.properties || []
62
+ const declaredSizes = new Set()
63
+ for (const member of members) {
64
+ const sizeNode = member.name || member.key,
65
+ size = sizeNode?.value ?? sizeNode?.name,
66
+ fileNode = member.value,
67
+ file = fileNode?.value
68
+ if (typeof size == 'string') declaredSizes.add(size)
69
+ if (typeof file != 'string') continue
70
+ if (checkSVG && re.svg.test(file))
71
+ context.report({ node: fileNode, messageId: 'svgNotSupported' })
72
+ else if (checkFiles && root // eslint-disable-next-line security/detect-non-literal-fs-filename
73
+ && !fs.existsSync(path.resolve(root, file))
74
+ ) context.report({ node: fileNode, messageId: 'missingFile', data: { file }})
75
+ }
76
+ if (requireRecommended) {
77
+ for (const size of sizes.recommended)
78
+ if (!declaredSizes.has(size))
79
+ context.report({ node: iconsObj, messageId: 'missingSize', data: { size }})
80
+ } else if (!declaredSizes.has(sizes.required))
81
+ context.report({ node: iconsObj, messageId: 'missingRequiredSize' })
82
+ }
83
+ }
84
+ }
85
+ }
@@ -1,5 +1,8 @@
1
- import { mv2OnlyKeys, forbiddenCSPDirectives } from '../data/manifest.js'
2
- import { readManifest } from '../utils.js'
1
+ import { mv2OnlyKeys, forbiddenCSPdirectives } from '../data/manifest.js'
2
+
3
+ import { getFilename, readManifest } from '../utils.js'
4
+
5
+ const re = { manifestFilename: /manifest\.json$/ }
3
6
 
4
7
  export default {
5
8
  meta: {
@@ -7,17 +10,63 @@ export default {
7
10
  docs: {
8
11
  description: 'Validate manifest against the MV3 schema',
9
12
  category: 'Possible Errors',
10
- recommended: true
13
+ recommended: false,
14
+ manifest: true
11
15
  },
12
16
  schema: [],
13
17
  messages: {
14
- mv2Key: '`{{key}}` is not allowed in MV3 - {{reason}}.',
18
+ mv2Key: '`{{key}}` is not allowed in MV3. {{reason}}.',
15
19
  unsafeCSP: 'MV3 CSP must not include `{{directive}}`.',
16
20
  wrongCSPType: '`content_security_policy` must be an object in MV3, not a string.'
17
21
  }
18
22
  },
19
23
 
20
24
  create(context) {
25
+ if (re.manifestFilename.test(getFilename(context))) { // lint it directly
26
+ const ancestors = [], pendingReports = []
27
+ let isMV3 = false
28
+ const keyOf = n => {
29
+ const k = n?.name || n?.key
30
+ return k?.value ?? k?.name
31
+ }
32
+ return {
33
+ '*'(node) {
34
+ const keyName = keyOf(node)
35
+ if (keyName) {
36
+ const isTopLevel = ancestors.length == 2
37
+ && ancestors[0].type == 'Document'
38
+ && ancestors[1].type == 'Object'
39
+ const inBackground = ancestors.some(a => keyOf(a) == 'background')
40
+ const inCSP = ancestors.some(a => keyOf(a) == 'content_security_policy')
41
+ if (keyName == 'manifest_version' && node.value?.value == 3) isMV3 = true
42
+ if (isTopLevel && mv2OnlyKeys.has(keyName))
43
+ pendingReports.push({ node, messageId: 'mv2Key',
44
+ data: { key: keyName, reason: 'MV2-only key' }})
45
+ if (inBackground && keyName == 'scripts')
46
+ pendingReports.push({ node, messageId: 'mv2Key',
47
+ data: { key: 'background.scripts', reason: 'use background.service_worker' }})
48
+ if (inBackground && keyName == 'persistent')
49
+ pendingReports.push({ node, messageId: 'mv2Key',
50
+ data: { key: 'background.persistent', reason: 'MV3 service workers are never persistent' }})
51
+ if (isTopLevel && keyName == 'content_security_policy' && node.value?.type == 'String')
52
+ pendingReports.push({ node: node.value, messageId: 'wrongCSPType' })
53
+ if (inCSP && keyName == 'extension_pages' && typeof node.value?.value == 'string')
54
+ for (const directive of forbiddenCSPdirectives)
55
+ if (node.value.value.includes(directive))
56
+ pendingReports.push({ node: node.value, messageId: 'unsafeCSP',
57
+ data: { directive }})
58
+ }
59
+ ancestors.push(node)
60
+ },
61
+ '*:exit'(node) {
62
+ ancestors.pop()
63
+ if (node.type == 'Document') {
64
+ if (!isMV3) return
65
+ for (const r of pendingReports) context.report(r)
66
+ }
67
+ }
68
+ }
69
+ }
21
70
  const manifest = readManifest(context) ; if (!manifest || manifest.manifest_version != 3) return {}
22
71
  return {
23
72
  Program(node) {
@@ -35,7 +84,7 @@ export default {
35
84
  context.report({ node, messageId: 'wrongCSPType' })
36
85
  const cspPages = csp?.extension_pages
37
86
  if (typeof cspPages == 'string')
38
- for (const directive of forbiddenCSPDirectives)
87
+ for (const directive of forbiddenCSPdirectives)
39
88
  if (cspPages.includes(directive))
40
89
  context.report({ node, messageId: 'unsafeCSP', data: { directive }})
41
90
  }
@@ -12,7 +12,7 @@ export default {
12
12
  schema: [],
13
13
  messages: {
14
14
  listenerNotTopLevel: '`chrome.*.addListener()` must be registered at module top level so it attaches on service worker wake-up.',
15
- keepaliveTimer: 'Sub-30s `{{fn}}` will not keep the MV3 service worker alive - use `chrome.alarms` instead.',
15
+ keepaliveTimer: 'Sub-30s `{{fn}}` will not keep the MV3 service worker alive. Use `chrome.alarms` instead.',
16
16
  domInSW: '`{{global}}` is not available in a service worker.'
17
17
  }
18
18
  },
@@ -0,0 +1,95 @@
1
+ import { getFilename, readManifest } from '../utils.js'
2
+
3
+ const re = { /* eslint-disable security/detect-unsafe-regex */
4
+ chromeParts: /^\d+(?:\.\d+){0,3}$/,
5
+ date: /^\d{4}\.(?:0?[1-9]|1[0-2])\.(?:0?[1-9]|[12]\d|3[01])(?:\.\d+)?$/,
6
+ manifestFilename: /manifest\.json$/,
7
+ semver: /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/
8
+ } /* eslint-enable security/detect-unsafe-regex */
9
+
10
+ export default {
11
+ meta: {
12
+ type: 'problem',
13
+ docs: {
14
+ description: 'Validate the manifest version field format',
15
+ category: 'Possible Errors',
16
+ recommended: false,
17
+ manifest: true
18
+ },
19
+ schema: [{
20
+ type: 'object',
21
+ properties: {
22
+ versionType: {
23
+ type: 'string',
24
+ enum: ['chrome', 'semver', 'date'],
25
+ description: 'Version format to enforce (default: chrome)'
26
+ },
27
+ allowFutureDate: {
28
+ type: 'boolean',
29
+ description: 'Allow version dates later than the current date (default: false)'
30
+ },
31
+ timezone: {
32
+ type: 'string',
33
+ description: 'IANA timezone for determining the current date (when omitted, only year and month are checked)'
34
+ }
35
+ },
36
+ additionalProperties: false
37
+ }],
38
+ defaultOptions: [{}],
39
+ messages: {
40
+ invalidVersion: 'Invalid manifest `version`: must be a valid {{versionType}} version (expected format: {{example}}).',
41
+ futureVersion: 'Invalid manifest `version`: date cannot be in the future.'
42
+ }
43
+ },
44
+
45
+ create(context) {
46
+ const options = context.options[0] || {},
47
+ versionType = options.versionType || 'chrome',
48
+ allowFutureDate = options.allowFutureDate ?? false,
49
+ timezone = options.timezone,
50
+ example = versionType == 'semver' ? '1.2.3'
51
+ : versionType == 'date' ? 'YYYY.M.D or YYYY.M.D.N'
52
+ : '1.2.3 or 1.2.3.4'
53
+ const now = timezone
54
+ ? Object.fromEntries(new Intl.DateTimeFormat('en', {
55
+ timeZone: timezone, year: 'numeric', month: 'numeric', day: 'numeric'
56
+ }).formatToParts().filter(({ type }) => ['year', 'month', 'day'].includes(type))
57
+ .map(({ type, value }) => [type, Number(value)]))
58
+ : { year: new Date().getUTCFullYear(), month: new Date().getUTCMonth() +1 }
59
+ function validate(node, version) {
60
+ if (typeof version != 'string') return
61
+ const parts = version.split('.')
62
+ if (!re.chromeParts.test(version) || parts.some(part => Number(part) > 65535))
63
+ return context.report({ node, messageId: 'invalidVersion', data: { versionType, example }})
64
+ if (versionType == 'semver') {
65
+ if (!re.semver.test(version))
66
+ context.report({ node, messageId: 'invalidVersion', data: { versionType, example }})
67
+ } else if (versionType == 'date') {
68
+ if (!re.date.test(version))
69
+ context.report({ node, messageId: 'invalidVersion', data: { versionType, example }})
70
+ else {
71
+ const [year, month, day] = parts.map(Number)
72
+ if (!allowFutureDate && (
73
+ year > now.year
74
+ || year == now.year && month > now.month
75
+ || timezone && year == now.year && month == now.month && day > now.day
76
+ )) context.report({ node, messageId: 'futureVersion' })
77
+ }
78
+ }
79
+ }
80
+ if (re.manifestFilename.test(getFilename(context))) // lint it directly
81
+ return {
82
+ '*'(node) {
83
+ const keyNode = node.name || node.key ; if (!keyNode) return
84
+ const keyName = keyNode.value ?? keyNode.name ; if (keyName != 'version') return
85
+ const valueNode = node.value ; if (valueNode?.type != 'String') return
86
+ validate(valueNode, valueNode.value)
87
+ }
88
+ }
89
+ return {
90
+ Program(node) {
91
+ validate(node, readManifest(context)?.version)
92
+ }
93
+ }
94
+ }
95
+ }
package/src/utils.js CHANGED
@@ -4,13 +4,34 @@ import path from 'node:path'
4
4
  const re = { chromeAPI: /\bchrome(?:\.\w+)+/g }, // eslint-disable-line security/detect-unsafe-regex
5
5
  manifestCache = new Map()
6
6
 
7
+ export function bundleRules(rules, predicate, pluginName, removals = []) {
8
+ return Object.fromEntries(Object.entries(rules)
9
+ .filter(([ruleId, rule]) => predicate(rule) || removals.includes(ruleId))
10
+ .map(([ruleId]) => [`${pluginName}/${ruleId}`, 'error']))
11
+ }
12
+
13
+ export function deprecatedFrom(bundles, bundleName) {
14
+ const ruleIds = []
15
+ for (const [ruleId, [bundle]] of Object.entries(bundles))
16
+ if (bundle === bundleName) ruleIds.push(ruleId)
17
+ return ruleIds
18
+ }
19
+
20
+ export function initBundleDeprecation(plugin, [ruleId, [bundleName, note]], pluginName) {
21
+ if (!process.env.DEBUG_MODE) return
22
+ if (plugin.rules[ruleId]) // eslint-disable-line security/detect-object-injection
23
+ console.warn(`[${pluginName}] '${ruleId}' will be removed from '${
24
+ bundleName}' in a future major release${note ? ` (${note})` : ''}`)
25
+ }
26
+
7
27
  export function findExtensionRoot(context) {
8
28
  let dir = path.dirname(getFilename(context))
9
- const root = path.parse(dir).root
10
- while (dir != root) {
29
+ while (dir) {
11
30
  // eslint-disable-next-line security/detect-non-literal-fs-filename
12
31
  if (fs.existsSync(path.join(dir, 'manifest.json'))) return dir
13
- dir = path.dirname(dir)
32
+ const parent = path.dirname(dir)
33
+ if (parent == dir) break // reached filesystem root or a stuck relative "."
34
+ dir = parent
14
35
  }
15
36
  return null
16
37
  }