eslint-plugin-chrome-extensions 2.2.0 → 2.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/docs/LICENSE.md CHANGED
@@ -1,4 +1,4 @@
1
- # 🏛️ MIT License
1
+ # MIT License
2
2
 
3
3
  **Copyright © 2026 [Adam Lui](https://codeberg.org/adamlui)**
4
4
 
package/package.json CHANGED
@@ -1,22 +1,24 @@
1
1
  {
2
2
  "name": "eslint-plugin-chrome-extensions",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "ESLint rules for Chrome extensions",
5
- "type": "module",
6
- "engines": {
7
- "node": ">=18.18.0"
8
- },
9
- "author": {
10
- "name": "Adam Lui",
11
- "email": "adam@kudoai.org",
12
- "url": "https://codeberg.org/adamlui"
13
- },
5
+ "keywords": [
6
+ "eslint",
7
+ "eslint-plugin",
8
+ "eslintplugin",
9
+ "chrome-extensions",
10
+ "chromium",
11
+ "security"
12
+ ],
14
13
  "homepage": "https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/#readme",
15
14
  "bugs": {
16
15
  "url": "https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/issues",
17
16
  "email": "support@adamlui.com"
18
17
  },
19
- "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://codeberg.org/adamlui/eslint-plugin-chrome-extensions.git"
21
+ },
20
22
  "funding": [
21
23
  {
22
24
  "type": "ko-fi",
@@ -27,44 +29,46 @@
27
29
  "url": "https://paypal.me/adamlui"
28
30
  }
29
31
  ],
30
- "main": "src/index.js",
32
+ "license": "MIT",
33
+ "author": {
34
+ "name": "Adam Lui",
35
+ "email": "adam@kudoai.org",
36
+ "url": "https://codeberg.org/adamlui"
37
+ },
38
+ "type": "module",
39
+ "exports": {
40
+ ".": {
41
+ "types": "./src/index.d.ts",
42
+ "default": "./src/index.js"
43
+ }
44
+ },
45
+ "main": "./src/index.js",
46
+ "types": "./src/index.d.ts",
31
47
  "files": [
32
48
  "docs/",
33
49
  "src/"
34
50
  ],
35
- "types": "src/index.d.ts",
36
51
  "scripts": {
52
+ "bump:feat": "npm run bump:minor",
53
+ "bump:major": "bash utils/bump.sh major",
54
+ "bump:minor": "bash utils/bump.sh minor",
55
+ "bump:patch": "bash utils/bump.sh patch",
56
+ "demo": "node demo/run.js",
37
57
  "lint": "eslint . --cache && yamllint .",
38
58
  "lint:all": "eslint . && yamllint .",
39
59
  "lint:fix": "eslint . --fix --cache && yamllint .",
40
60
  "lint:fix-all": "eslint . --fix && yamllint .",
41
- "test": "node tests/run.js",
42
- "demo": "node demo/run.js",
43
- "bump:patch": "bash utils/bump.sh patch",
44
- "bump:minor": "bash utils/bump.sh minor",
45
- "bump:feat": "npm run bump:minor",
46
- "bump:major": "bash utils/bump.sh major"
47
- },
48
- "repository": {
49
- "type": "git",
50
- "url": "git+https://codeberg.org/adamlui/eslint-plugin-chrome-extensions.git"
61
+ "test": "node tests/run.js"
51
62
  },
52
- "keywords": [
53
- "eslint",
54
- "eslint-plugin",
55
- "eslintplugin",
56
- "chrome-extensions",
57
- "chromium"
58
- ],
59
- "peerDependencies": {
60
- "eslint": ">=9.6.0",
61
- "@eslint/json": ">=0.1.0"
63
+ "dependencies": {
64
+ "globals": "^17.13.0",
65
+ "spdx-license-ids": "^3.0.24"
62
66
  },
63
67
  "devDependencies": {
64
68
  "@e18e/eslint-plugin": "^0.8.1",
65
69
  "@eslint/js": "^10.0.1",
66
70
  "@eslint/json": "^2.1.1",
67
- "@eslint/markdown": "^8.0.3",
71
+ "@eslint/markdown": "^7.5.1",
68
72
  "@stylistic/eslint-plugin": "^5.10.0",
69
73
  "eslint": "^10.12.0",
70
74
  "eslint-plugin-constructors": "^1.4.2",
@@ -73,12 +77,19 @@
73
77
  "eslint-plugin-n": "^18.4.1",
74
78
  "eslint-plugin-no-secrets": "^2.3.3",
75
79
  "eslint-plugin-no-unsanitized": "^4.1.5",
80
+ "eslint-plugin-package-json": "^1.10.1",
76
81
  "eslint-plugin-promise": "^7.3.0",
77
82
  "eslint-plugin-regexp": "^3.3.1",
78
83
  "eslint-plugin-security": "^4.2.0",
79
84
  "yamllint-js": "^0.2.4"
80
85
  },
81
- "dependencies": {
82
- "globals": "^17.13.0"
86
+ "peerDependencies": {
87
+ "@eslint/css": ">=0.5.0",
88
+ "@eslint/json": ">=0.1.0",
89
+ "@html-eslint/eslint-plugin": ">=0.42.0",
90
+ "eslint": ">=9.6.0"
91
+ },
92
+ "engines": {
93
+ "node": ">=18.18.0"
83
94
  }
84
95
  }
package/src/data/apis.js CHANGED
@@ -1,26 +1,3 @@
1
- export const deprecatedAPIs = new Map([
2
- ['chrome.pageAction.show', 'chrome.action.enable'],
3
- ['chrome.pageAction.hide', 'chrome.action.disable'],
4
- ['chrome.pageAction.setIcon', 'chrome.action.setIcon'],
5
- ['chrome.pageAction.setTitle', 'chrome.action.setTitle'],
6
- ['chrome.pageAction.setPopup', 'chrome.action.setPopup'],
7
- ['chrome.pageAction.getTitle', 'chrome.action.getTitle'],
8
- ['chrome.pageAction.getPopup', 'chrome.action.getPopup'],
9
- ['chrome.browserAction', 'chrome.action'],
10
- ['chrome.pageAction', 'chrome.action'],
11
- ['chrome.extension.getBackgroundPage', 'chrome.runtime.getContexts'],
12
- ['chrome.extension.getURL', 'chrome.runtime.getURL'],
13
- ['chrome.extension.sendRequest', 'chrome.runtime.sendMessage'],
14
- ['chrome.extension.onRequest', 'chrome.runtime.onMessage'],
15
- ['chrome.extension.onRequestExternal', 'chrome.runtime.onMessageExternal'],
16
- ['chrome.extension.connect', 'chrome.runtime.connect'],
17
- ['chrome.extension.onConnect', 'chrome.runtime.onConnect'],
18
- ['chrome.extension.onConnectExternal', 'chrome.runtime.onConnectExternal'],
19
- ['chrome.tabs.executeScript', 'chrome.scripting.executeScript'],
20
- ['chrome.tabs.insertCSS', 'chrome.scripting.insertCSS'],
21
- ['chrome.tabs.removeCSS', 'chrome.scripting.removeCSS']
22
- ])
23
-
24
1
  export const apiPermissionMap = new Map([
25
2
  ['chrome.alarms', 'alarms'],
26
3
  ['chrome.bookmarks', 'bookmarks'],
@@ -47,7 +24,32 @@ export const apiPermissionMap = new Map([
47
24
  ['chrome.webRequest', 'webRequest']
48
25
  ])
49
26
 
50
- export const permissionlessAPIs = new Set([
27
+ export const deprecatedAPIs = new Map([
28
+ ['chrome.pageAction.show', 'chrome.action.enable'],
29
+ ['chrome.pageAction.hide', 'chrome.action.disable'],
30
+ ['chrome.pageAction.setIcon', 'chrome.action.setIcon'],
31
+ ['chrome.pageAction.setTitle', 'chrome.action.setTitle'],
32
+ ['chrome.pageAction.setPopup', 'chrome.action.setPopup'],
33
+ ['chrome.pageAction.getTitle', 'chrome.action.getTitle'],
34
+ ['chrome.pageAction.getPopup', 'chrome.action.getPopup'],
35
+ ['chrome.browserAction', 'chrome.action'],
36
+ ['chrome.pageAction', 'chrome.action'],
37
+ ['chrome.extension.getBackgroundPage', 'chrome.runtime.getContexts'],
38
+ ['chrome.extension.getURL', 'chrome.runtime.getURL'],
39
+ ['chrome.extension.sendRequest', 'chrome.runtime.sendMessage'],
40
+ ['chrome.extension.onRequest', 'chrome.runtime.onMessage'],
41
+ ['chrome.extension.onRequestExternal', 'chrome.runtime.onMessageExternal'],
42
+ ['chrome.extension.connect', 'chrome.runtime.connect'],
43
+ ['chrome.extension.onConnect', 'chrome.runtime.onConnect'],
44
+ ['chrome.extension.onConnectExternal', 'chrome.runtime.onConnectExternal'],
45
+ ['chrome.tabs.executeScript', 'chrome.scripting.executeScript'],
46
+ ['chrome.tabs.insertCSS', 'chrome.scripting.insertCSS'],
47
+ ['chrome.tabs.removeCSS', 'chrome.scripting.removeCSS']
48
+ ])
49
+
50
+ export const externalMsgListeners = new Set(['chrome.runtime.onMessageExternal', 'chrome.runtime.onConnectExternal'])
51
+
52
+ export const permlessAPIs = new Set([
51
53
  // https://developer.chrome.com/docs/extensions/reference/api/management
52
54
  'chrome.management.getPermissionWarningsByManifest',
53
55
  'chrome.management.getSelf',
@@ -96,9 +98,6 @@ export const permissionlessAPIs = new Set([
96
98
  'chrome.tabs.update'
97
99
  ])
98
100
 
99
- export const externalMessagingListeners =
100
- new Set(['chrome.runtime.onMessageExternal', 'chrome.runtime.onConnectExternal'])
101
-
102
101
  export function findDeprecated(api) {
103
102
  for (const [deprecated, replacement] of deprecatedAPIs)
104
103
  if (api == deprecated || api.startsWith(`${deprecated}.`))
@@ -107,11 +106,11 @@ export function findDeprecated(api) {
107
106
  }
108
107
 
109
108
  export function findPermission(api) {
110
- if (permissionlessAPIs.has(api)) return null
109
+ if (permlessAPIs.has(api)) return null
111
110
  for (const [prefix, permission] of apiPermissionMap)
112
111
  if (api == prefix || api.startsWith(`${prefix}.`))
113
112
  return permission
114
113
  return null
115
114
  }
116
115
 
117
- export function isExternalMessagingListener(api) { return externalMessagingListeners.has(api) }
116
+ export function isExternalMessagingListener(api) { return externalMsgListeners.has(api) }
@@ -0,0 +1,71 @@
1
+ /* eslint-disable security/detect-non-literal-fs-filename -- lint-time project inspection only */
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ export const re = {
7
+ getMsgLiteral: /\b(?:chrome|browser)\.i18n\.getMessage\(\s*['"]([^'"]+)['"]/g,
8
+ getMsgTemplate: /\b(?:chrome|browser)\.i18n\.getMessage\(\s*`([^`]*)`/g,
9
+ msgPlaceholder: /__MSG_([\w@]+)__/g,
10
+ specialChars: /[.*+?^${}()|[\]\\]/g,
11
+ templateExpr: /\$\{[^}]*\}/g
12
+ }
13
+
14
+ export function collectUsedKeys(root, scanExtensions) {
15
+ const used = new Set(),
16
+ patterns = [],
17
+ exts = new Set(scanExtensions),
18
+ stack = [root]
19
+ while (stack.length) {
20
+ const dir = stack.pop()
21
+ let entries
22
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { continue }
23
+ for (const entry of entries) {
24
+ if (entry.name.startsWith('.') || entry.name == 'node_modules') continue
25
+ const full = path.join(dir, entry.name)
26
+ if (entry.isDirectory()) { stack.push(full) ; continue }
27
+ const ext = path.extname(entry.name).slice(1).toLowerCase()
28
+ if (!exts.has(ext)) continue
29
+ let text
30
+ try { text = fs.readFileSync(full, 'utf8') } catch { continue }
31
+ for (const match of text.matchAll(re.msgPlaceholder))
32
+ if (!isPredefined(match[1])) used.add(match[1])
33
+ for (const match of text.matchAll(re.getMsgLiteral))
34
+ used.add(match[1])
35
+ for (const match of text.matchAll(re.getMsgTemplate)) {
36
+ const body = match[1]
37
+ if (body.includes('${')) patterns.push(templateToPattern(body))
38
+ else used.add(body)
39
+ }
40
+ }
41
+ }
42
+ return { used, patterns }
43
+ }
44
+
45
+ export const defaultScanExtensions = [
46
+ 'cjs', 'css', 'htm', 'html', 'js', 'json', 'jsx', 'md', 'mjs', 'svg', 'ts', 'tsx', 'txt']
47
+
48
+ export function findExtensionRoot(startPath) {
49
+ let dir = path.dirname(startPath)
50
+ while (dir != path.dirname(dir)) {
51
+ if (fs.existsSync(path.join(dir, 'manifest.json'))) return dir
52
+ dir = path.dirname(dir)
53
+ }
54
+ }
55
+
56
+ export function isPredefined(key) { return key.startsWith('@@') }
57
+
58
+ export function readDefaultLocaleMessages(manifest, root) {
59
+ const locale = manifest?.default_locale
60
+ if (typeof locale != 'string') return null
61
+ const filePath = path.join(root, '_locales', locale, 'messages.json')
62
+ let messages
63
+ try { messages = JSON.parse(fs.readFileSync(filePath, 'utf8')) } catch { return null }
64
+ if (!messages || typeof messages != 'object' || Array.isArray(messages)) return null
65
+ return { locale, messages }
66
+ }
67
+
68
+ export function templateToPattern(body) {
69
+ const parts = body.split(re.templateExpr).map(part => part.replace(re.specialChars, '\\$&'))
70
+ return new RegExp(`^${parts.join('.*')}$`) // eslint-disable-line security/detect-non-literal-regexp
71
+ }
@@ -0,0 +1,27 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export const re = {
5
+ licenseFilename: // e.g. LICENSE.md, LICENSE-MIT, MIT-LICENSE.md, COPYING.LESSER, LICENSE-2.0.txt
6
+ /^(?:[\w.-]*-)?(?:LICEN[SC]E|COPYING)(?:[-.][\w.-]+)?$/i, // eslint-disable-line security/detect-unsafe-regex
7
+ spdxTag: /SPDX-License-Identifier:\s*([\w.+-]+)/i
8
+ }
9
+
10
+ export const skipDirs = new Set(['node_modules'])
11
+
12
+ export function findLicenseFile(root) {
13
+ const queue = [root]
14
+ while (queue.length) {
15
+ const dir = queue.shift(), entries = tryReaddir(dir)
16
+ if (!entries) continue
17
+ for (const entry of entries) {
18
+ if (entry.name.startsWith('.') || skipDirs.has(entry.name)) continue
19
+ if (entry.isFile() && re.licenseFilename.test(entry.name)) return path.join(dir, entry.name)
20
+ if (entry.isDirectory()) queue.push(path.join(dir, entry.name))
21
+ }
22
+ }
23
+ return null
24
+ }
25
+
26
+ function tryReaddir(dir) { // eslint-disable-next-line security/detect-non-literal-fs-filename
27
+ try { return fs.readdirSync(dir, { withFileTypes: true }) } catch { return null }}
@@ -3,6 +3,20 @@ import globals from 'globals'
3
3
  const serviceWorkerGlobals = new Set(Object.keys(globals.serviceworker || {}))
4
4
  export const domGlobals = new Set(Object.keys(globals.browser).filter(name => !serviceWorkerGlobals.has(name)))
5
5
 
6
+ export const ffDataCollectionRequired = new Set([
7
+ 'none', 'authenticationInfo', 'bookmarksInfo', 'browsingActivity', 'financialAndPaymentInfo', 'healthInfo',
8
+ 'locationInfo', 'personalCommunications', 'personallyIdentifyingInfo', 'searchTerms', 'websiteActivity',
9
+ 'websiteContent'
10
+ ])
11
+
12
+ export const ffDataCollectionOptional = new Set([
13
+ 'authenticationInfo', 'bookmarksInfo', 'browsingActivity', 'financialAndPaymentInfo', 'healthInfo', 'locationInfo',
14
+ 'personalCommunications', 'personallyIdentifyingInfo', 'searchTerms', 'technicalAndInteraction', 'websiteActivity',
15
+ 'websiteContent'
16
+ ])
17
+
18
+ export const ffMV3bgKeys = new Set(['scripts', 'page'])
19
+
6
20
  export const forbiddenCSPdirectives = [
7
21
  `'unsafe-eval'`, `'unsafe-inline'`, `'unsafe-hashes'`, `'wasm-unsafe-eval'`, 'http://', 'https://']
8
22
 
@@ -22,19 +36,6 @@ export const manifestKeyOrder = [
22
36
  'theme_experiment', 'tts_engine', 'update_url', 'version_name', 'web_accessible_resources'
23
37
  ]
24
38
 
25
- export const ffDataCollectionRequired = new Set([
26
- 'none', 'authenticationInfo', 'bookmarksInfo', 'browsingActivity', 'financialAndPaymentInfo', 'healthInfo',
27
- 'locationInfo', 'personalCommunications', 'personallyIdentifyingInfo', 'searchTerms', 'websiteActivity',
28
- 'websiteContent'
29
- ])
30
-
31
- export const ffDataCollectionOptional = new Set([
32
- 'authenticationInfo', 'bookmarksInfo', 'browsingActivity', 'financialAndPaymentInfo', 'healthInfo', 'locationInfo',
33
- 'personalCommunications', 'personallyIdentifyingInfo', 'searchTerms', 'technicalAndInteraction', 'websiteActivity',
34
- 'websiteContent'
35
- ])
36
-
37
- export const ffMV3bgKeys = new Set(['scripts', 'page'])
38
39
  export const mv2OnlyKeys = new Set(['browser_action', 'page_action'])
39
40
 
40
41
  export const remoteCodePatterns = [
@@ -0,0 +1,11 @@
1
+ export const textParser = {
2
+ parse: text => ({
3
+ type: 'Program',
4
+ body: [],
5
+ sourceType: 'module',
6
+ range: [0, text.length],
7
+ loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 0 }},
8
+ tokens: [],
9
+ comments: []
10
+ })
11
+ }
package/src/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { bundles as oldBundles } from './data/deprecations.js'
2
2
 
3
+ import css from '@eslint/css'
4
+ import html from '@html-eslint/eslint-plugin'
5
+ import json from '@eslint/json'
6
+
3
7
  import manifestKeyOrder from './rules/manifest-key-order.js'
4
8
  import manifestSpacing from './rules/manifest-spacing.js'
5
9
  import noContentStyleLeak from './rules/no-content-style-leak.js'
@@ -8,18 +12,27 @@ import noDuplicateManifestKeys from './rules/no-duplicate-manifest-keys.js'
8
12
  import noInsecureFetch from './rules/no-insecure-fetch.js'
9
13
  import noInsecureMessaging from './rules/no-insecure-messaging.js'
10
14
  import noInvalidPermissions from './rules/no-invalid-permissions.js'
15
+ import noMissingDefaultLocale from './rules/no-missing-default-locale.js'
16
+ import noMissingI18nKeys from './rules/no-missing-i18n-keys.js'
11
17
  import noMissingPermissions from './rules/no-missing-permissions.js'
12
18
  import noMissingWebAccessibleResources from './rules/no-missing-web-accessible-resources.js'
13
19
  import noRemoteCode from './rules/no-remote-code.js'
14
20
  import noUnrestrictedHostPermissions from './rules/no-unrestricted-host-permissions.js'
21
+ import noUnusedI18nKeys from './rules/no-unused-i18n-keys.js'
15
22
  import noUnusedPermissions from './rules/no-unused-permissions.js'
23
+ import requireLicenseFile from './rules/require-license-file.js'
16
24
  import requireMV3 from './rules/require-mv3.js'
25
+ import requireShortNameWhenUseful from './rules/require-short-name-when-useful.js'
26
+ import validFilenames from './rules/valid-filenames.js'
27
+ import validLicenseFile from './rules/valid-license-file.js'
17
28
  import validManifestIcons from './rules/valid-manifest-icons.js'
18
29
  import validMV3schema from './rules/valid-mv3-schema.js'
19
30
  import validServiceWorker from './rules/valid-service-worker.js'
31
+ import validURLfields from './rules/valid-url-fields.js'
20
32
  import validVersionField from './rules/valid-version-field.js'
21
33
 
22
34
  import { bundleRules, initBundleDeprecation } from './utils.js'
35
+ import { textParser } from './data/text-parser.js'
23
36
 
24
37
  const plugin = {
25
38
  rules: {
@@ -31,15 +44,23 @@ const plugin = {
31
44
  'no-insecure-fetch': noInsecureFetch,
32
45
  'no-insecure-messaging': noInsecureMessaging,
33
46
  'no-invalid-permissions': noInvalidPermissions,
47
+ 'no-missing-default-locale': noMissingDefaultLocale,
48
+ 'no-missing-i18n-keys': noMissingI18nKeys,
34
49
  'no-missing-permissions': noMissingPermissions,
35
50
  'no-missing-web-accessible-resources': noMissingWebAccessibleResources,
36
51
  'no-remote-code': noRemoteCode,
37
52
  'no-unrestricted-host-permissions': noUnrestrictedHostPermissions,
53
+ 'no-unused-i18n-keys': noUnusedI18nKeys,
38
54
  'no-unused-permissions': noUnusedPermissions,
39
55
  'require-mv3': requireMV3,
56
+ 'require-license-file': requireLicenseFile,
57
+ 'require-short-name-when-useful': requireShortNameWhenUseful,
58
+ 'valid-filenames': validFilenames,
59
+ 'valid-license-file': validLicenseFile,
40
60
  'valid-manifest-icons': validManifestIcons,
41
61
  'valid-mv3-schema': validMV3schema,
42
62
  'valid-service-worker': validServiceWorker,
63
+ 'valid-url-fields': validURLfields,
43
64
  'valid-version-field': validVersionField
44
65
  }
45
66
  }
@@ -65,8 +86,39 @@ plugin.configs = {
65
86
  rules: bundleRules(plugin.rules, rule => rule.meta.docs.security, 'chrome-extensions')
66
87
  },
67
88
  manifest: {
68
- plugins: { 'chrome-extensions': plugin },
89
+ language: 'json/json',
90
+ plugins: { 'chrome-extensions': plugin, json },
69
91
  rules: bundleRules(plugin.rules, rule => rule.meta.docs.manifest, 'chrome-extensions')
92
+ },
93
+ license: {
94
+ languageOptions: { parser: textParser },
95
+ plugins: { 'chrome-extensions': plugin },
96
+ rules: bundleRules(plugin.rules, rule => rule.meta.docs.license, 'chrome-extensions')
97
+ },
98
+ css: {
99
+ language: 'css/css',
100
+ plugins: { 'chrome-extensions': plugin, css },
101
+ rules: bundleRules(plugin.rules, rule => rule.meta.docs.css, 'chrome-extensions')
102
+ },
103
+ html: {
104
+ language: 'html/html',
105
+ plugins: { 'chrome-extensions': plugin, html },
106
+ rules: bundleRules(plugin.rules, rule => rule.meta.docs.html, 'chrome-extensions')
107
+ },
108
+ json: {
109
+ language: 'json/json',
110
+ plugins: { 'chrome-extensions': plugin, json },
111
+ rules: bundleRules(plugin.rules, rule => rule.meta.docs.json, 'chrome-extensions')
112
+ },
113
+ json5: {
114
+ language: 'json/json5',
115
+ plugins: { 'chrome-extensions': plugin, json },
116
+ rules: bundleRules(plugin.rules, rule => rule.meta.docs.json, 'chrome-extensions')
117
+ },
118
+ jsonc: {
119
+ language: 'json/jsonc',
120
+ plugins: { 'chrome-extensions': plugin, json },
121
+ rules: bundleRules(plugin.rules, rule => rule.meta.docs.json, 'chrome-extensions')
70
122
  }
71
123
  }
72
124
 
@@ -1,5 +1,4 @@
1
1
  import { manifestKeyOrder } from '../data/manifest.js'
2
-
3
2
  import { getFilename } from '../utils.js'
4
3
 
5
4
  const re = { manifestFilename: /manifest\.json$/ }
@@ -15,8 +14,7 @@ function findViolation(members, keys) {
15
14
  const key = keyOf(member)
16
15
  return { member, key, rank: ranks.get(key) }
17
16
  }).filter(item => item.rank !== undefined)
18
- for (let i = 1; i < known.length; i++)
19
- // eslint-disable-next-line security/detect-object-injection
17
+ for (let i = 1 ; i < known.length ; i++) // eslint-disable-next-line security/detect-object-injection
20
18
  if (known[i -1].rank > known[i].rank) return [known[i -1], known[i]]
21
19
  }
22
20
 
@@ -73,7 +73,7 @@ export default {
73
73
  lineOffsets = [0]
74
74
  for (const line of lines)
75
75
  lineOffsets.push(lineOffsets.at(-1) + line.length +1)
76
- let depth = 0, lastLine = 0
76
+ let depth = 0, lastLine = 0, prevToken = null
77
77
  for (const token of tokens) { // check indentation
78
78
  const startLine = token.loc.start.line
79
79
  if (startLine != lastLine) {
@@ -92,8 +92,15 @@ export default {
92
92
  }
93
93
  lastLine = startLine
94
94
  }
95
- if (brackets.open.has(token.type)) depth += 1
96
- else if (brackets.close.has(token.type)) depth -= 1
95
+ const isOpen = brackets.open.has(token.type),
96
+ isClose = brackets.close.has(token.type),
97
+ wasOpen = prevToken && brackets.open.has(prevToken.type),
98
+ wasClose = prevToken && brackets.close.has(prevToken.type),
99
+ isSameLine = prevToken && prevToken.loc.end.line == token.loc.start.line,
100
+ isContiguous = prevToken && prevToken.range[1] == token.range[0]
101
+ if (isOpen && !(wasOpen && isSameLine)) depth += 1
102
+ else if (isClose && !(wasClose && isContiguous)) depth -= 1
103
+ prevToken = token
97
104
  }
98
105
  for (let t = 0 ; t < tokens.length ; t++) { // check inline spacing
99
106
  const token = tokens[t], // eslint-disable-line security/detect-object-injection
@@ -131,7 +138,8 @@ export default {
131
138
  if (prev) {
132
139
  const between = text.slice(prev.range[1], token.range[0]),
133
140
  inline = !between.includes('\n'),
134
- isColon = type == 'Colon', isComma = type == 'Comma'
141
+ isColon = type == 'Colon',
142
+ isComma = type == 'Comma'
135
143
  if (checkColons && isColon && inline && between != '')
136
144
  context.report({
137
145
  loc: token.loc.start, messageId: 'spaceBeforeColon',
@@ -1,5 +1,7 @@
1
1
  import { readManifest, isContentScriptFile } from '../utils.js'
2
2
 
3
+ const re = { appendMethod: /^append(?:Child)?$/ }
4
+
3
5
  export default {
4
6
  meta: {
5
7
  type: 'suggestion',
@@ -41,7 +43,8 @@ export default {
41
43
  if (isObjectAssign(node) && isCreateStyle(node.arguments[0])) return true
42
44
  if (isObjectAssign(node)
43
45
  && node.arguments[0]?.type == 'Identifier'
44
- && styleElems.has(node.arguments[0].name)) return true
46
+ && styleElems.has(node.arguments[0].name)
47
+ ) return true
45
48
  return false
46
49
  }
47
50
  return {
@@ -54,7 +57,7 @@ export default {
54
57
  CallExpression(node) {
55
58
  if (node.callee.type != 'MemberExpression') return
56
59
  const method = node.callee.property.name
57
- if (method != 'append' && method != 'appendChild') return
60
+ if (!re.appendMethod.test(method)) return
58
61
  if (!isDocumentMember(node.callee.object)) return
59
62
  for (const arg of node.arguments)
60
63
  if (isStyleTarget(arg))
@@ -64,10 +64,10 @@ export default {
64
64
  }
65
65
  }
66
66
  const manifest = readManifest(context) ; if (!manifest) return {}
67
- const all = [...(manifest.permissions || []), ...(manifest.optional_permissions || [])]
67
+ const allPermissions = [...(manifest.permissions || []), ...(manifest.optional_permissions || [])]
68
68
  return {
69
69
  'Program:exit'() {
70
- for (const permission of all) {
70
+ for (const permission of allPermissions) {
71
71
  if (allowed.has(permission) || isValidPermission(permission, target)) continue
72
72
  context.report({ loc: { line: 1, column: 0 },
73
73
  messageId: 'invalidPermission', data: { permission, target }})
@@ -0,0 +1,91 @@
1
+ /* eslint-disable security/detect-non-literal-fs-filename -- lint-time project inspection only */
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ import { findExtensionRoot, isPredefined } from '../data/i18n.js'
7
+ import { readManifest } from '../utils.js'
8
+
9
+ const re = { manifestFile: /manifest\.json$/ }
10
+
11
+ function readMessages(filePath) {
12
+ try {
13
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'))
14
+ return (data && typeof data == 'object' && !Array.isArray(data)) ? data : null
15
+ } catch { return null }
16
+ }
17
+
18
+ function readLocaleDirs(localesDir) {
19
+ try {
20
+ return fs.readdirSync(localesDir, { withFileTypes: true })
21
+ .filter(entry => entry.isDirectory())
22
+ .map(entry => entry.name)
23
+ } catch { return [] }
24
+ }
25
+
26
+ export default {
27
+ meta: {
28
+ type: 'problem',
29
+ docs: {
30
+ description: 'Ensure `default_locale` is valid and all `_locales` keys exist in it',
31
+ category: 'Possible Errors',
32
+ recommended: false,
33
+ manifest: true
34
+ },
35
+ schema: [{
36
+ type: 'object',
37
+ properties: {
38
+ ignoreKeys: {
39
+ type: 'array', items: { type: 'string' },
40
+ description: 'Keys to skip when checking the default locale'
41
+ }
42
+ },
43
+ additionalProperties: false
44
+ }],
45
+ defaultOptions: [{ ignoreKeys: [] }],
46
+ messages: {
47
+ missingDefaultLocale: '`_locales/` exists but `default_locale` is not declared.',
48
+ unexpectedDefaultLocale: '`default_locale` is declared but no `_locales/` directory exists.',
49
+ missingDefaultLocaleFile: '`_locales/{{locale}}/messages.json` does not exist.',
50
+ missingKeyInDefault: '`{{key}}` exists in `_locales/{{locale}}/messages.json`, '
51
+ + 'but not in default locale `{{defaultLocale}}`.'
52
+ }
53
+ },
54
+
55
+ create(context) {
56
+ const filename = context.filename
57
+ if (!filename || !re.manifestFile.test(filename)) return {}
58
+ const manifest = readManifest(context) ; if (!manifest) return {}
59
+ const root = findExtensionRoot(filename) ; if (!root) return {}
60
+ const localesDir = path.join(root, '_locales'),
61
+ hasLocales = fs.existsSync(localesDir),
62
+ defaultLocale = manifest.default_locale,
63
+ opts = context.options[0] || {},
64
+ ignored = new Set(opts.ignoreKeys || []),
65
+ loc = { line: 1, column: 1 }
66
+ if (hasLocales && typeof defaultLocale != 'string') {
67
+ context.report({ loc, messageId: 'missingDefaultLocale' })
68
+ return {}
69
+ }
70
+ if (typeof defaultLocale == 'string' && !hasLocales) {
71
+ context.report({ loc, messageId: 'unexpectedDefaultLocale' })
72
+ return {}
73
+ }
74
+ if (typeof defaultLocale != 'string' || !hasLocales) return {}
75
+ const defaultMsgs = readMessages(path.join(localesDir, defaultLocale, 'messages.json'))
76
+ if (!defaultMsgs) {
77
+ context.report({ loc, messageId: 'missingDefaultLocaleFile', data: { locale: defaultLocale }})
78
+ return {}
79
+ }
80
+ for (const locale of readLocaleDirs(localesDir)) {
81
+ if (locale == defaultLocale) continue
82
+ const msgs = readMessages(path.join(localesDir, locale, 'messages.json')) ; if (!msgs) continue
83
+ for (const key of Object.keys(msgs)) {
84
+ if (isPredefined(key) || ignored.has(key)) continue
85
+ if (!(key in defaultMsgs))
86
+ context.report({ loc, messageId: 'missingKeyInDefault', data: { key, locale, defaultLocale }})
87
+ }
88
+ }
89
+ return {}
90
+ }
91
+ }