eslint-plugin-chrome-extensions 2.4.2 → 2.6.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 (47) hide show
  1. package/README.md +272 -115
  2. package/package.json +6 -8
  3. package/src/data/apis.js +166 -115
  4. package/src/data/deprecations.js +5 -7
  5. package/src/data/i18n.js +19 -69
  6. package/src/data/manifest.js +61 -57
  7. package/src/data/permissions.js +23 -30
  8. package/src/data/regex.js +10 -0
  9. package/src/index.js +7 -124
  10. package/src/rules/manifest-key-order.js +8 -8
  11. package/src/rules/manifest-spacing.js +5 -5
  12. package/src/rules/no-content-style-leak.js +6 -4
  13. package/src/rules/no-deprecated-apis.js +4 -4
  14. package/src/rules/no-duplicate-manifest-keys.js +3 -4
  15. package/src/rules/no-insecure-messaging.js +4 -4
  16. package/src/rules/no-invalid-locales.js +89 -0
  17. package/src/rules/no-invalid-permissions.js +10 -13
  18. package/src/rules/no-missing-default-locale.js +44 -21
  19. package/src/rules/no-missing-i18n-keys.js +24 -25
  20. package/src/rules/no-missing-permissions.js +7 -6
  21. package/src/rules/no-missing-web-accessible-resources.js +7 -6
  22. package/src/rules/no-remote-code.js +12 -12
  23. package/src/rules/no-sensitive-permissions.js +75 -0
  24. package/src/rules/no-unrestricted-host-permissions.js +35 -19
  25. package/src/rules/no-unused-i18n-keys.js +11 -10
  26. package/src/rules/no-unused-permissions.js +31 -23
  27. package/src/rules/require-license-file.js +4 -5
  28. package/src/rules/require-mv3.js +5 -6
  29. package/src/rules/require-short-name-when-useful.js +4 -3
  30. package/src/rules/valid-filenames.js +3 -10
  31. package/src/rules/valid-license-file.js +18 -11
  32. package/src/rules/valid-manifest-icons.js +35 -6
  33. package/src/rules/valid-mv3-schema.js +22 -22
  34. package/src/rules/valid-service-worker.js +34 -13
  35. package/src/rules/valid-url-fields.js +49 -41
  36. package/src/rules/valid-version-field.js +5 -9
  37. package/src/utils/extension.js +52 -33
  38. package/src/utils/i18n.js +58 -0
  39. package/src/utils/init.js +107 -0
  40. package/src/utils/license.js +56 -0
  41. package/src/utils/node.js +20 -19
  42. package/src/utils/pkg.js +12 -10
  43. package/src/utils/rules.js +6 -4
  44. package/src/utils/text.js +13 -0
  45. package/src/data/license.js +0 -28
  46. package/src/data/text-parser.js +0 -11
  47. package/src/utils/bundle.js +0 -10
