Harden WebExtension compatibility linting

This commit is contained in:
Oliver Blanthorn 2026-07-11 18:50:09 +02:00
parent 0a5aa84e87
commit 408fe82021
No known key found for this signature in database
GPG key ID: 2BB8C36BB504BFF3
2 changed files with 198 additions and 50 deletions

View file

@ -5,21 +5,66 @@ function propertyNameOrValue(n) {
return n.property.type == "Literal" ? n.property.value : n.property.name
}
function isVersionNewer(version, minimumVersion) {
const versionParts = String(version).split(".").map(Number)
const minimumParts = String(minimumVersion).split(".").map(Number)
if (versionParts.some(Number.isNaN) || minimumParts.some(Number.isNaN))
return false
function compareVersions(left, right) {
const versionPattern = /^(≤)?\d+(\.\d+)*$/
if (!versionPattern.test(String(left)) || !versionPattern.test(String(right)))
return undefined
const versionParts = String(left).replace(/^≤/, "").split(".").map(Number)
const minimumParts = String(right).replace(/^≤/, "").split(".").map(Number)
const length = Math.max(versionParts.length, minimumParts.length)
for (let i = 0; i < length; i++) {
const difference = (versionParts[i] || 0) - (minimumParts[i] || 0)
if (difference !== 0) return difference > 0
if (difference !== 0) return difference
}
return false
return 0
}
function detectBrowserUsage(context, node, browser, minimumVersion) {
let localApi = api
function supportState(statement, minimumVersion) {
if (Array.isArray(statement)) {
const states = statement.map(item => supportState(item, minimumVersion))
if (states.includes("supported")) return "supported"
if (states.includes("tooRecent")) return "tooRecent"
return "unsupported"
}
if (
!statement ||
typeof statement !== "object" ||
statement.flags ||
statement.prefix ||
statement.alternative_name ||
statement.partial_implementation
) {
return "unsupported"
}
const added = statement.version_added
if (added === false || added === null || added === undefined)
return "unsupported"
if (statement.version_removed) {
if (minimumVersion === undefined) return "unsupported"
const removed = compareVersions(statement.version_removed, minimumVersion)
if (removed === undefined || removed <= 0) return "unsupported"
}
if (minimumVersion === undefined) {
return added === true || compareVersions(added, added) !== undefined
? "supported"
: "unsupported"
}
if (added === true) return "supported"
const comparison = compareVersions(added, minimumVersion)
if (comparison === undefined) return "unsupported"
if (String(added).startsWith("≤") && comparison > 0)
return "unsupported"
return comparison > 0 ? "tooRecent" : "supported"
}
function detectBrowserUsage(
context,
node,
browser,
minimumVersion,
compatibilityApi,
) {
let localApi = compatibilityApi
const fullName = []
while (
node.type == "MemberExpression" &&
@ -33,8 +78,9 @@ function detectBrowserUsage(context, node, browser, minimumVersion) {
if (!localApi.__compat) {
continue
}
const support = localApi.__compat.support
if (support[browser].version_added === false) {
const support = localApi.__compat.support || {}
const state = supportState(support[browser], minimumVersion)
if (state === "unsupported") {
context.report({
node: n,
messageId: "unsupportedApis",
@ -43,48 +89,53 @@ function detectBrowserUsage(context, node, browser, minimumVersion) {
api: fullName.join("."),
},
})
} else {
const version = support[browser].version_added
if (
minimumVersion !== undefined &&
isVersionNewer(version, minimumVersion)
) {
context.report({
node: n,
messageId: "apiTooRecent",
data: {
api: fullName.join("."),
name: browser,
version: minimumVersion,
},
})
}
} else if (state === "tooRecent") {
context.report({
node: n,
messageId: "apiTooRecent",
data: {
api: fullName.join("."),
name: browser,
version: minimumVersion,
},
})
}
}
}
module.exports = browser => ({
meta: {
schema: [
{
type: "object",
properties: { minimumVersion: { type: "string" } },
additionalProperties: false,
function createUnsupportedApis(browser, compatibilityApi = api) {
return {
meta: {
schema: [
{
type: "object",
properties: { minimumVersion: { type: "string" } },
additionalProperties: false,
},
],
messages: {
unsupportedApis: "{{ api }} unsupported on '{{ name }}'",
apiTooRecent:
"{{ api }} is not supported on {{ name }} {{ version }}",
},
],
messages: {
unsupportedApis: "{{ api }} unsupported on '{{ name }}'",
apiTooRecent:
"{{ api }} is not supported on {{ name }} {{ version }}",
},
},
create(context) {
const { minimumVersion } = context.options[0] || {}
const detect = node =>
detectBrowserUsage(context, node, browser, minimumVersion)
return {
'MemberExpression[object.name="browser"]': detect,
'MemberExpression[object.name="browserBg"]': detect,
}
},
})
create(context) {
const { minimumVersion } = context.options[0] || {}
const detect = node =>
detectBrowserUsage(
context,
node,
browser,
minimumVersion,
compatibilityApi,
)
return {
'MemberExpression[object.name="browser"]': detect,
'MemberExpression[object.name="browserBg"]': detect,
}
},
}
}
module.exports = createUnsupportedApis
module.exports.supportState = supportState

View file

@ -0,0 +1,97 @@
const { Linter } = require("eslint")
const unsupportedApis = require("../lib/unsupported-apis")
function compat(support) {
return { __compat: { support } }
}
function verify(code, api, browser = "firefox", minimumVersion = "68") {
const linter = new Linter()
linter.defineRule("unsupported", unsupportedApis(browser, api))
return linter.verify(code, {
parserOptions: { ecmaVersion: 2020 },
rules: { unsupported: ["error", { minimumVersion }] },
})
}
test("detects browser, browserBg, and computed API access", () => {
const api = { tabs: { hide: compat({ firefox: { version_added: false } }) } }
const messages = verify(
'browser.tabs.hide(); browserBg.tabs.hide(); browser["tabs"]["hide"]()',
api,
)
expect(messages.map(message => message.messageId)).toEqual([
"unsupportedApis",
"unsupportedApis",
"unsupportedApis",
])
})
test("checks target minimum versions", () => {
const api = {
tabs: {
old: compat({ firefox: { version_added: "67" } }),
exact: compat({ firefox: { version_added: "68" } }),
recent: compat({ firefox: { version_added: "69" } }),
},
}
const messages = verify(
"browser.tabs.old; browser.tabs.exact; browser.tabs.recent",
api,
)
expect(messages).toHaveLength(1)
expect(messages[0].messageId).toBe("apiTooRecent")
})
test.each([
["missing target", undefined],
["null", null],
["removed", { version_added: "1", version_removed: "60" }],
["qualified", { version_added: "1", flags: [{ type: "preference" }] }],
["non-numeric", { version_added: "preview" }],
])("treats %s BCD conservatively", (_name, statement) => {
const api = { tabs: { test: compat({ firefox: statement }) } }
expect(verify("browser.tabs.test", api)[0].messageId).toBe(
"unsupportedApis",
)
})
test("accepts a supported unqualified statement from an array", () => {
const api = {
tabs: {
test: compat({
firefox: [
{ version_added: false },
{ version_added: "67" },
],
}),
},
}
expect(verify("browser.tabs.test", api)).toHaveLength(0)
})
test("keeps platform suppressions independent", () => {
const api = {
tabs: {
test: compat({
firefox: { version_added: false },
chrome: { version_added: false },
}),
},
}
const linter = new Linter()
linter.defineRule("unsupported-firefox", unsupportedApis("firefox", api))
linter.defineRule("unsupported-chrome", unsupportedApis("chrome", api))
const messages = linter.verify(
"// eslint-disable-next-line unsupported-firefox\nbrowser.tabs.test",
{
parserOptions: { ecmaVersion: 2020 },
rules: {
"unsupported-firefox": "error",
"unsupported-chrome": "error",
},
},
)
expect(messages).toHaveLength(1)
expect(messages[0].ruleId).toBe("unsupported-chrome")
})