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.
@@ -0,0 +1,72 @@
1
+ import { getMemberPath, readManifest } from '../utils.js'
2
+ import { findExtensionRoot, isPredefined, readDefaultLocaleMessages, re as i18nRe } from '../data/i18n.js'
3
+
4
+ const re = { file: { js: /\.(cjs|js|jsx|mjs|ts|tsx)$/i, manifest: /manifest\.json$/, text: /\.(css|htm|html)$/i }}
5
+
6
+ export default {
7
+ meta: {
8
+ type: 'problem',
9
+ docs: {
10
+ description: 'Flag i18n keys referenced in code but missing from `_locales/<default_locale>/messages.json`',
11
+ category: 'Possible Errors',
12
+ recommended: true,
13
+ manifest: true,
14
+ css: true,
15
+ html: true,
16
+ json: true
17
+ },
18
+ schema: [{
19
+ type: 'object',
20
+ properties: {
21
+ ignoreKeys: {
22
+ type: 'array', items: { type: 'string' },
23
+ description: 'Keys to skip (e.g. `["myKey"]`)'
24
+ }
25
+ },
26
+ additionalProperties: false
27
+ }],
28
+ defaultOptions: [{ ignoreKeys: [] }],
29
+ messages: {
30
+ missingKey: '`{{key}}` is not in `_locales/{{locale}}/messages.json`.'
31
+ }
32
+ },
33
+
34
+ create(context) {
35
+ const filename = context.filename
36
+ if (!filename || filename == '<input>') return {}
37
+ const manifest = readManifest(context) ; if (!manifest?.default_locale) return {}
38
+ const root = findExtensionRoot(filename) ; if (!root) return {}
39
+ const i18n = readDefaultLocaleMessages(manifest, root) ; if (!i18n) return {}
40
+ const opts = context.options[0] || {},
41
+ ignored = new Set(opts.ignoreKeys || []),
42
+ isMissing = key => !isPredefined(key) && !ignored.has(key) && !(key in i18n.messages),
43
+ loc = { line: 1, column: 1 }
44
+ if (re.file.js.test(filename))
45
+ return {
46
+ CallExpression(node) {
47
+ if (node.callee.type != 'MemberExpression') return
48
+ const path = getMemberPath(node.callee)
49
+ if (path != 'chrome.i18n.getMessage' && path != 'browser.i18n.getMessage') return
50
+ const arg = node.arguments[0] ; if (!arg) return
51
+ let key
52
+ if (arg.type == 'Literal' && typeof arg.value == 'string') key = arg.value
53
+ else if (arg.type == 'TemplateLiteral' && arg.expressions.length == 0)
54
+ key = arg.quasis[0].value.cooked
55
+ if (key && isMissing(key))
56
+ context.report({ loc, messageId: 'missingKey', data: { key, locale: i18n.locale }})
57
+ }
58
+ }
59
+ if (!re.file.text.test(filename) && !re.file.manifest.test(filename)) return {}
60
+ const seen = new Set()
61
+ const scanPlaceholders = () => {
62
+ for (const match of context.sourceCode.text.matchAll(i18nRe.msgPlaceholder)) {
63
+ const key = match[1]
64
+ if (seen.has(key)) continue
65
+ seen.add(key)
66
+ if (isMissing(key))
67
+ context.report({ loc, messageId: 'missingKey', data: { key, locale: i18n.locale }})
68
+ }
69
+ }
70
+ return { 'Document': scanPlaceholders, 'Program': scanPlaceholders }
71
+ }
72
+ }
@@ -1,5 +1,14 @@
1
1
  import { findPermission } from '../data/apis.js'
2
- import { readManifest, getMemberPath } from '../utils.js'
2
+ import { getMemberPath, readManifest } from '../utils.js'
3
+
4
+ function queriesActiveTab(node) {
5
+ const arg = node.arguments[0] ; if (arg?.type != 'ObjectExpression') return false
6
+ return arg.properties.some(prop => {
7
+ if (prop.type != 'Property') return false
8
+ const key = prop.key?.name ?? prop.key?.value
9
+ return key == 'active' && prop.value?.type == 'Literal' && prop.value.value === true
10
+ })
11
+ }
3
12
 
