| author | |
| committer | |
| log | 37a8ab2b29dfa33b4e20ad709a9860f0da7b7eb2 |
| tree | 46f6b32121f45c09dcfa1f8a5e46d32a62d7574a |
| parent | 6f7aaffe26f0e6ae3d47b0ae512b322f890e0f43 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 154 insertions(+), 107 deletions(-)
oxlint-plugin-t3code/index.ts+2-2| ... | @@ -2,7 +2,7 @@ import { definePlugin } from "@oxlint/plugins"; | ... | @@ -2,7 +2,7 @@ import { definePlugin } from "@oxlint/plugins"; |
| 2 | 2 | ||
| 3 | import namespaceNodeImports from "./rules/namespace-node-imports.ts"; | 3 | import namespaceNodeImports from "./rules/namespace-node-imports.ts"; |
| 4 | import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; | 4 | import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; |
| 5 | import noHermesUnsupportedArrayMethods from "./rules/no-hermes-unsupported-array-methods.ts"; | 5 | import noHermesUnsupportedApis from "./rules/no-hermes-unsupported-apis.ts"; |
| 6 | import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; | 6 | import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; |
| 7 | import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; | 7 | import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; |
| 8 | import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; | 8 | import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; |
| ... | @@ -15,7 +15,7 @@ export default definePlugin({ | ... | @@ -15,7 +15,7 @@ export default definePlugin({ |
| 15 | rules: { | 15 | rules: { |
| 16 | "namespace-node-imports": namespaceNodeImports, | 16 | "namespace-node-imports": namespaceNodeImports, |
| 17 | "no-global-process-runtime": noGlobalProcessRuntime, | 17 | "no-global-process-runtime": noGlobalProcessRuntime, |
| 18 | "no-hermes-unsupported-array-methods": noHermesUnsupportedArrayMethods, | 18 | "no-hermes-unsupported-apis": noHermesUnsupportedApis, |
| 19 | "no-inline-schema-compile": noInlineSchemaCompile, | 19 | "no-inline-schema-compile": noInlineSchemaCompile, |
| 20 | "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, | 20 | "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, |
| 21 | "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, | 21 | "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, |
oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.test.ts created+79| ... | @@ -0,0 +1,79 @@ | ||
| 1 | import { assert, describe } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { createOxlintRuleHarness } from "../test/utils.ts"; | ||
| 4 | |||
| 5 | const rule = createOxlintRuleHarness("t3code/no-hermes-unsupported-apis", { | ||
| 6 | filename: "fixture.ts", | ||
| 7 | }); | ||
| 8 | |||
| 9 | describe("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 @@ | ||
| 1 | import { defineRule, type ESTree } from "@oxlint/plugins"; | ||
| 2 | |||
| 3 | // Add global APIs by dotted path, or instance methods by name. Values explain the replacement. | ||
| 4 | const UNSUPPORTED_GLOBAL_APIS = new Map([ | ||
| 5 | [ | ||
| 6 | "Intl.Segmenter", | ||
| 7 | "Use a portable implementation or a simpler character-counting approximation.", | ||
| 8 | ], | ||
| 9 | ]); | ||
| 10 | |||
| 11 | const 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 | |||
| 27 | function 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 | |||
| 36 | function 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 | |||
| 45 | export 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 @@ | ||
| 1 | import { assert, describe } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { createOxlintRuleHarness } from "../test/utils.ts"; | ||
| 4 | |||
| 5 | const rule = createOxlintRuleHarness("t3code/no-hermes-unsupported-array-methods", { | ||
| 6 | filename: "fixture.ts", | ||
| 7 | }); | ||
| 8 | |||
| 9 | describe("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 @@ | ||
| 1 | import { 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. | ||
| 6 | const 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 | |||
| 13 | export 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({ | ... | @@ -136,8 +136,8 @@ export default defineConfig({ |
| 136 | rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, | 136 | rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, |
| 137 | }, | 137 | }, |
| 138 | { | 138 | { |
| 139 | // Code that runs on Hermes. It has no ES2023 change-array-by-copy methods, and | 139 | // Shared client code must not call APIs missing from Hermes. Our ESNext |
| 140 | // tsconfig targets ESNext, so only lint stands between a call and a fatal launch. | 140 | // TypeScript target accepts them even when they would crash mobile at launch. |
| 141 | // Tests run on Node and are exempt. | 141 | // Tests run on Node and are exempt. |
| 142 | files: [ | 142 | files: [ |
| 143 | "apps/mobile/src/**", | 143 | "apps/mobile/src/**", |
| ... | @@ -146,7 +146,7 @@ export default defineConfig({ | ... | @@ -146,7 +146,7 @@ export default defineConfig({ |
| 146 | "packages/shared/src/**", | 146 | "packages/shared/src/**", |
| 147 | ], | 147 | ], |
| 148 | excludeFiles: ["**/*.test.ts", "**/*.test.tsx"], | 148 | excludeFiles: ["**/*.test.ts", "**/*.test.tsx"], |
| 149 | rules: { "t3code/no-hermes-unsupported-array-methods": "error" }, | 149 | rules: { "t3code/no-hermes-unsupported-apis": "error" }, |
| 150 | }, | 150 | }, |
| 151 | { | 151 | { |
| 152 | // Reviewed native and third-party interop boundaries that cannot consume a className. | 152 | // Reviewed native and third-party interop boundaries that cannot consume a className. |