| author | |
| committer | |
| log | 5781e2be2b7d079faaf677cc1b39679e9850d833 |
| tree | 75a210c059f792b137a4b8315d0582ef03ac9a0f |
| parent | 2587c8060c103245b4f970b925977008f2592d1d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Code <noreply@anthropic.com>6 files changed, 137 insertions(+), 2 deletions(-)
oxlint-plugin-t3code/index.ts+2| ... | @@ -2,6 +2,7 @@ import { definePlugin } from "@oxlint/plugins"; | ... | @@ -2,6 +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 noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; | 6 | import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; |
| 6 | import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; | 7 | import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; |
| 7 | import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; | 8 | import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; |
| ... | @@ -14,6 +15,7 @@ export default definePlugin({ | ... | @@ -14,6 +15,7 @@ export default definePlugin({ |
| 14 | rules: { | 15 | rules: { |
| 15 | "namespace-node-imports": namespaceNodeImports, | 16 | "namespace-node-imports": namespaceNodeImports, |
| 16 | "no-global-process-runtime": noGlobalProcessRuntime, | 17 | "no-global-process-runtime": noGlobalProcessRuntime, |
| 18 | "no-hermes-unsupported-array-methods": noHermesUnsupportedArrayMethods, | ||
| 17 | "no-inline-schema-compile": noInlineSchemaCompile, | 19 | "no-inline-schema-compile": noInlineSchemaCompile, |
| 18 | "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, | 20 | "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, |
| 19 | "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, | 21 | "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, |
oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts created+57| ... | @@ -0,0 +1,57 @@ | ||
| 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 created+45| ... | @@ -0,0 +1,45 @@ | ||
| 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 | }); | ||
packages/shared/src/threadPullRequests.test.ts+17-1| ... | @@ -3,7 +3,7 @@ import { | ... | @@ -3,7 +3,7 @@ import { |
| 3 | type ThreadPullRequestLink, | 3 | type ThreadPullRequestLink, |
| 4 | type ThreadPullRequestSnapshot, | 4 | type ThreadPullRequestSnapshot, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { describe, expect, it } from "vite-plus/test"; | 6 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| 9 | legacyLinkedPullRequestOf, | 9 | legacyLinkedPullRequestOf, |
| ... | @@ -15,6 +15,22 @@ import { | ... | @@ -15,6 +15,22 @@ import { |
| 15 | threadPullRequestKeysEqual, | 15 | threadPullRequestKeysEqual, |
| 16 | } from "./threadPullRequests.ts"; | 16 | } from "./threadPullRequests.ts"; |
| 17 | 17 | ||
| 18 | // Match Hermes: these ES2023 array methods are absent on mobile, and this module runs in | ||
| 19 | // the home thread list on every launch. | ||
| 20 | beforeEach(() => { | ||
| 21 | const methods = ["toSorted", "toReversed", "toSpliced"] as const; | ||
| 22 | const descriptors = methods.map((method) => | ||
| 23 | Object.getOwnPropertyDescriptor(Array.prototype, method), | ||
| 24 | ); | ||
| 25 | for (const method of methods) Reflect.deleteProperty(Array.prototype, method); | ||
| 26 | return () => { | ||
| 27 | for (const [index, method] of methods.entries()) { | ||
| 28 | const descriptor = descriptors[index]; | ||
| 29 | if (descriptor) Reflect.defineProperty(Array.prototype, method, descriptor); | ||
| 30 | } | ||
| 31 | }; | ||
| 32 | }); | ||
| 33 | |||
| 18 | function snapshot(input: Partial<ThreadPullRequestSnapshot> = {}): ThreadPullRequestSnapshot { | 34 | function snapshot(input: Partial<ThreadPullRequestSnapshot> = {}): ThreadPullRequestSnapshot { |
| 19 | return { | 35 | return { |
| 20 | state: "open", | 36 | state: "open", |
packages/shared/src/threadPullRequests.ts+3-1| ... | @@ -106,8 +106,10 @@ export function resolveThreadCurrentPullRequest( | ... | @@ -106,8 +106,10 @@ export function resolveThreadCurrentPullRequest( |
| 106 | if (open.length === 1) return { kind: "single", link: open[0]! }; | 106 | if (open.length === 1) return { kind: "single", link: open[0]! }; |
| 107 | const chains = resolveThreadPullRequestChains(visible); | 107 | const chains = resolveThreadPullRequestChains(visible); |
| 108 | if (open.length > 1) { | 108 | if (open.length > 1) { |
| 109 | // `.reverse()` on a copy, not `.toReversed()`: this runs on Hermes, which has no ES2023 | ||
| 110 | // array methods, and a TypeError here is fatal on every mobile launch that renders a stack. | ||
| 109 | const openChains = chains | 111 | const openChains = chains |
| 110 | .map((chain) => chain.layers.toReversed().filter(isOpen)) | 112 | .map((chain) => [...chain.layers].reverse().filter(isOpen)) |
| 111 | .filter((layers) => layers.length > 0) | 113 | .filter((layers) => layers.length > 0) |
| 112 | .sort( | 114 | .sort( |
| 113 | (left, right) => | 115 | (left, right) => |
vite.config.ts+13| ... | @@ -135,6 +135,19 @@ export default defineConfig({ | ... | @@ -135,6 +135,19 @@ export default defineConfig({ |
| 135 | files: ["apps/mobile/src/**"], | 135 | files: ["apps/mobile/src/**"], |
| 136 | rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, | 136 | rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, |
| 137 | }, | 137 | }, |
| 138 | { | ||
| 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. | ||
| 141 | // Tests run on Node and are exempt. | ||
| 142 | files: [ | ||
| 143 | "apps/mobile/src/**", | ||
| 144 | "packages/client-runtime/src/**", | ||
| 145 | "packages/contracts/src/**", | ||
| 146 | "packages/shared/src/**", | ||
| 147 | ], | ||
| 148 | excludeFiles: ["**/*.test.ts", "**/*.test.tsx"], | ||
| 149 | rules: { "t3code/no-hermes-unsupported-array-methods": "error" }, | ||
| 150 | }, | ||
| 138 | { | 151 | { |
| 139 | // 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. |
| 140 | files: [ | 153 | files: [ |