authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-15 16:14:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 23:14:01+00:00
log37a8ab2b29dfa33b4e20ad709a9860f0da7b7eb2
tree46f6b32121f45c09dcfa1f8a5e46d32a62d7574a
parent6f7aaffe26f0e6ae3d47b0ae512b322f890e0f43
signature Signed by PGP key B5690EEEBB952194

feat(lint): extend Hermes API bans with a configurable API list (#11982)


6 files changed, 154 insertions(+), 107 deletions(-)

oxlint-plugin-t3code/index.ts+2-2
......@@ -2,7 +2,7 @@ import { definePlugin } from "@oxlint/plugins";
22
33import namespaceNodeImports from "./rules/namespace-node-imports.ts";
44import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts";
5import noHermesUnsupportedArrayMethods from "./rules/no-hermes-unsupported-array-methods.ts";
5import noHermesUnsupportedApis from "./rules/no-hermes-unsupported-apis.ts";
66import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";
77import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";
88import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";
......@@ -15,7 +15,7 @@ export default definePlugin({
1515 rules: {
1616 "namespace-node-imports": namespaceNodeImports,
1717 "no-global-process-runtime": noGlobalProcessRuntime,
18 "no-hermes-unsupported-array-methods": noHermesUnsupportedArrayMethods,
18 "no-hermes-unsupported-apis": noHermesUnsupportedApis,
1919 "no-inline-schema-compile": noInlineSchemaCompile,
2020 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,
2121 "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,
oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.test.ts created+79
......@@ -0,0 +1,79 @@
1import { assert, describe } from "@effect/vitest";
2
3import { createOxlintRuleHarness } from "../test/utils.ts";
4
5const rule = createOxlintRuleHarness("t3code/no-hermes-unsupported-apis", {
6 filename: "fixture.ts",
7});
8
9describe("t3code/no-hermes-unsupported-apis", () => {
10 rule.valid("allows in-place sort on a copy", `const sorted = [...items].sort(compare);`);
11
12 rule.valid("allows in-place reverse on a copy", `const reversed = [...items].reverse();`);
13
14 rule.valid(
15 "allows other ES2023 methods Hermes ships",
16 `const last = items.findLast(Boolean); const tail = items.at(-1);`,
17 );
18
19 rule.valid(
20 "ignores property reads that are not calls",
21 `const hasToSorted = typeof Array.prototype.toSorted === "function";`,
22 );
23
24 rule.invalid("reports toSorted", `const sorted = items.toSorted(compare);`, (output) => {
25 assert.match(output, /Array#toSorted/);
26 });
27
28 rule.invalid(
29 "reports toReversed in a chain",
30 `const open = chains.map((chain) => chain.layers.toReversed().filter(isOpen));`,
31 (output) => {
32 assert.match(output, /Array#toReversed/);
33 },
34 );
35
36 rule.invalid(
37 "reports toSpliced via computed access with a copy-then-splice remediation",
38 `const next = items["toSpliced"](0, 1);`,
39 (output) => {
40 assert.match(output, /Array#toSpliced/);
41 assert.match(output, /const copy = \[\.\.\.array\]; copy\.splice\(\.\.\.\); use copy/);
42 },
43 );
44
45 rule.invalid(
46 "reports a static template-literal property name",
47 "const reversed = items[`toReversed`]();",
48 (output) => {
49 assert.match(output, /Array#toReversed/);
50 },
51 );
52
53 rule.valid(
54 "ignores a template-literal property with substitutions",
55 "const value = items[`to${suffix}`]();",
56 );
57
58 rule.valid("allows supported Intl constructors", `new Intl.NumberFormat();`);
59 rule.valid("allows feature detection", `const supported = typeof Intl.Segmenter === "function";`);
60 rule.valid("allows unrelated Segmenter constructors", `new custom.Segmenter();`);
61 rule.valid("ignores dynamic computed names", `new Intl[Segmenter](); items[toSorted]();`);
62
63 for (const expression of [
64 "new Intl.Segmenter(undefined, { granularity: 'grapheme' })",
65 "new Intl['Segmenter']()",
66 "new Intl[`Segmenter`]()",
67 "new globalThis.Intl.Segmenter()",
68 "new globalThis['Intl']['Segmenter']()",
69 "new global.Intl.Segmenter()",
70 "new window.Intl.Segmenter()",
71 "Intl.Segmenter()",
72 "Intl.Segmenter?.()",
73 ]) {
74 rule.invalid(`reports ${expression}`, `${expression};`, (output) => {
75 assert.match(output, /Hermes does not implement Intl\.Segmenter/);
76 assert.match(output, /portable implementation/);
77 });
78 }
79});
oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.ts created+70
......@@ -0,0 +1,70 @@
1import { defineRule, type ESTree } from "@oxlint/plugins";
2
3// Add global APIs by dotted path, or instance methods by name. Values explain the replacement.
4const UNSUPPORTED_GLOBAL_APIS = new Map([
5 [
6 "Intl.Segmenter",
7 "Use a portable implementation or a simpler character-counting approximation.",
8 ],
9]);
10
11const UNSUPPORTED_METHODS = new Map([
12 [
13 "toSorted",
14 "Hermes does not implement Array#toSorted. Copy the array first: [...array].sort(...).",
15 ],
16 [
17 "toReversed",
18 "Hermes does not implement Array#toReversed. Copy the array first: [...array].reverse().",
19 ],
20 // splice returns the removed elements, so the copy itself is the result.
21 [
22 "toSpliced",
23 "Hermes does not implement Array#toSpliced. Copy the array first: const copy = [...array]; copy.splice(...); use copy.",
24 ],
25]);
26
27function memberName(node: ESTree.MemberExpression): string | null {
28 const { property } = node;
29 if (!node.computed && property.type === "Identifier") return property.name;
30 if (property.type === "Literal" && typeof property.value === "string") return property.value;
31 if (property.type === "TemplateLiteral" && property.expressions.length === 0)
32 return property.quasis[0]?.value.cooked ?? null;
33 return null;
34}
35
36function globalApiPath(node: ESTree.Node): string | null {
37 if (node.type === "Identifier") return node.name;
38 if (node.type !== "MemberExpression") return null;
39 const object = globalApiPath(node.object);
40 const property = memberName(node);
41 if (object === null || property === null) return null;
42 return ["globalThis", "global", "window"].includes(object) ? property : `${object}.${property}`;
43}
44
45export default defineRule({
46 meta: {
47 type: "problem",
48 docs: {
49 description: "Disallow APIs that Hermes does not implement in mobile and shared client code.",
50 },
51 },
52 create(context) {
53 function checkApi(node: ESTree.CallExpression | ESTree.NewExpression) {
54 const path = globalApiPath(node.callee);
55 const replacement = path === null ? undefined : UNSUPPORTED_GLOBAL_APIS.get(path);
56 if (replacement !== undefined) {
57 context.report({
58 node: node.callee,
59 message: `Hermes does not implement ${path}. ${replacement}`,
60 });
61 return;
62 }
63 if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return;
64 const name = memberName(node.callee);
65 const message = name === null ? undefined : UNSUPPORTED_METHODS.get(name);
66 if (message !== undefined) context.report({ node: node.callee.property, message });
67 }
68 return { NewExpression: checkApi, CallExpression: checkApi };
69 },
70});
oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts deleted-57
......@@ -1,57 +0,0 @@
1import { assert, describe } from "@effect/vitest";
2
3import { createOxlintRuleHarness } from "../test/utils.ts";
4
5const rule = createOxlintRuleHarness("t3code/no-hermes-unsupported-array-methods", {
6 filename: "fixture.ts",
7});
8
9describe("t3code/no-hermes-unsupported-array-methods", () => {
10 rule.valid("allows in-place sort on a copy", `const sorted = [...items].sort(compare);`);
11
12 rule.valid("allows in-place reverse on a copy", `const reversed = [...items].reverse();`);
13
14 rule.valid(
15 "allows other ES2023 methods Hermes ships",
16 `const last = items.findLast(Boolean); const tail = items.at(-1);`,
17 );
18
19 rule.valid(
20 "ignores property reads that are not calls",
21 `const hasToSorted = typeof Array.prototype.toSorted === "function";`,
22 );
23
24 rule.invalid("reports toSorted", `const sorted = items.toSorted(compare);`, (output) => {
25 assert.match(output, /Array#toSorted/);
26 });
27
28 rule.invalid(
29 "reports toReversed in a chain",
30 `const open = chains.map((chain) => chain.layers.toReversed().filter(isOpen));`,
31 (output) => {
32 assert.match(output, /Array#toReversed/);
33 },
34 );
35
36 rule.invalid(
37 "reports toSpliced via computed access with a copy-then-splice remediation",
38 `const next = items["toSpliced"](0, 1);`,
39 (output) => {
40 assert.match(output, /Array#toSpliced/);
41 assert.match(output, /const copy = \[\.\.\.array\]; copy\.splice\(\.\.\.\); use copy/);
42 },
43 );
44
45 rule.invalid(
46 "reports a static template-literal property name",
47 "const reversed = items[`toReversed`]();",
48 (output) => {
49 assert.match(output, /Array#toReversed/);
50 },
51 );
52
53 rule.valid(
54 "ignores a template-literal property with substitutions",
55 "const value = items[`to${suffix}`]();",
56 );
57});
oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts deleted-45
......@@ -1,45 +0,0 @@
1import { defineRule } from "@oxlint/plugins";
2
3// ES2023 change-array-by-copy methods. Hermes does not implement them, and
4// tsconfig targets ESNext, so nothing but this rule stands between a call and a
5// TypeError that is fatal on every mobile launch that reaches it.
6const UNSUPPORTED_METHODS = new Map([
7 ["toSorted", "[...array].sort(...)"],
8 ["toReversed", "[...array].reverse()"],
9 // splice returns the removed elements, so the copy itself is the result.
10 ["toSpliced", "const copy = [...array]; copy.splice(...); use copy"],
11]);
12
13export default defineRule({
14 meta: {
15 type: "problem",
16 docs: {
17 description:
18 "Disallow ES2023 array-by-copy methods (toSorted, toReversed, toSpliced) in code that runs on Hermes.",
19 },
20 },
21 create(context) {
22 return {
23 CallExpression(node) {
24 if (node.callee.type !== "MemberExpression") return;
25 const { property } = node.callee;
26 const name =
27 property.type === "Identifier"
28 ? property.name
29 : property.type === "Literal" && typeof property.value === "string"
30 ? property.value
31 : property.type === "TemplateLiteral" && property.expressions.length === 0
32 ? (property.quasis[0]?.value.cooked ?? null)
33 : null;
34 if (name === null) return;
35 const replacement = UNSUPPORTED_METHODS.get(name);
36 if (replacement === undefined) return;
37
38 context.report({
39 node: property,
40 message: `Hermes does not implement Array#${name}. Copy the array first: ${replacement}.`,
41 });
42 },
43 };
44 },
45});
vite.config.ts+3-3
......@@ -136,8 +136,8 @@ export default defineConfig({
136136 rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" },
137137 },
138138 {
139 // Code that runs on Hermes. It has no ES2023 change-array-by-copy methods, and
140 // tsconfig targets ESNext, so only lint stands between a call and a fatal launch.
139 // Shared client code must not call APIs missing from Hermes. Our ESNext
140 // TypeScript target accepts them even when they would crash mobile at launch.
141141 // Tests run on Node and are exempt.
142142 files: [
143143 "apps/mobile/src/**",
......@@ -146,7 +146,7 @@ export default defineConfig({
146146 "packages/shared/src/**",
147147 ],
148148 excludeFiles: ["**/*.test.ts", "**/*.test.tsx"],
149 rules: { "t3code/no-hermes-unsupported-array-methods": "error" },
149 rules: { "t3code/no-hermes-unsupported-apis": "error" },
150150 },
151151 {
152152 // Reviewed native and third-party interop boundaries that cannot consume a className.