4
13
  export default {
5
14
  meta: {
@@ -11,7 +20,8 @@ export default {
11
20
  },
12
21
  schema: [],
13
22
  messages: {
14
- missingPermission: '`{{api}}` requires the "{{permission}}" manifest permission.'
23
+ missingPermission: '`{{api}}` requires the "{{permission}}" manifest permission.',
24
+ missingActiveTab: '`{{api}}` requires the "activeTab" manifest permission.'
15
25
  }
16
26
  },
17
27
 
@@ -23,7 +33,10 @@ export default {
23
33
  if (node.callee.type != 'MemberExpression') return
24
34
  const api = getMemberPath(node.callee) ; if (!api) return
25
35
  const permission = findPermission(api) ; if (!permission || declared.has(permission)) return
26
- context.report({ node, messageId: 'missingPermission', data: { api, permission }})
36
+ const isActiveTabQuery = permission == 'tabs' && queriesActiveTab(node)
37
+ if (isActiveTabQuery && declared.has('activeTab')) return
38
+ const messageId = `missing${ isActiveTabQuery ? 'ActiveTab' : 'Permission' }`
39
+ context.report({ node, messageId, data: { api, permission }})
27
40
  }
28
41
  }
29
42
  }
@@ -2,7 +2,7 @@ import { getMemberPath, isContentScriptFile, readManifest } from '../utils.js'
2
2
 
