diff --git a/eslint.config.mjs b/eslint.config.mjs index 1adf4b6..5fe5fe9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,23 +6,47 @@ import testingLibrary from "eslint-plugin-testing-library"; import noRelativeImportPaths from "eslint-plugin-no-relative-import-paths"; import unusedImports from "eslint-plugin-unused-imports"; +import requireFutureBlockCapture from "./eslint/rules/require-future-block-capture.ts"; + // ESLint 9 flat config, converted from .eslintrc.json. Same rule set: the file // is longer because flat config spells out what `extends` and `env` used to imply. export default tseslint.config( - { ignores: ["build/**", "src/locales/**", "src/**/snapshots/*.ts", "**/*.d.ts"] }, + { + ignores: [ + "dist/**", + "build/**", + "node_modules/**", + "coverage/**", + "eslint.config.*", + "src/locales/**", + "src/**/snapshots/*.ts", + "**/*.d.ts", + ], + }, js.configs.recommended, ...tseslint.configs.recommended, react.configs.flat.recommended, + react.configs.flat["jsx-runtime"], + reactHooks.configs.flat.recommended, { languageOptions: { - parserOptions: { project: "./tsconfig.json" }, + parserOptions: { + ecmaVersion: "latest", + project: "./tsconfig.json", + sourceType: "module", + }, }, settings: { - react: { pragma: "React", version: "16.6.0" }, + react: { version: "detect" }, }, plugins: { + local: { + rules: { + "require-future-block-capture": requireFutureBlockCapture, + }, + }, "react-hooks": reactHooks, "no-relative-import-paths": noRelativeImportPaths, "unused-imports": unusedImports, @@ -77,11 +101,14 @@ export default tseslint.config( "react-hooks/rules-of-hooks": "warn", "react-hooks/exhaustive-deps": "warn", + // TODO: Enable `react-hooks/set-state-in-effect`. + "react-hooks/set-state-in-effect": "off", "no-relative-import-paths/no-relative-import-paths": [ "error", { allowSameFolder: true, rootDir: "src", prefix: "$" }, ], + "local/require-future-block-capture": "error", }, }, diff --git a/eslint/rules/package.json b/eslint/rules/package.json new file mode 100644 index 0000000..4720025 --- /dev/null +++ b/eslint/rules/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/eslint/rules/require-future-block-capture.md b/eslint/rules/require-future-block-capture.md new file mode 100644 index 0000000..6cf6897 --- /dev/null +++ b/eslint/rules/require-future-block-capture.md @@ -0,0 +1,21 @@ +# require-future-block-capture + +Inside a `Future.block` callback, await a `Future` through the callback's capture function. This lets the block track the operation's cancellation and failure. + +Incorrect: + +```ts +Future.block(async $ => { + const user = await loadUser(); + return user; +}); +``` + +Correct, when `loadUserFuture()` returns a `Future`: + +```ts +Future.block(async $ => { + const user = await $(loadUserFuture()); + return user; +}); +``` diff --git a/eslint/rules/require-future-block-capture.spec.ts b/eslint/rules/require-future-block-capture.spec.ts new file mode 100644 index 0000000..ae645eb --- /dev/null +++ b/eslint/rules/require-future-block-capture.spec.ts @@ -0,0 +1,81 @@ +import { TSESLint } from "@typescript-eslint/utils"; +import { describe, expect, test } from "vitest"; + +import requireFutureBlockCapture from "./require-future-block-capture"; + +function lint(source: string) { + const linter = new TSESLint.Linter({ configType: "flat" }); + const config = { + languageOptions: { ecmaVersion: 2022, sourceType: "module" }, + plugins: { + local: { + rules: { "require-future-block-capture": requireFutureBlockCapture }, + }, + }, + rules: { "local/require-future-block-capture": "error" }, + } satisfies TSESLint.FlatConfig.Config; + + return linter.verifyAndFix(source, config, {}); +} + +describe("require-future-block-capture", () => { + test("allows Futures captured in Future.block", () => { + const result = lint("Future.block(async $ => await $(Future.success(1))); "); + + expect(result.messages).toEqual([]); + }); + + test("allows Futures captured in Future.block_", () => { + const result = lint("Future.block_()(async capture => await capture(Future.success(1))); "); + + expect(result.messages).toEqual([]); + }); + + test("reports but does not rewrite a native Promise", () => { + const source = "Future.block(async $ => await Promise.resolve(1));"; + const result = lint(source); + + expect(result.output).toBe(source); + expect(result.messages).toMatchObject([{ messageId: "wrapAwait" }]); + }); + + test("does not report awaits in nested function declarations", () => { + const source = ` + Future.block(async $ => { + async function helper() { + await fetch(url); + } + + return $(Future.fromPromise(helper())); + }); + `; + + expect(lint(source).messages).toEqual([]); + }); + + test("does not report awaits in nested arrow functions", () => { + const source = ` + Future.block(async $ => { + const helper = async () => { + await fetch(url); + }; + + return $(Future.fromPromise(helper())); + }); + `; + + expect(lint(source).messages).toEqual([]); + }); + + test("reports awaits when the callback has no capture parameter", () => { + const result = lint("Future.block(async () => await Promise.resolve(1));"); + + expect(result.messages).toMatchObject([{ messageId: "wrapAwait" }]); + }); + + test("reports awaits when the callback destructures its capture parameter", () => { + const result = lint("Future.block(async ({ length }) => await Promise.resolve(length));"); + + expect(result.messages).toMatchObject([{ messageId: "wrapAwait" }]); + }); +}); diff --git a/eslint/rules/require-future-block-capture.ts b/eslint/rules/require-future-block-capture.ts new file mode 100644 index 0000000..94cfa5d --- /dev/null +++ b/eslint/rules/require-future-block-capture.ts @@ -0,0 +1,96 @@ +import { ESLintUtils, type TSESLint, type TSESTree } from "@typescript-eslint/utils"; + +type FunctionNode = + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression; + +function isIdentifier( + node: TSESTree.Node | null | undefined, + name: string +): node is TSESTree.Identifier { + return node?.type === "Identifier" && node.name === name; +} + +function isFutureMethodCall( + node: TSESTree.Node | null | undefined, + method: "block" | "block_" +): boolean { + return ( + node?.type === "CallExpression" && + node.callee.type === "MemberExpression" && + !node.callee.computed && + isIdentifier(node.callee.object, "Future") && + isIdentifier(node.callee.property, method) + ); +} + +function isFunctionNode(node: TSESTree.Node): node is FunctionNode { + return ( + node.type === "ArrowFunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" + ); +} + +function getFunctionAncestor( + sourceCode: TSESLint.SourceCode, + node: TSESTree.Node +): FunctionNode | null { + return sourceCode.getAncestors(node).findLast(isFunctionNode) ?? null; +} + +function isFutureBlockCallback(functionNode: FunctionNode): boolean { + const parent = functionNode.parent; + if (parent?.type !== "CallExpression" || parent.arguments[0] !== functionNode) return false; + + return isFutureMethodCall(parent, "block") || isFutureMethodCall(parent.callee, "block_"); +} + +const createRule = ESLintUtils.RuleCreator( + name => `https://github.com/EyeSeeTea/dhis2-app-skeleton/blob/master/eslint/rules/${name}.md` +); + +export default createRule({ + name: "require-future-block-capture", + meta: { + type: "problem", + docs: { + description: + "Require await calls inside Future.block callbacks to go through the capture function", + }, + schema: [], + messages: { + wrapAwait: "Use `await {{capture}}(...)` inside `Future.block`.", + }, + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + + return { + AwaitExpression(node) { + const functionNode = getFunctionAncestor(sourceCode, node); + if (!functionNode) return; + if (!isFutureBlockCallback(functionNode)) return; + + const firstParam = functionNode.params[0]; + const captureName = firstParam?.type === "Identifier" ? firstParam.name : null; + const awaited = node.argument; + if ( + captureName !== null && + awaited.type === "CallExpression" && + isIdentifier(awaited.callee, captureName) + ) { + return; + } + + context.report({ + node, + messageId: "wrapAwait", + data: { capture: captureName ?? "$" }, + }); + }, + }; + }, +}); diff --git a/package.json b/package.json index 6b20f97..b1f9fa3 100644 --- a/package.json +++ b/package.json @@ -48,13 +48,14 @@ "@types/react-router-dom": "5.3.3", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", + "@typescript-eslint/utils": "^8.65.0", "@vitejs/plugin-react": "^5.0.4", "cmd-ts": "^0", "depcheck": "^1.4.7", "eslint": "^9.39.5", "eslint-plugin-no-relative-import-paths": "^1.5.3", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-testing-library": "^7.16.2", "eslint-plugin-unused-imports": "^4", "expect-type": "^0", diff --git a/tsconfig.json b/tsconfig.json index 0b6c2b7..46fad69 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,6 @@ "$/*": ["./*"] } }, - "include": ["src"], + "include": ["src", "eslint/**/*.ts"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/yarn.lock b/yarn.lock index 0bc0322..67e77ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36,7 +36,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.29.0": +"@babel/core@npm:^7.24.4, @babel/core@npm:^7.29.0": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -164,6 +164,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.24.4": + version: 7.29.8 + resolution: "@babel/parser@npm:7.29.8" + dependencies: + "@babel/types": "npm:^7.29.8" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/acc890c5e6a6dd40863a47b50bac111d7185ee6fbbe163ebe11d5214854ca2adb901462ad4d718a65090ef84bd2230e9e8ab45a2e0caccc685f1f57ab0bb1e28 + languageName: node + linkType: hard + "@babel/plugin-transform-react-jsx-self@npm:^7.27.1": version: 7.29.7 resolution: "@babel/plugin-transform-react-jsx-self@npm:7.29.7" @@ -229,6 +240,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/types@npm:7.29.8" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6 + languageName: node + linkType: hard + "@date-io/core@npm:1.3.6": version: 1.3.6 resolution: "@date-io/core@npm:1.3.6" @@ -3738,6 +3759,19 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/project-service@npm:8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/project-service@npm:8.70.0" + dependencies: + "@typescript-eslint/tsconfig-utils": "npm:^8.70.0" + "@typescript-eslint/types": "npm:^8.70.0" + debug: "npm:^4.4.3" + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/eb7d0b4c91015e9aa47672ba20dfda0717e5fa09afa7e28f25b5d7b7b0cd8af4b394f94c3a1528358a7d8c8be4165ed4291a345cbe19bb137a24c6a4f006b398 + languageName: node + linkType: hard + "@typescript-eslint/scope-manager@npm:8.60.0": version: 8.60.0 resolution: "@typescript-eslint/scope-manager@npm:8.60.0" @@ -3758,6 +3792,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/scope-manager@npm:8.70.0" + dependencies: + "@typescript-eslint/types": "npm:8.70.0" + "@typescript-eslint/visitor-keys": "npm:8.70.0" + checksum: 10c0/ca05825fb16266d90e73bd1745407f719fc6d5fdb33045cdae21d3259f13c9b54d8b956d5c64a3eb83baff7690527fb16d01a2d7318379f97fb79a1cbf8ca103 + languageName: node + linkType: hard + "@typescript-eslint/tsconfig-utils@npm:8.60.0, @typescript-eslint/tsconfig-utils@npm:^8.60.0": version: 8.60.0 resolution: "@typescript-eslint/tsconfig-utils@npm:8.60.0" @@ -3776,6 +3820,15 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/tsconfig-utils@npm:8.70.0, @typescript-eslint/tsconfig-utils@npm:^8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.70.0" + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/925df7c1fbb4e8050dda3fa3dad4a6f515a1f7f2b43f5c145d9eaac856adb84bba04eb5038cc0f78c2307c1df4e2d64dbb4e2e47abffa4844e25cfe62ce402cf + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:8.60.0": version: 8.60.0 resolution: "@typescript-eslint/type-utils@npm:8.60.0" @@ -3822,6 +3875,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:8.70.0, @typescript-eslint/types@npm:^8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/types@npm:8.70.0" + checksum: 10c0/e3f98d0c0e708fcadebb3b457bf3f38a2512220b6bc9823212cacf310377539fb3c24f9e9e754d13b611a3ac6028a0bba16d02eeef03198c65e7ba605bb2036d + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:8.60.0": version: 8.60.0 resolution: "@typescript-eslint/typescript-estree@npm:8.60.0" @@ -3860,6 +3920,25 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.70.0" + dependencies: + "@typescript-eslint/project-service": "npm:8.70.0" + "@typescript-eslint/tsconfig-utils": "npm:8.70.0" + "@typescript-eslint/types": "npm:8.70.0" + "@typescript-eslint/visitor-keys": "npm:8.70.0" + debug: "npm:^4.4.3" + minimatch: "npm:^10.2.2" + semver: "npm:^7.7.3" + tinyglobby: "npm:^0.2.15" + ts-api-utils: "npm:^2.5.0" + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/11e6c9c0108184902bc229376cce00eb81ee2b88a7cc3bf02a8c159bf59b2c7af21d2b330976b1751fedd5c27e257731b0d60840268299582c458c2a0a29525e + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:8.60.0": version: 8.60.0 resolution: "@typescript-eslint/utils@npm:8.60.0" @@ -3890,6 +3969,21 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:^8.65.0": + version: 8.70.0 + resolution: "@typescript-eslint/utils@npm:8.70.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.9.1" + "@typescript-eslint/scope-manager": "npm:8.70.0" + "@typescript-eslint/types": "npm:8.70.0" + "@typescript-eslint/typescript-estree": "npm:8.70.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/15353ecd0c5e29057e5846c6b84de601b679448e01293dbf1ae7dbe94ab6d4b586f8690a262630522441307fafad01ab3b0b46a85eb71907c19a2f124d7bba3f + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:8.60.0": version: 8.60.0 resolution: "@typescript-eslint/visitor-keys@npm:8.60.0" @@ -3910,6 +4004,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.70.0" + dependencies: + "@typescript-eslint/types": "npm:8.70.0" + eslint-visitor-keys: "npm:^5.0.0" + checksum: 10c0/43fe28dc1045a686deead8c9334f6e9d8b3fa8f84917147aabd9747783b8bc531254a95a9bfb33355b25cf5ec9a72c62bcde313f13869d7a5e5603d96be593a9 + languageName: node + linkType: hard + "@vitejs/plugin-react@npm:^5.0.4": version: 5.2.0 resolution: "@vitejs/plugin-react@npm:5.2.0" @@ -5670,6 +5774,7 @@ __metadata: "@types/react-router-dom": "npm:5.3.3" "@typescript-eslint/eslint-plugin": "npm:^8" "@typescript-eslint/parser": "npm:^8" + "@typescript-eslint/utils": "npm:^8.65.0" "@vitejs/plugin-react": "npm:^5.0.4" cmd-ts: "npm:^0" d2-manifest: "npm:^1" @@ -5677,7 +5782,7 @@ __metadata: eslint: "npm:^9.39.5" eslint-plugin-no-relative-import-paths: "npm:^1.5.3" eslint-plugin-react: "npm:^7.37.5" - eslint-plugin-react-hooks: "npm:^5.2.0" + eslint-plugin-react-hooks: "npm:^7.1.1" eslint-plugin-testing-library: "npm:^7.16.2" eslint-plugin-unused-imports: "npm:^4" expect-type: "npm:^0" @@ -6260,12 +6365,18 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-hooks@npm:^5.2.0": - version: 5.2.0 - resolution: "eslint-plugin-react-hooks@npm:5.2.0" +"eslint-plugin-react-hooks@npm:^7.1.1": + version: 7.1.1 + resolution: "eslint-plugin-react-hooks@npm:7.1.1" + dependencies: + "@babel/core": "npm:^7.24.4" + "@babel/parser": "npm:^7.24.4" + hermes-parser: "npm:^0.25.1" + zod: "npm:^3.25.0 || ^4.0.0" + zod-validation-error: "npm:^3.5.0 || ^4.0.0" peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - checksum: 10c0/1c8d50fa5984c6dea32470651807d2922cc3934cf3425e78f84a24c2dfd972e7f019bee84aefb27e0cf2c13fea0ac1d4473267727408feeb1c56333ca1489385 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + checksum: 10c0/cee8454915d71ac5d70a0d8f4f260e76eaf45fcd4162747dd4282b792ee5616d187351dabe6cdcff9040c79d0cec625635c4fd0777276be119efa88ebe058525 languageName: node linkType: hard @@ -7130,6 +7241,22 @@ __metadata: languageName: node linkType: hard +"hermes-estree@npm:0.25.1": + version: 0.25.1 + resolution: "hermes-estree@npm:0.25.1" + checksum: 10c0/48be3b2fa37a0cbc77a112a89096fa212f25d06de92781b163d67853d210a8a5c3784fac23d7d48335058f7ed283115c87b4332c2a2abaaccc76d0ead1a282ac + languageName: node + linkType: hard + +"hermes-parser@npm:^0.25.1": + version: 0.25.1 + resolution: "hermes-parser@npm:0.25.1" + dependencies: + hermes-estree: "npm:0.25.1" + checksum: 10c0/3abaa4c6f1bcc25273f267297a89a4904963ea29af19b8e4f6eabe04f1c2c7e9abd7bfc4730ddb1d58f2ea04b6fee74053d8bddb5656ec6ebf6c79cc8d14202c + languageName: node + linkType: hard + "history@npm:^4.9.0": version: 4.10.1 resolution: "history@npm:4.10.1" @@ -11734,3 +11861,19 @@ __metadata: checksum: 10c0/b9de0ac816d454e801a62179719df2a27b5731cd1b213bbcdbf5e47c43ec6c42baccbf32ee976026c0420a90f651131e4893e697e4aeeab1b14cc52f4668635e languageName: node linkType: hard + +"zod-validation-error@npm:^3.5.0 || ^4.0.0": + version: 4.0.2 + resolution: "zod-validation-error@npm:4.0.2" + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + checksum: 10c0/0ccfec48c46de1be440b719cd02044d4abb89ed0e14c13e637cd55bf29102f67ccdba373f25def0fc7130e5f15025be4d557a7edcc95d5a3811599aade689e1b + languageName: node + linkType: hard + +"zod@npm:^3.25.0 || ^4.0.0": + version: 4.5.4 + resolution: "zod@npm:4.5.4" + checksum: 10c0/511a2a4d1a6f875dfdd70a1586989a8b14ef126776cd6218a2c760b9f65f14ef389ec20d92ee1aa4fcb4566d4ff3fa012956044f9dc503510d82e4c756a8ba92 + languageName: node + linkType: hard