@@ -1,31 +1,24 @@
1
- const re = { url: /^https?:\/\// }
2
-
3
- export const sensitivePermissions = new Set([
4
- 'accessibilityFeatures.modify', 'accessibilityFeatures.read', 'bookmarks', 'browsingData', 'clipboardRead',
5
- 'contentSettings', 'cookies', 'debugger', 'declarativeNetRequest', 'downloads', 'downloads.open', 'downloads.ui',
6
- 'geolocation', 'history', 'management', 'nativeMessaging', 'pageCapture', 'privacy', 'proxy', 'readingList',
7
- 'sessions', 'system.storage', 'tabCapture', 'tabGroups', 'tabs', 'topSites', 'ttsEngine',
8
- 'webAuthenticationProxy', 'webNavigation', 'webRequest', 'webRequestBlocking'
9
- ])
10
-
11
- export const validPermissions = new Set([
12
- 'accessibilityFeatures.modify', 'accessibilityFeatures.read', 'activeTab', 'alarms', 'audio', 'background',
13
- 'bookmarks', 'browsingData', 'certificateProvider', 'clipboardRead', 'clipboardWrite', 'contentSettings',
14
- 'contextMenus', 'cookies', 'debugger', 'declarativeContent', 'declarativeNetRequest',
15
- 'declarativeNetRequestFeedback', 'declarativeNetRequestWithHostAccess', 'desktopCapture', 'dns', 'documentScan',
16
- 'downloads', 'downloads.open', 'downloads.ui', 'enterprise.deviceAttributes', 'enterprise.hardwarePlatform',
17
- 'enterprise.networkingAttributes', 'enterprise.platformKeys', 'favicon', 'fileBrowserHandler',
18
- 'fileSystemProvider', 'fontSettings', 'gcm', 'geolocation', 'history', 'identity', 'identity.email', 'idle',
19
- 'loginState', 'management', 'menus', 'nativeMessaging', 'notifications', 'offscreen', 'pageCapture', 'platformKeys',
20
- 'power', 'printerProvider', 'printing', 'printingMetrics', 'privacy', 'processes', 'proxy', 'publicSuffix',
21
- 'readingList', 'runtime', 'scripting', 'search', 'sessions', 'sidePanel', 'storage', 'system.cpu',
22
- 'system.display', 'system.memory', 'system.storage', 'tabCapture', 'tabGroups', 'tabs', 'topSites', 'tts',
23
- 'ttsEngine', 'unlimitedStorage', 'userScripts', 'vpnProvider', 'wallpaper', 'webAuthenticationProxy',
24
- 'webNavigation', 'webRequest', 'webRequestBlocking'
25
- ])
26
-
27
- export function isValidPermission(name, target = 'mv3') {
28
- if (validPermissions.has(name)) return true
29
- if (target == 'mv3') return false
30
- return re.url.test(name) || name.startsWith('<') || name == '*://*/*'
1
+ export default {
2
+ sensitive: new Set([
3
+ 'accessibilityFeatures.modify', 'accessibilityFeatures.read', 'bookmarks', 'browsingData', 'clipboardRead',
4
+ 'contentSettings', 'cookies', 'debugger', 'declarativeNetRequest', 'downloads', 'downloads.open',
5
+ 'downloads.ui', 'geolocation', 'history', 'management', 'nativeMessaging', 'pageCapture', 'privacy',
6
+ 'proxy', 'readingList', 'sessions', 'system.storage', 'tabCapture', 'tabGroups', 'tabs', 'topSites',
7
+ 'ttsEngine', 'webAuthenticationProxy', 'webNavigation', 'webRequest', 'webRequestBlocking'
8
+ ]),
9
+ valid: new Set([
10
+ 'accessibilityFeatures.modify', 'accessibilityFeatures.read', 'activeTab', 'alarms', 'audio', 'background',
11
+ 'bookmarks', 'browsingData', 'certificateProvider', 'clipboardRead', 'clipboardWrite', 'contentSettings',
12
+ 'contextMenus', 'cookies', 'debugger', 'declarativeContent', 'declarativeNetRequest',
13
+ 'declarativeNetRequestFeedback', 'declarativeNetRequestWithHostAccess', 'desktopCapture', 'dns',
14
+ 'documentScan', 'downloads', 'downloads.open', 'downloads.ui', 'enterprise.deviceAttributes',
15
+ 'enterprise.hardwarePlatform', 'enterprise.networkingAttributes', 'enterprise.platformKeys', 'favicon',
16
+ 'fileBrowserHandler', 'fileSystemProvider', 'fontSettings', 'gcm', 'geolocation', 'history', 'identity',
17
+ 'identity.email', 'idle', 'loginState', 'management', 'menus', 'nativeMessaging', 'notifications',
18
+ 'offscreen', 'pageCapture', 'platformKeys', 'power', 'printerProvider', 'printing', 'printingMetrics',
19
+ 'privacy', 'processes', 'proxy', 'publicSuffix', 'readingList', 'runtime', 'scripting', 'search',
20
+ 'sessions', 'sidePanel', 'storage', 'system.cpu', 'system.display', 'system.memory', 'system.storage',
21
+ 'tabCapture', 'tabGroups', 'tabs', 'topSites', 'tts', 'ttsEngine', 'unlimitedStorage', 'userScripts',
22
+ 'vpnProvider', 'wallpaper', 'webAuthenticationProxy', 'webNavigation', 'webRequest', 'webRequestBlocking'
23
+ ])
31
24
  }
@@ -0,0 +1,10 @@
1
+ export default {
2
+ filename: {
3
+ css: /\.css$/i,
4
+ license: // eslint-disable-next-line security/detect-unsafe-regex
5
+ /^(?:[\w.-]*-)?(?:LICEN[SC]E|COPYING)(?:[-.][\w.-]+)?$/i,
6
+ manifest: /manifest\.json$/,
7
+ script: /\.[cm]?[jt]sx?$/i
8
+ },
9
+ url: /^https?:\/\//
10
+ }
package/src/index.js CHANGED
@@ -1,129 +1,12 @@
1
- import { bundles as oldBundles } from './data/deprecations.js'
1
+ import init from './utils/init.js'
2
+ import deprecations from './data/deprecations.js'
2
3
 
3
- import css from '@eslint/css'
4
- import html from '@html-eslint/eslint-plugin'
5
- import json from '@eslint/json'
4
+ const plugin = init.plugin()
6
5
 
7
- import manifestKeyOrder from './rules/manifest-key-order.js'
8
- import manifestSpacing from './rules/manifest-spacing.js'
9
- import noContentStyleLeak from './rules/no-content-style-leak.js'
10
- import noDeprecatedAPIs from './rules/no-deprecated-apis.js'
11
- import noDuplicateManifestKeys from './rules/no-duplicate-manifest-keys.js'
12
- import noInsecureFetch from './rules/no-insecure-fetch.js'
13
- import noInsecureMessaging from './rules/no-insecure-messaging.js'
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'
17
- import noMissingPermissions from './rules/no-missing-permissions.js'
18
- import noMissingWebAccessibleResources from './rules/no-missing-web-accessible-resources.js'
19
- import noRemoteCode from './rules/no-remote-code.js'
20
- import noUnrestrictedHostPermissions from './rules/no-unrestricted-host-permissions.js'
21
- import noUnusedI18nKeys from './rules/no-unused-i18n-keys.js'
22
- import noUnusedPermissions from './rules/no-unused-permissions.js'
23
- import requireLicenseFile from './rules/require-license-file.js'
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'
28
- import validManifestIcons from './rules/valid-manifest-icons.js'
29
- import validMV3schema from './rules/valid-mv3-schema.js'
30
- import validServiceWorker from './rules/valid-service-worker.js'
31
- import validURLfields from './rules/valid-url-fields.js'
32
- import validVersionField from './rules/valid-version-field.js'
6
+ init.rules(plugin)
7
+ init.configs(plugin)
33
8
 
34
- import { bundle as bundleRules } from './utils/rules.js'
35
- import { initDeprecation as initBundleDeprecation } from './utils/bundle.js'
36
- import { textParser } from './data/text-parser.js'
37
-
38
- const plugin = {
39
- rules: {
40
- 'manifest-key-order': manifestKeyOrder,
41
- 'manifest-spacing': manifestSpacing,
42
- 'no-content-style-leak': noContentStyleLeak,
43
- 'no-deprecated-apis': noDeprecatedAPIs,
44
- 'no-duplicate-manifest-keys': noDuplicateManifestKeys,
45
- 'no-insecure-fetch': noInsecureFetch,
46
- 'no-insecure-messaging': noInsecureMessaging,
47
- 'no-invalid-permissions': noInvalidPermissions,
48
- 'no-missing-default-locale': noMissingDefaultLocale,
49
- 'no-missing-i18n-keys': noMissingI18nKeys,
50
- 'no-missing-permissions': noMissingPermissions,
51
- 'no-missing-web-accessible-resources': noMissingWebAccessibleResources,
52
- 'no-remote-code': noRemoteCode,
53
- 'no-unrestricted-host-permissions': noUnrestrictedHostPermissions,
54
- 'no-unused-i18n-keys': noUnusedI18nKeys,
55
- 'no-unused-permissions': noUnusedPermissions,
56
- 'require-mv3': requireMV3,
57
- 'require-license-file': requireLicenseFile,
58
- 'require-short-name-when-useful': requireShortNameWhenUseful,
59
- 'valid-filenames': validFilenames,
60
- 'valid-license-file': validLicenseFile,
61
- 'valid-manifest-icons': validManifestIcons,
62
- 'valid-mv3-schema': validMV3schema,
63
- 'valid-service-worker': validServiceWorker,
64
- 'valid-url-fields': validURLfields,
65
- 'valid-version-field': validVersionField
66
- }
67
- }
68
-
69
- for (const [ruleId, rule] of Object.entries(plugin.rules))
70
- rule.meta.docs.url = `https://codeberg.org/adamlui/eslint-plugin-chrome-extensions/#${ruleId}`
71
-
72
- plugin.configs = {
73
- recommended: {
74
- plugins: { 'chrome-extensions': plugin },
75
- rules: bundleRules(plugin.rules, rule => rule.meta.docs.recommended, 'chrome-extensions')
76
- },
77
- strict: {
78
- plugins: { 'chrome-extensions': plugin },
79
- rules: bundleRules(plugin.rules, () => true, 'chrome-extensions')
80
- },
81
- problem: {
82
- plugins: { 'chrome-extensions': plugin },
83
- rules: bundleRules(plugin.rules, rule => rule.meta.type == 'problem', 'chrome-extensions')
84
- },
85
- security: {
86
- plugins: { 'chrome-extensions': plugin },
87
- rules: bundleRules(plugin.rules, rule => rule.meta.docs.security, 'chrome-extensions')
88
- },
89
- manifest: {
90
- language: 'json/json',
91
- plugins: { 'chrome-extensions': plugin, json },
92
- rules: bundleRules(plugin.rules, rule => rule.meta.docs.manifest, 'chrome-extensions')
93
- },
94
- license: {
95
- languageOptions: { parser: textParser },
96
- plugins: { 'chrome-extensions': plugin },
97
- rules: bundleRules(plugin.rules, rule => rule.meta.docs.license, 'chrome-extensions')
98
- },
99
- css: {
100
- language: 'css/css',
101
- plugins: { 'chrome-extensions': plugin, css },
102
- rules: bundleRules(plugin.rules, rule => rule.meta.docs.css, 'chrome-extensions')
103
- },
104
- html: {
105
- language: 'html/html',
106
- plugins: { 'chrome-extensions': plugin, html },
107
- rules: bundleRules(plugin.rules, rule => rule.meta.docs.html, 'chrome-extensions')
108
- },
109
- json: {
110
- language: 'json/json',
111
- plugins: { 'chrome-extensions': plugin, json },
112
- rules: bundleRules(plugin.rules, rule => rule.meta.docs.json, 'chrome-extensions')
113
- },
114
- json5: {
115
- language: 'json/json5',
116
- plugins: { 'chrome-extensions': plugin, json },
117
- rules: bundleRules(plugin.rules, rule => rule.meta.docs.json, 'chrome-extensions')
118
- },
119
- jsonc: {
120
- language: 'json/jsonc',
121
- plugins: { 'chrome-extensions': plugin, json },
122
- rules: bundleRules(plugin.rules, rule => rule.meta.docs.json, 'chrome-extensions')
123
- }
124
- }
125
-
126
- for (const entry of Object.entries(oldBundles))
127
- initBundleDeprecation(plugin, entry, 'chrome-extensions')
9
+ for (const entry of Object.entries(deprecations.bundles))
10
+ init.bundleDeprecation(plugin, entry, 'chrome-extensions')
128
11
 
129
12
  export { plugin as default, plugin as chromeExtensions }
@@ -1,7 +1,7 @@
1
- import { manifestKeyOrder } from '../data/manifest.js'
2
- import { file } from '../utils/extension.js'
1
+ import extension from '../utils/extension.js'
3
2
 
4
- const re = { manifestFilename: /manifest\.json$/ }
3
+ import manifest from '../data/manifest.js'
4
+ import regex from '../data/regex.js'
5
5
 
6
6
  function keyOf(member) {
7
7
  const keyNode = member.name || member.key
@@ -43,15 +43,15 @@ export default {
43
43
  },
44
44
 
45
45
  create(context) {
46
- if (!re.manifestFilename.test(file.getName(context))) return {}
46
+ if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
47
47
  const custom = context.options[0]?.order ?? [],
48
- master = custom.length ? manifestKeyOrder.filter(key => !custom.includes(key))
49
- : manifestKeyOrder
48
+ master = custom.length ? manifest.keys.order.filter(key => !custom.includes(key))
49
+ : manifest.keys.order
50
50
  return {
51
51
  'Document:exit'(document) {
52
52
  const root = document.body ; if (root?.type != 'Object') return
53
- const members = root.members || root.properties || []
54
- const violation = findViolation(members, master) ?? (custom.length && findViolation(members, custom))
53
+ const members = root.members || root.properties || [],
54
+ violation = findViolation(members, master) ?? (custom.length && findViolation(members, custom))
55
55
  if (!violation) return
56
56
  const [first, second] = violation
57
57
  context.report({
@@ -1,6 +1,7 @@
1
- import { file } from '../utils/extension.js'
1
+ import extension from '../utils/extension.js'
2
+ import regex from '../data/regex.js'
2
3
 
3
- const re = { manifestFilename: /manifest\.json$/, leadingWhitespace: /^[ \t]+/ },
4
+ const re = { leadingWhitespace: /^[ \t]+/ },
4
5
  brackets = { open: new Set(['LBrace', 'LBracket']), close: new Set(['RBrace', 'RBracket']) }
5
6
 
6
7
  export default {
@@ -39,8 +40,7 @@ export default {
39
40
  },
40
41
  additionalProperties: false
41
42
  }],
42
- defaultOptions: [{ spaces: 2, checkColons: true, checkCommas: true,
43
- checkBraces: true, checkBrackets: true }],
43
+ defaultOptions: [{ spaces: 2, checkColons: true, checkCommas: true, checkBraces: true, checkBrackets: true }],
44
44
  messages: {
45
45
  wrongIndent: 'Expected {{expected}} spaces of indentation (found {{found}}).',
46
46
  tabFound: 'Expected spaces for indentation, found tabs.',
@@ -57,7 +57,7 @@ export default {
57
57
  },
58
58
 
59
59
  create(context) {
60
- if (!re.manifestFilename.test(file.getName(context))) return {}
60
+ if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
61
61
  const opts = context.options[0] || {},
62
62
  spaces = opts.spaces ?? 2,
63
63
  checkColons = opts.checkColons ?? true,
@@ -1,4 +1,4 @@
1
- import { file, readManifest } from '../utils/extension.js'
1
+ import extension from '../utils/extension.js'
2
2
 
3
3
  const re = { appendMethod: /^append(?:Child)?$/ }
4
4
 
@@ -17,7 +17,8 @@ export default {
17
17
  },
18
18
 
19
19
  create(context) {
20
- const manifest = readManifest(context) ; if (!manifest || !file.isContentScript(context, manifest)) return {}
20
+ const manifest = extension.manifest.read(context)
21
+ if (!manifest || !extension.file.isContentScript(context, manifest)) return {}
21
22
  const styleElems = new Set(), docAliases = new Set()
22
23
  function isCreateStyle(node) {
23
24
  return node?.type == 'CallExpression'
@@ -57,8 +58,9 @@ export default {
57
58
  CallExpression(node) {
58
59
  if (node.callee.type != 'MemberExpression') return
59
60
  const method = node.callee.property.name
60
- if (!re.appendMethod.test(method)) return
61
- if (!isDocumentMember(node.callee.object)) return
61
+ if (!re.appendMethod.test(method)
62
+ || !isDocumentMember(node.callee.object)
63
+ ) return
62
64
  for (const arg of node.arguments)
63
65
  if (isStyleTarget(arg))
64
66
  context.report({ node: arg, messageId: 'styleLeak' })
@@ -1,5 +1,5 @@
1
- import { findDeprecated } from '../data/apis.js'
2
- import { getMemberPath } from '../utils/node.js'
1
+ import nodeUtils from '../utils/node.js'
2
+ import apis from '../data/apis.js'
3
3
 
4
4
  export default {
5
5
  meta: {
@@ -50,8 +50,8 @@ export default {
50
50
  return {
51
51
  MemberExpression(node) {
52
52
  if (node.parent.type == 'MemberExpression' && node.parent.object === node) return
53
- const api = getMemberPath(node) ; if (!api?.startsWith('chrome.') || isAllowed(api)) return
54
- const found = findDeprecated(api) ; if (!found) return
53
+ const api = nodeUtils.getMemberPath(node) ; if (!api?.startsWith('chrome.') || isAllowed(api)) return
54
+ const found = apis.findDeprecated(api) ; if (!found) return
55
55
  context.report({ node, messageId: 'deprecatedAPI',
56
56
  data: { api: found.deprecated, replacement: found.replacement, target }})
57
57
  }
@@ -1,6 +1,5 @@
1
- import { file } from '../utils/extension.js'
2
-
3
- const re = { manifestFilename: /manifest\.json$/ }
1
+ import extension from '../utils/extension.js'
2
+ import regex from '../data/regex.js'
4
3
 
5
4
  export default {
6
5
  meta: {
@@ -18,7 +17,7 @@ export default {
18
17
  },
19
18
 
20
19
  create(context) {
21
- if (!re.manifestFilename.test(file.getName(context))) return {}
20
+ if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
22
21
  return {
23
22
  Object(node) {
24
23
  const members = node.members || node.properties || [],
@@ -1,5 +1,5 @@
1
- import { getMemberPath } from '../utils/node.js'
2
- import { isExternalMessagingListener } from '../data/apis.js'
1
+ import nodeUtils from '../utils/node.js'
2
+ import apis from '../data/apis.js'
3
3
 
4
4
  const re = { postmsgValidation: /\b\w+\.(?:origin|source)\b/, senderValidation: /sender\.(?:id|origin)/ }
5
5
 
@@ -44,9 +44,9 @@ export default {
44
44
  return {
45
45
  CallExpression(node) {
46
46
  if (node.callee.type != 'MemberExpression') return
47
- const path = getMemberPath(node.callee) ; if (!path) return
47
+ const path = nodeUtils.getMemberPath(node.callee) ; if (!path) return
48
48
  const listenerRoot = path.endsWith('.addListener') ? path.slice(0, -'.addListener'.length) : null
49
- if (listenerRoot && isExternalMessagingListener(listenerRoot)) {
49
+ if (listenerRoot && apis.isExternalMsgListener(listenerRoot)) {
50
50
  const handler = node.arguments[0] ; if (!handler) return
51
51
  const body = context.sourceCode.getText(handler)
52
52
  if (!re.senderValidation.test(body))
@@ -0,0 +1,89 @@
1
+ /* eslint-disable security/detect-non-literal-fs-filename -- lint-time project inspection only */
2
+
3
+ import extension from '../utils/extension.js'
4
+ import fs from 'node:fs'
5
+ import i18nUtils from '../utils/i18n.js'
6
+ import path from 'node:path'
7
+
8
+ import regex from '../data/regex.js'
9
+
10
+ export default {
11
+ meta: {
12
+ type: 'problem',
13
+ docs: {
14
+ description: 'Disallow invalid Chrome locale codes in `_locales/` folders and `default_locale`',
15
+ category: 'Possible Errors',
16
+ recommended: true,
17
+ manifest: true
18
+ },
19
+ schema: [{
20
+ type: 'object',
21
+ properties: {
22
+ allowedLocales: {
23
+ oneOf: [
24
+ { type: 'string', description: 'Single locale code to allow' },
25
+ {
26
+ type: 'array',
27
+ items: { type: 'string', description: 'Locale code to allow' },
28
+ description: 'List of locale codes to allow'
29
+ }
30
+ ],
31
+ description: 'Additional locale codes to allow (e.g. for Chromium forks)'
32
+ }
33
+ },
34
+ additionalProperties: false
35
+ }],
36
+ defaultOptions: [{ allowedLocales: [] }],
37
+ messages: {
38
+ invalidLocale: '`{{value}}` is not a Chrome-supported locale code.'
39
+ }
40
+ },
41
+
42
+ create(context) {
43
+ const filename = extension.file.getName(context),
44
+ isManifest = regex.filename.manifest.test(filename),
45
+ isScript = regex.filename.script.test(filename)
46
+ if (!isManifest && !isScript) return {}
47
+ const opts = context.options[0] || {},
48
+ allowed = new Set(Array.isArray(opts.allowedLocales) ? opts.allowedLocales
49
+ : opts.allowedLocales ? [opts.allowedLocales] : []),
50
+ invalidFolders = [],
51
+ root = extension.findRoot(context)
52
+ if (root) {
53
+ const localesDir = path.join(root, '_locales')
54
+ try {
55
+ for (const entry of fs.readdirSync(localesDir, { withFileTypes: true }))
56
+ if (entry.isDirectory() && isInvalid(entry.name)) invalidFolders.push(entry.name)
57
+ invalidFolders.sort()
58
+ } catch {} // eslint-disable-line no-empty
59
+ }
60
+ const loc = { line: 1, column: 1 }
61
+ if (!isManifest) {
62
+ const manifest = extension.manifest.read(context)
63
+ if (manifest) {
64
+ const defaultLocale = manifest.default_locale
65
+ if (isInvalid(defaultLocale))
66
+ context.report({ loc, messageId: 'invalidLocale', data: { value: defaultLocale }})
67
+ }
68
+ for (const name of invalidFolders)
69
+ context.report({ loc, messageId: 'invalidLocale', data: { value: name }})
70
+ return {}
71
+ }
72
+ function isInvalid(locale) {
73
+ return typeof locale == 'string' && !i18nUtils.localeIsValid(locale, allowed) }
74
+ return {
75
+ '*'(node) {
76
+ const keyNode = node.name || node.key ; if (!keyNode) return
77
+ const keyName = keyNode.value ?? keyNode.name ; if (keyName != 'default_locale') return
78
+ const valueNode = node.value ; if (valueNode?.type != 'String') return
79
+ if (isInvalid(valueNode.value))
80
+ context.report({ node: valueNode, messageId: 'invalidLocale',
81
+ data: { value: valueNode.value }})
82
+ },
83
+ 'Document:exit'() {
84
+ for (const name of invalidFolders)
85
+ context.report({ loc, messageId: 'invalidLocale', data: { value: name }})
86
+ }
87
+ }
88
+ }
89
+ }
@@ -1,7 +1,5 @@
1
- import { file, readManifest } from '../utils/extension.js'
2
- import { isValidPermission } from '../data/permissions.js'
3
-
4
- const re = { manifestFilename: /manifest\.json$/ }
1
+ import extension from '../utils/extension.js'
2
+ import regex from '../data/regex.js'
5
3
 
6
4
  export default {
7
5
  meta: {
@@ -9,7 +7,7 @@ export default {
9
7
  docs: {
10
8
  description: 'Disallow invalid or unsupported manifest permission names',
11
9
  category: 'Possible Errors',
12
- recommended: false,
10
+ recommended: true,
13
11
  manifest: true
14
12
  },
15
13
  defaultOptions: [{ target: 'mv3', allowedPermissions: [] }],
@@ -46,29 +44,28 @@ export default {
46
44
  : opts.allowedPermissions ? [opts.allowedPermissions] : [])
47
45
  function checkPermission(node, permission) {
48
46
  if (typeof permission != 'string') return
49
- if (allowed.has(permission) || isValidPermission(permission, target)) return
47
+ if (allowed.has(permission) || extension.manifest.permission.isValid(permission, target)) return
50
48
  context.report({ node, messageId: 'invalidPermission', data: { permission, target }})
51
49
  }
52
- if (re.manifestFilename.test(file.getName(context))) // lint it directly
50
+ if (regex.filename.manifest.test(extension.file.getName(context))) // lint it directly
53
51
  return {
54
52
  '*'(node) {
55
53
  const keyNode = node.name || node.key ; if (!keyNode) return
56
- const keyName = keyNode.value ?? keyNode.name
57
- if (keyName != 'permissions' && keyName != 'optional_permissions') return
54
+ if (!['optional_permissions', 'permissions'].includes(keyNode.value ?? keyNode.name)) return
58
55
  const arrNode = node.value ; if (arrNode?.type != 'Array') return
59
- const elems = arrNode.elements || arrNode.items || []
60
- for (const elem of elems) {
56
+ const nodeElems = arrNode.elements || arrNode.items || []
57
+ for (const elem of nodeElems) {
61
58
  const valueNode = elem.value || elem
62
59
  checkPermission(valueNode, valueNode?.value)
63
60
  }
64
61
  }
65
62
  }
66
- const manifest = readManifest(context) ; if (!manifest) return {}
63
+ const manifest = extension.manifest.read(context) ; if (!manifest) return {}
67
64
  const allPermissions = [...(manifest.permissions || []), ...(manifest.optional_permissions || [])]
68
65
  return {
69
66
  'Program:exit'() {
70
67
  for (const permission of allPermissions) {
71
- if (allowed.has(permission) || isValidPermission(permission, target)) continue
68
+ if (allowed.has(permission) || extension.manifest.permission.isValid(permission, target)) continue
72
69
  context.report({ loc: { line: 1, column: 0 },
73
70
  messageId: 'invalidPermission', data: { permission, target }})
74
71
  }
@@ -1,12 +1,19 @@
1
1
  /* eslint-disable security/detect-non-literal-fs-filename -- lint-time project inspection only */
2
2
 
3
+ import extension from '../utils/extension.js'
3
4
  import fs from 'node:fs'
5
+ import i18n from '../utils/i18n.js'
4
6
  import path from 'node:path'
5
7
 
6
- import { findExtensionRoot, isPredefined } from '../data/i18n.js'
7
- import { readManifest } from '../utils/extension.js'
8
+ import regex from '../data/regex.js'
8
9
 
9
- const re = { manifestFile: /manifest\.json$/ }
10
+ function readLocaleDirs(localesDir) {
11
+ try {
12
+ return fs.readdirSync(localesDir, { withFileTypes: true })
13
+ .filter(entry => entry.isDirectory())
14
+ .map(entry => entry.name)
15
+ } catch { return [] }
16
+ }
10
17
 
11
18
  function readMessages(filePath) {
12
19
  try {
@@ -15,21 +22,13 @@ function readMessages(filePath) {
15
22
  } catch { return null }
16
23
  }
17
24
 
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
25
  export default {
27
26
  meta: {
28
27
  type: 'problem',
29
28
  docs: {
30
29
  description: 'Ensure `default_locale` is valid and all `_locales` keys exist in it',
31
30
  category: 'Possible Errors',
32
- recommended: false,
31
+ recommended: true,
33
32
  manifest: true
34
33
  },
35
34
  schema: [{
@@ -37,31 +36,37 @@ export default {
37
36
  properties: {
38
37
  ignoreKeys: {
39
38
  type: 'array', items: { type: 'string' },
40
- description: 'Keys to skip when checking the default locale'
39
+ description: 'Keys to skip when checking locales'
40
+ },
41
+ checkAllLocales: {
42
+ type: 'boolean',
43
+ description: 'Also flag keys missing from any locale, not just the default'
41
44
  }
42
45
  },
43
46
  additionalProperties: false
44
47
  }],
45
- defaultOptions: [{ ignoreKeys: [] }],
48
+ defaultOptions: [{ ignoreKeys: [], checkAllLocales: false }],
46
49
  messages: {
47
50
  missingDefaultLocale: '`_locales/` exists but `default_locale` is not declared.',
48
51
  unexpectedDefaultLocale: '`default_locale` is declared but no `_locales/` directory exists.',
49
52
  missingDefaultLocaleFile: '`_locales/{{locale}}/messages.json` does not exist.',
50
53
  missingKeyInDefault: '`{{key}}` exists in `_locales/{{locale}}/messages.json`, '
51
- + 'but not in default locale `{{defaultLocale}}`.'
54
+ + 'but not in default locale `{{defaultLocale}}`.',
55
+ missingKeyInLocale: '`{{key}}` is missing from `_locales/{{locale}}/messages.json`.'
52
56
  }
53
57
  },
54
58
 
55
59
  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 filename = extension.file.getName(context) ; if (!filename) return {}
61
+ if (!regex.filename.manifest.test(filename) && !regex.filename.script.test(filename)) return {}
62
+ const manifest = extension.manifest.read(context) ; if (!manifest) return {}
63
+ const root = extension.findRoot(context) ; if (!root) return {}
60
64
  const localesDir = path.join(root, '_locales'),
61
65
  hasLocales = fs.existsSync(localesDir),
62
66
  defaultLocale = manifest.default_locale,
63
67
  opts = context.options[0] || {},
64
68
  ignored = new Set(opts.ignoreKeys || []),
69
+ checkAllLocales = opts.checkAllLocales ?? false,
65
70
  loc = { line: 1, column: 1 }
66
71
  if (hasLocales && typeof defaultLocale != 'string') {
67
72
  context.report({ loc, messageId: 'missingDefaultLocale' })
@@ -77,11 +82,29 @@ export default {
77
82
  context.report({ loc, messageId: 'missingDefaultLocaleFile', data: { locale: defaultLocale }})
78
83
  return {}
79
84
  }
80
- for (const locale of readLocaleDirs(localesDir)) {
85
+ const localeDirs = readLocaleDirs(localesDir)
86
+ if (checkAllLocales) {
87
+ const allMsgs = new Map([[defaultLocale, defaultMsgs]])
88
+ for (const locale of localeDirs) {
89
+ if (locale == defaultLocale) continue
90
+ const msgs = readMessages(path.join(localesDir, locale, 'messages.json'))
91
+ if (msgs) allMsgs.set(locale, msgs)
92
+ }
93
+ const union = new Set()
94
+ for (const msgs of allMsgs.values())
95
+ for (const key of Object.keys(msgs))
96
+ if (!i18n.isPredefined(key) && !ignored.has(key)) union.add(key)
97
+ for (const [locale, msgs] of allMsgs)
98
+ for (const key of union)
99
+ if (!(key in msgs))
100
+ context.report({ loc, messageId: 'missingKeyInLocale', data: { key, locale }})
101
+ return {}
102
+ }
103
+ for (const locale of localeDirs) {
81
104
  if (locale == defaultLocale) continue
82
105
  const msgs = readMessages(path.join(localesDir, locale, 'messages.json')) ; if (!msgs) continue
83
106
  for (const key of Object.keys(msgs)) {
84
- if (isPredefined(key) || ignored.has(key)) continue
107
+ if (i18n.isPredefined(key) || ignored.has(key)) continue
85
108
  if (!(key in defaultMsgs))
86
109
  context.report({ loc, messageId: 'missingKeyInDefault', data: { key, locale, defaultLocale }})
87
110
  }