3
3
  const re = { leadingSlash: /^\//, regexSpecial: /[.+?^${}()|[\]\\]/g }
4
4
 
5
- function collectWarPatterns(manifest) {
5
+ function collectWARpatterns(manifest) {
6
6
  const war = manifest.web_accessible_resources
7
7
  if (!Array.isArray(war)) return []
8
8
  const patterns = []
@@ -29,14 +29,14 @@ export default {
29
29
  },
30
30
  schema: [],
31
31
  messages: {
32
- missingWar: '`{{path}}` is not in `web_accessible_resources`.'
32
+ missingWAR: '`{{path}}` is not in `web_accessible_resources`.'
33
33
  }
34
34
  },
35
35
 
36
36
  create(context) {
37
37
  const manifest = readManifest(context) ; if (!manifest) return {}
38
38
  if (!isContentScriptFile(context, manifest)) return {}
39
- const regexes = collectWarPatterns(manifest).map(patternToRegex)
39
+ const regexes = collectWARpatterns(manifest).map(patternToRegex)
40
40
  return {
41
41
  CallExpression(node) {
42
42
  if (node.callee.type != 'MemberExpression') return
@@ -44,7 +44,7 @@ export default {
44
44
  const arg = node.arguments[0] ; if (arg?.type != 'Literal' || typeof arg.value != 'string') return
45
45
  const resourcePath = arg.value.replace(re.leadingSlash, '')
46
46
  for (const regex of regexes) if (regex.test(resourcePath)) return
47
- context.report({ node: arg, messageId: 'missingWar', data: { path: arg.value }})
47
+ context.report({ node: arg, messageId: 'missingWAR', data: { path: arg.value }})
48
48
  }
49
49
  }
50
50
  }
@@ -1,5 +1,4 @@
1
1
  import { remoteCodePatterns } from '../data/manifest.js'
2
-
3
2
  import { getMemberPath } from '../utils.js'
4
3
 
5
4
  const re = { url: /^https?:\/\// }
@@ -49,16 +48,16 @@ export default {
49
48
  patterns = new Set(opts.patterns || remoteCodePatterns),
50
49
  checkClosures = opts.checkExecuteScriptClosures !== false,
51
50
  scriptElems = new Set()
52
- function isLiteralUrl(node) {
53
- return node?.type == 'Literal'
54
- && typeof node.value == 'string'
55
- && re.url.test(node.value)
56
- }
57
51
  function isCreateScript(node) {
58
52
  return node?.type == 'CallExpression'
59
53
  && node.callee?.property?.name == 'createElement'
60
54
  && node.arguments[0]?.value == 'script'
61
55
  }
56
+ function isLiteralUrl(node) {
57
+ return node?.type == 'Literal'
58
+ && typeof node.value == 'string'
59
+ && re.url.test(node.value)
60
+ }
62
61
  function isObjectAssign(node) {
63
62
  return node?.type == 'CallExpression'
64
63
  && node.callee?.type == 'MemberExpression'
@@ -25,7 +25,8 @@ export default {
25
25
  additionalProperties: false
26
26
  }],
27
27
  messages: {
28
- unrestrictedHost: 'Host permission "{{pattern}}" grants access to every site. Narrow to specific origins or allow explicitly instead.'
28
+ unrestrictedHost: 'Host permission "{{pattern}}" grants access to every site. '
29
+ + 'Narrow to specific origins or allow explicitly instead.'
29
30
  }
30
31
  },
31
32
 
@@ -38,9 +39,9 @@ export default {
38
39
  const keyName = keyNode.value ?? keyNode.name
39
40
  if (!hostKeys.has(keyName)) return
40
41
  const arrNode = node.value ; if (arrNode?.type != 'Array') return
41
- const elements = arrNode.elements || arrNode.items || []
42
- for (const el of elements) {
43
- const valueNode = el.value || el,
42
+ const elems = arrNode.elements || arrNode.items || []
43
+ for (const elem of elems) {
44
+ const valueNode = elem.value || elem,
44
45
  pattern = valueNode?.value
45
46
  if (typeof pattern != 'string'
46
47
  || allowed.has(pattern)
@@ -0,0 +1,54 @@
1
+ import { readManifest } from '../utils.js'
2
+ import { collectUsedKeys, defaultScanExtensions, findExtensionRoot, readDefaultLocaleMessages } from '../data/i18n.js'
3
+
4
+ const re = { manifestFile: /manifest\.json$/ }
5
+
6
+ export default {
7
+ meta: {
8
+ type: 'suggestion',
9
+ docs: {
10
+ description: 'Flag i18n keys in `messages.json` that are never referenced',
11
+ category: 'Stylistic Issues',
12
+ recommended: false,
13
+ manifest: true
14
+ },
15
+ schema: [{
16
+ type: 'object',
17
+ properties: {
18
+ ignoreKeys: {
19
+ type: 'array', items: { type: 'string' },
20
+ description: 'Keys to always treat as used (e.g. `["myKey"]`)'
21
+ },
22
+ scanExtensions: {
23
+ type: 'array', items: { type: 'string' },
24
+ description: 'File extensions to scan for references'
25
+ }
26
+ },
27
+ additionalProperties: false
28
+ }],
29
+ defaultOptions: [{ ignoreKeys: [], scanExtensions: defaultScanExtensions }],
30
+ messages: {
31
+ unusedKey: '`{{key}}` in `_locales/{{locale}}/messages.json` is never referenced.'
32
+ }
33
+ },
34
+
35
+ create(context) {
36
+ const filename = context.filename
37
+ if (!filename || !re.manifestFile.test(filename)) return {}
38
+ const manifest = readManifest(context) ; if (!manifest?.default_locale) return {}
39
+ const root = findExtensionRoot(filename) ; if (!root) return {}
40
+ const i18n = readDefaultLocaleMessages(manifest, root) ; if (!i18n) return {}
41
+ const opts = context.options[0] || {},
42
+ ignored = new Set(opts.ignoreKeys || []),
43
+ scanExtensions = opts.scanExtensions || defaultScanExtensions
44
+ function check() {
45
+ const { used, patterns } = collectUsedKeys(root, scanExtensions)
46
+ for (const key of Object.keys(i18n.messages)) {
47
+ if (ignored.has(key) || used.has(key) || patterns.some(patt => patt.test(key))) continue
48
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'unusedKey',
49
+ data: { key, locale: i18n.locale }})
50
+ }
51
+ }
52
+ return { 'Document:exit': check, 'Program:exit': check }
53
+ }
54
+ }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+
3
4
  import { apiPermissionMap, findPermission } from '../data/apis.js'
4
5
  import { findExtensionRoot, getFilename, readManifest } from '../utils.js'
5
6
 
@@ -19,8 +20,8 @@ function collectUsedAPIs(manifest, root) {
19
20
  const used = new Set()
20
21
  for (const file of files) {
21
22
  try {
22
- // eslint-disable-next-line security/detect-non-literal-fs-filename
23
- const src = fs.readFileSync(path.resolve(root, file), 'utf8')
23
+ const src = // eslint-disable-next-line security/detect-non-literal-fs-filename
24
+ fs.readFileSync(path.resolve(root, file), 'utf8')
24
25
  for (const match of src.matchAll(re.chromeAPI)) used.add(match[0])
25
26
  } catch {} // eslint-disable-line no-empty
26
27
  }
@@ -0,0 +1,27 @@
1
+ import path from 'node:path'
2
+
3
+ import { findLicenseFile } from '../data/license.js'
4
+
5
+ export default {
6
+ meta: {
7
+ type: 'problem',
8
+ docs: {
9
+ description: 'Requires a license file in the extension',
10
+ recommended: false,
11
+ manifest: true
12
+ },
13
+ schema: [],
14
+ messages: {
15
+ missingFile: 'No license file found. Add one of: LICENSE, LICENSE.md, LICENSE.txt, COPYING '
16
+ + '(at extension root or in a subdir).'
17
+ }
18
+ },
19
+ create(context) {
20
+ const { filename } = context,
21
+ check = node => {
22
+ if (!findLicenseFile(path.dirname(filename)))
23
+ context.report({ node, messageId: 'missingFile' })
24
+ }
25
+ return { Program: check, Document: check }
26
+ }
27
+ }
@@ -0,0 +1,71 @@
1
+ import { getFilename } from '../utils.js'
2
+
3
+ const defaults = { maxNameLength: 12 },
4
+ re = { i18nPlaceholder: /^__MSG_.+__$/, manifestFilename: /manifest\.json$/ }
5
+
6
+ function isI18n(value) { return typeof value == 'string' && re.i18nPlaceholder.test(value) }
7
+
8
+ export default {
9
+ meta: {
10
+ type: 'suggestion',
11
+ docs: {
12
+ description: 'Requires a `short_name` when `name` exceeds 12 chars',
13
+ category: 'Stylistic Issues',
14
+ recommended: false,
15
+ manifest: true
16
+ },
17
+ schema: [{
18
+ type: 'object',
19
+ properties: {
20
+ maxNameLength: {
21
+ type: 'number',
22
+ description: 'Length of `name` above which `short_name` is required (default `12`)'
23
+ },
24
+ always: {
25
+ type: 'boolean',
26
+ description: 'Require `short_name` regardless of `name` length (default `false`)'
27
+ },
28
+ checkRedundant: {
29
+ type: 'boolean',
30
+ description: 'Also flag `short_name` that duplicates `name` (default `false`)'
31
+ }
32
+ },
33
+ additionalProperties: false
34
+ }],
35
+ defaultOptions: [{
36
+ maxNameLength: defaults.maxNameLength, always: false, checkRedundant: false
37
+ }],
38
+ messages: {
39
+ missingShortName: '`name` exceeds {{max}} chars. Declare `short_name` to control truncation.',
40
+ shortNameTooLong: '`short_name` exceeds the recommended {{max}}-character limit (found {{length}}).',
41
+ redundantShortName: '`short_name` duplicates `name` (Chrome falls back to `name` automatically).'
42
+ }
43
+ },
44
+
45
+ create(context) {
46
+ if (!re.manifestFilename.test(getFilename(context))) return {}
47
+ const opts = context.options[0] || {},
48
+ maxNameLength = opts.maxNameLength ?? defaults.maxNameLength,
49
+ always = opts.always ?? false,
50
+ checkRedundant = opts.checkRedundant ?? false
51
+ let manifest
52
+ try { manifest = JSON.parse(context.sourceCode.text) } catch { return {} }
53
+ if (!manifest || typeof manifest != 'object') return {}
54
+ const name = manifest.name, shortName = manifest.short_name
55
+ if (typeof name != 'string') return {}
56
+ const nameIsI18n = isI18n(name),
57
+ shortNameIsI18n = isI18n(shortName),
58
+ loc = { line: 1, column: 1 }
59
+ if (typeof shortName != 'string') {
60
+ if (always || (!nameIsI18n && name.length > maxNameLength))
61
+ context.report({ loc, messageId: 'missingShortName', data: { max: maxNameLength }})
62
+ } else {
63
+ if (!shortNameIsI18n && shortName.length > maxNameLength)
64
+ context.report({ loc, messageId: 'shortNameTooLong',
65
+ data: { max: maxNameLength, length: shortName.length }})
66
+ if (checkRedundant && shortName == name)
67
+ context.report({ loc, messageId: 'redundantShortName' })
68
+ }
69
+ return {}
70
+ }
71
+ }
@@ -0,0 +1,151 @@
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
+ const re = { invalidChars: /[^\w\-.]/, manifestFilename: /manifest\.json$/, reservedPrefix: /^_/ }
7
+
8
+ const refKeys = [
9
+ ['action.default_popup', manifest => manifest.action?.default_popup],
10
+ ['background.page', manifest => manifest.background?.page],
11
+ ['background.service_worker', manifest => manifest.background?.service_worker],
12
+ ['devtools_page', manifest => manifest.devtools_page],
13
+ ['options_page', manifest => manifest.options_page],
14
+ ['options_ui.page', manifest => manifest.options_ui?.page],
15
+ ['side_panel.default_path', manifest => manifest.side_panel?.default_path]
16
+ ]
17
+
18
+ function findExtensionRoot(startPath) {
19
+ let dir = path.dirname(startPath)
20
+ while (dir != path.dirname(dir)) {
21
+ if (fs.existsSync(path.join(dir, 'manifest.json'))) return dir
22
+ dir = path.dirname(dir)
23
+ }
24
+ }
25
+
26
+ function collectRefs(manifest) {
27
+ const refs = []
28
+ for (const [label, get] of refKeys) {
29
+ const val = get(manifest)
30
+ if (typeof val == 'string') refs.push({ label, file: val })
31
+ }
32
+ if (manifest.background?.scripts)
33
+ refs.push(...manifest.background.scripts.map(file => ({ label: 'background.scripts', file })))
34
+ if (manifest.icons)
35
+ refs.push(...Object.values(manifest.icons).map(file => ({ label: 'icons', file })))
36
+ if (manifest.action?.default_icon && typeof manifest.action.default_icon == 'object')
37
+ refs.push(...Object.values(manifest.action.default_icon).map(file => ({ label: 'action.default_icon', file })))
38
+ for (const cs of manifest.content_scripts || []) {
39
+ if (cs.js) refs.push(...cs.js.map(file => ({ label: 'content_scripts.js', file })))
40
+ if (cs.css) refs.push(...cs.css.map(file => ({ label: 'content_scripts.css', file })))
41
+ }
42
+ for (const war of manifest.web_accessible_resources || [])
43
+ if (war.resources) refs.push(...war.resources.map(file => ({ label: 'web_accessible_resources', file })))
44
+ return refs
45
+ }
46
+
47
+ function resolveRef(abs) {
48
+ const dir = path.dirname(abs),
49
+ base = path.basename(abs)
50
+ let entries
51
+ try { entries = fs.readdirSync(dir) } catch { return { id: 'fileMissing' }}
52
+ if (entries.includes(base)) return null
53
+ const hit = entries.find(entry => entry.toLowerCase() == base.toLowerCase())
54
+ return hit ? { id: 'caseMismatch', actual: hit } : { id: 'fileMissing' }
55
+ }
56
+
57
+ export default {
58
+ meta: {
59
+ type: 'problem',
60
+ docs: {
61
+ description: 'Enforce valid filenames and paths in Chrome extensions',
62
+ category: 'Possible Errors',
63
+ recommended: true,
64
+ manifest: true,
65
+ html: true,
66
+ json: true
67
+ },
68
+ schema: [{
69
+ type: 'object',
70
+ properties: {
71
+ noReservedPrefix: {
72
+ type: 'boolean',
73
+ description: 'Disallow filenames/dirs starting w/ `_` (except `_locales`)'
74
+ },
75
+ noSpecialChars: {
76
+ type: 'boolean',
77
+ description: 'Warn on characters outside `a-z`, `0-9`, `-`, `_`, `.`'
78
+ },
79
+ checkFileExists: {
80
+ type: 'boolean',
81
+ description: 'Verify files referenced in `manifest.json` exist on disk'
82
+ },
83
+ enforceCaseSensitivity: {
84
+ type: 'boolean',
85
+ description: 'Verify manifest references match actual file case'
86
+ },
87
+ maxFullPathLength: {
88
+ type: 'number',
89
+ description: 'Max full path length (default 260)'
90
+ }
91
+ },
92
+ additionalProperties: false
93
+ }],
94
+ defaultOptions: [{
95
+ noReservedPrefix: true, noSpecialChars: true, checkFileExists: true, enforceCaseSensitivity: true,
96
+ maxFullPathLength: 260
97
+ }],
98
+ messages: {
99
+ reservedPrefix: '`{{name}}` starts with reserved prefix `_` (only `_locales` is allowed).',
100
+ specialChars: '`{{name}}` contains characters outside `a-z`, `0-9`, `-`, `_`, and `.`.',
101
+ pathTooLong: 'Full path length ({{length}}) exceeds the {{max}}-character limit (Windows `MAX_PATH`).',
102
+ fileMissing: '`{{label}}` references `{{file}}`, which does not exist.',
103
+ caseMismatch: '`{{label}}` references `{{file}}`, but the actual file is `{{actual}}`.'
104
+ }
105
+ },
106
+
107
+ create(context) {
108
+ const opts = context.options[0] || {},
109
+ noReservedPrefix = opts.noReservedPrefix ?? true,
110
+ noSpecialChars = opts.noSpecialChars ?? true,
111
+ checkFileExists = opts.checkFileExists ?? true,
112
+ enforceCaseSensitivity = opts.enforceCaseSensitivity ?? true,
113
+ maxFullPathLength = opts.maxFullPathLength ?? 260,
114
+ filename = context.filename
115
+ if (!filename || filename == '<input>') return {}
116
+ const basename = path.basename(filename),
117
+ extRoot = findExtensionRoot(filename) || path.dirname(filename),
118
+ segments = path.relative(extRoot, filename).split(path.sep)
119
+ if (noReservedPrefix)
120
+ for (const seg of segments)
121
+ if (re.reservedPrefix.test(seg) && seg != '_locales')
122
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'reservedPrefix', data: { name: seg }})
123
+ if (noSpecialChars)
124
+ for (const seg of segments)
125
+ if (re.invalidChars.test(seg))
126
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'specialChars', data: { name: seg }})
127
+ if (filename.length >= maxFullPathLength)
128
+ context.report({
129
+ loc: { line: 1, column: 1 }, messageId: 'pathTooLong',
130
+ data: { length: filename.length, max: maxFullPathLength }
131
+ })
132
+ if (basename != 'manifest.json' || (!checkFileExists && !enforceCaseSensitivity)) return {}
133
+ const manifestPath = path.join(extRoot, 'manifest.json')
134
+ if (!fs.existsSync(manifestPath)) return {}
135
+ let manifest ; try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) } catch { return {} }
136
+ for (const { label, file } of collectRefs(manifest)) {
137
+ if (file.includes('*')) continue // wildcard refs can't be verified
138
+ const abs = path.resolve(extRoot, file),
139
+ result = resolveRef(abs)
140
+ if (!result
141
+ || result.id == 'fileMissing' && !checkFileExists
142
+ || result.id == 'caseMismatch' && !enforceCaseSensitivity
143
+ ) continue
144
+ const data = result.id == 'caseMismatch'
145
+ ? { label, file, actual: result.actual }
146
+ : { label, file }
147
+ context.report({ loc: { line: 1, column: 1 }, messageId: result.id, data })
148
+ }
149
+ return {}
150
+ }
151
+ }
@@ -0,0 +1,59 @@
1
+ import { createRequire } from 'node:module'
2
+ import path from 'node:path'
3
+
4
+ import { re } from '../data/license.js'
5
+
6
+ const require = createRequire(import.meta.url),
7
+ spdxLicenseIDs = require('spdx-license-ids')
8
+
9
+ function toArray(value) { return value == null ? [] : Array.isArray(value) ? value : [value] }
10
+
11
+ function validateContent(context, node, { requireSPDX, allowedLicenses }) {
12
+ const match = context.sourceCode.text.match(re.spdxTag)
13
+ if (!match) return void (requireSPDX && context.report({ node, messageId: 'missingSPDX' }))
14
+ const id = match[1]
15
+ if (!spdxLicenseIDs.includes(id))
16
+ return context.report({ node, messageId: 'invalidSPDX', data: { id }})
17
+ if (allowedLicenses.length && !allowedLicenses.includes(id))
18
+ context.report({ node, messageId: 'disallowedSPDX', data: { id, allowed: allowedLicenses.join(', ') }})
19
+ }
20
+
21
+ export default {
22
+ meta: {
23
+ type: 'problem',
24
+ docs: {
25
+ description: 'Validates SPDX identifiers in license files',
26
+ recommended: false,
27
+ license: true
28
+ },
29
+ defaultOptions: [{ requireSPDX: false, allowedLicenses: [] }],
30
+ schema: [{
31
+ type: 'object',
32
+ properties: {
33
+ requireSPDX: { type: 'boolean', description: 'Require an SPDX-License-Identifier line' },
34
+ allowedLicenses: {
35
+ description: 'SPDX license IDs to restrict to',
36
+ oneOf: [
37
+ { type: 'string', description: 'Single SPDX license ID' },
38
+ { type: 'array', description: 'Array of SPDX license IDs', items: { type: 'string' }}
39
+ ]
40
+ }
41
+ },
42
+ additionalProperties: false
43
+ }],
44
+ messages: {
45
+ missingSPDX: 'License file missing `SPDX-License-Identifier` line (e.g. `SPDX-License-Identifier: MIT`).',
46
+ invalidSPDX: 'Invalid SPDX identifier `{{id}}` (see https://spdx.org/licenses/).',
47
+ disallowedSPDX: 'License `{{id}}` is not in allowed list: {{allowed}}.'
48
+ }
49
+ },
50
+ create(context) {
51
+ const opts = context.options[0],
52
+ { requireSPDX } = opts,
53
+ allowedLicenses = toArray(opts.allowedLicenses),
54
+ { filename } = context,
55
+ basename = path.basename(filename)
56
+ if (!re.licenseFilename.test(basename)) return {}
57
+ return { Program: node => validateContent(context, node, { requireSPDX, allowedLicenses })}
58
+ }
59
+ }
@@ -1,7 +1,8 @@
1
- import { getFilename, readManifest } from '../utils.js'
2
1
  import { mv2OnlyKeys, forbiddenCSPdirectives, ffMV3bgKeys, ffDataCollectionRequired, ffDataCollectionOptional }
3
2
  from '../data/manifest.js'
4
3
 
4
+ import { getFilename, readManifest } from '../utils.js'
5
+
5
6
  const re = { manifestFilename: /manifest\.json$/ }
6
7
 
7
8
  export default {
@@ -1,5 +1,5 @@
1
1
  import { domGlobals, serviceWorkerKeepaliveMinMS } from '../data/manifest.js'
2
- import { readManifest, isServiceWorkerFile, isInsideNestedFunction } from '../utils.js'
2
+ import { isServiceWorkerFile, isInsideNestedFunction, readManifest } from '../utils.js'
3
3
 
4
4
  export default {
5
5
  meta: {
@@ -11,7 +11,8 @@ export default {
11
11
  },
12
12
  schema: [],
13
13
  messages: {
14
- listenerNotTopLevel: '`chrome.*.addListener()` must be registered at module top level so it attaches on service worker wake-up.',
14
+ listenerNotTopLevel: '`chrome.*.addListener()` must be registered at module top level'
15
+ + ' so it attaches on service worker wake-up.',
15
16
  keepaliveTimer: 'Sub-30s `{{fn}}` will not keep the MV3 service worker alive. Use `chrome.alarms` instead.',
16
17
  domInSW: '`{{global}}` is not available in a service worker.'
17
18
  }