From 37a8ab2b29dfa33b4e20ad709a9860f0da7b7eb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 16:14:01 -0700 Subject: [PATCH] feat(lint): extend Hermes API bans with a configurable API list (#11982) --- oxlint-plugin-t3code/index.ts | 4 +- ....ts => no-hermes-unsupported-apis.test.ts} | 26 ++++++- .../rules/no-hermes-unsupported-apis.ts | 70 +++++++++++++++++++ .../no-hermes-unsupported-array-methods.ts | 45 ------------ vite.config.ts | 6 +- 5 files changed, 99 insertions(+), 52 deletions(-) rename oxlint-plugin-t3code/rules/{no-hermes-unsupported-array-methods.test.ts => no-hermes-unsupported-apis.test.ts} (62%) create mode 100644 oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.ts delete mode 100644 oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index a2d1997e43f9defe1233de5d69bb0879fe6d089e..075d16d5b39084b0d22ec43f9ae3d37a458d3033 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -2,7 +2,7 @@ import { definePlugin } from "@oxlint/plugins"; import namespaceNodeImports from "./rules/namespace-node-imports.ts"; import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; -import noHermesUnsupportedArrayMethods from "./rules/no-hermes-unsupported-array-methods.ts"; +import noHermesUnsupportedApis from "./rules/no-hermes-unsupported-apis.ts"; import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; @@ -15,7 +15,7 @@ export default definePlugin({ rules: { "namespace-node-imports": namespaceNodeImports, "no-global-process-runtime": noGlobalProcessRuntime, - "no-hermes-unsupported-array-methods": noHermesUnsupportedArrayMethods, + "no-hermes-unsupported-apis": noHermesUnsupportedApis, "no-inline-schema-compile": noInlineSchemaCompile, "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, diff --git a/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts b/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.test.ts similarity index 62% rename from oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts rename to oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.test.ts index 4bd751c13c9a3eefe46c49e65cfadb6e136c8594..ceccf8d32c33aeb21ae7e53e34787ff540d3d826 100644 --- a/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts +++ b/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.test.ts @@ -2,11 +2,11 @@ import { assert, describe } from "@effect/vitest"; import { createOxlintRuleHarness } from "../test/utils.ts"; -const rule = createOxlintRuleHarness("t3code/no-hermes-unsupported-array-methods", { +const rule = createOxlintRuleHarness("t3code/no-hermes-unsupported-apis", { filename: "fixture.ts", }); -describe("t3code/no-hermes-unsupported-array-methods", () => { +describe("t3code/no-hermes-unsupported-apis", () => { rule.valid("allows in-place sort on a copy", `const sorted = [...items].sort(compare);`); rule.valid("allows in-place reverse on a copy", `const reversed = [...items].reverse();`); @@ -54,4 +54,26 @@ describe("t3code/no-hermes-unsupported-array-methods", () => { "ignores a template-literal property with substitutions", "const value = items[`to${suffix}`]();", ); + + rule.valid("allows supported Intl constructors", `new Intl.NumberFormat();`); + rule.valid("allows feature detection", `const supported = typeof Intl.Segmenter === "function";`); + rule.valid("allows unrelated Segmenter constructors", `new custom.Segmenter();`); + rule.valid("ignores dynamic computed names", `new Intl[Segmenter](); items[toSorted]();`); + + for (const expression of [ + "new Intl.Segmenter(undefined, { granularity: 'grapheme' })", + "new Intl['Segmenter']()", + "new Intl[`Segmenter`]()", + "new globalThis.Intl.Segmenter()", + "new globalThis['Intl']['Segmenter']()", + "new global.Intl.Segmenter()", + "new window.Intl.Segmenter()", + "Intl.Segmenter()", + "Intl.Segmenter?.()", + ]) { + rule.invalid(`reports ${expression}`, `${expression};`, (output) => { + assert.match(output, /Hermes does not implement Intl\.Segmenter/); + assert.match(output, /portable implementation/); + }); + } }); diff --git a/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.ts b/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.ts new file mode 100644 index 0000000000000000000000000000000000000000..b06b522376e0720763c7463ec1976c9dbff047de --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.ts @@ -0,0 +1,70 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; + +// Add global APIs by dotted path, or instance methods by name. Values explain the replacement. +const UNSUPPORTED_GLOBAL_APIS = new Map([ + [ + "Intl.Segmenter", + "Use a portable implementation or a simpler character-counting approximation.", + ], +]); + +const UNSUPPORTED_METHODS = new Map([ + [ + "toSorted", + "Hermes does not implement Array#toSorted. Copy the array first: [...array].sort(...).", + ], + [ + "toReversed", + "Hermes does not implement Array#toReversed. Copy the array first: [...array].reverse().", + ], + // splice returns the removed elements, so the copy itself is the result. + [ + "toSpliced", + "Hermes does not implement Array#toSpliced. Copy the array first: const copy = [...array]; copy.splice(...); use copy.", + ], +]); + +function memberName(node: ESTree.MemberExpression): string | null { + const { property } = node; + if (!node.computed && property.type === "Identifier") return property.name; + if (property.type === "Literal" && typeof property.value === "string") return property.value; + if (property.type === "TemplateLiteral" && property.expressions.length === 0) + return property.quasis[0]?.value.cooked ?? null; + return null; +} + +function globalApiPath(node: ESTree.Node): string | null { + if (node.type === "Identifier") return node.name; + if (node.type !== "MemberExpression") return null; + const object = globalApiPath(node.object); + const property = memberName(node); + if (object === null || property === null) return null; + return ["globalThis", "global", "window"].includes(object) ? property : `${object}.${property}`; +} + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: "Disallow APIs that Hermes does not implement in mobile and shared client code.", + }, + }, + create(context) { + function checkApi(node: ESTree.CallExpression | ESTree.NewExpression) { + const path = globalApiPath(node.callee); + const replacement = path === null ? undefined : UNSUPPORTED_GLOBAL_APIS.get(path); + if (replacement !== undefined) { + context.report({ + node: node.callee, + message: `Hermes does not implement ${path}. ${replacement}`, + }); + return; + } + if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return; + const name = memberName(node.callee); + const message = name === null ? undefined : UNSUPPORTED_METHODS.get(name); + if (message !== undefined) context.report({ node: node.callee.property, message }); + } + return { NewExpression: checkApi, CallExpression: checkApi }; + }, +}); diff --git a/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts b/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts deleted file mode 100644 index e5dd8867e69647e9a9608059ae74a5a662fe356b..0000000000000000000000000000000000000000 --- a/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -// ES2023 change-array-by-copy methods. Hermes does not implement them, and -// tsconfig targets ESNext, so nothing but this rule stands between a call and a -// TypeError that is fatal on every mobile launch that reaches it. -const UNSUPPORTED_METHODS = new Map([ - ["toSorted", "[...array].sort(...)"], - ["toReversed", "[...array].reverse()"], - // splice returns the removed elements, so the copy itself is the result. - ["toSpliced", "const copy = [...array]; copy.splice(...); use copy"], -]); - -export default defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow ES2023 array-by-copy methods (toSorted, toReversed, toSpliced) in code that runs on Hermes.", - }, - }, - create(context) { - return { - CallExpression(node) { - if (node.callee.type !== "MemberExpression") return; - const { property } = node.callee; - const name = - property.type === "Identifier" - ? property.name - : property.type === "Literal" && typeof property.value === "string" - ? property.value - : property.type === "TemplateLiteral" && property.expressions.length === 0 - ? (property.quasis[0]?.value.cooked ?? null) - : null; - if (name === null) return; - const replacement = UNSUPPORTED_METHODS.get(name); - if (replacement === undefined) return; - - context.report({ - node: property, - message: `Hermes does not implement Array#${name}. Copy the array first: ${replacement}.`, - }); - }, - }; - }, -}); diff --git a/vite.config.ts b/vite.config.ts index b9f2c9cc2c4b335c607dc59bbe5ac60c6393e29e..e128bbb05f70349a36d09ca5fb6bce3fd1b44b8a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -136,8 +136,8 @@ export default defineConfig({ rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, }, { - // Code that runs on Hermes. It has no ES2023 change-array-by-copy methods, and - // tsconfig targets ESNext, so only lint stands between a call and a fatal launch. + // Shared client code must not call APIs missing from Hermes. Our ESNext + // TypeScript target accepts them even when they would crash mobile at launch. // Tests run on Node and are exempt. files: [ "apps/mobile/src/**", @@ -146,7 +146,7 @@ export default defineConfig({ "packages/shared/src/**", ], excludeFiles: ["**/*.test.ts", "**/*.test.tsx"], - rules: { "t3code/no-hermes-unsupported-array-methods": "error" }, + rules: { "t3code/no-hermes-unsupported-apis": "error" }, }, { // Reviewed native and third-party interop boundaries that cannot consume a className. -- 2.54.0