diff --git a/.changeset/healthy-ears-knock.md b/.changeset/healthy-ears-knock.md new file mode 100644 index 00000000000..cd29b113b4e --- /dev/null +++ b/.changeset/healthy-ears-knock.md @@ -0,0 +1,5 @@ +--- +"hardhat": patch +--- + +Throw better error messages when trying to use a Hardhat 2 plugin with Hardhat 3 [#7991](https://github.com/NomicFoundation/hardhat/pull/7991). diff --git a/v-next/hardhat/package.json b/v-next/hardhat/package.json index 26c3224c3c6..6afaedf8af6 100644 --- a/v-next/hardhat/package.json +++ b/v-next/hardhat/package.json @@ -21,6 +21,7 @@ "./hre": "./dist/src/hre.js", "./plugins": "./dist/src/plugins.js", "./internal/lsp-helpers": "./dist/src/lsp-helpers.js", + "./types": "./dist/src/types/index.js", "./types/arguments": "./dist/src/types/arguments.js", "./types/artifacts": "./dist/src/types/artifacts.js", "./types/config": "./dist/src/types/config.js", @@ -38,7 +39,10 @@ "./console.sol": "./console.sol", "./internal/coverage": "./dist/src/internal/builtin-plugins/coverage/exports.js", "./internal/gas-analytics": "./dist/src/internal/builtin-plugins/gas-analytics/exports.js", - "./utils/contract-names": "./dist/src/utils/contract-names.js" + "./utils/contract-names": "./dist/src/utils/contract-names.js", + "./types/runtime": "./dist/src/internal/deprecated-module-imported-from-hardhat2-plugin.js", + "./builtin-tasks/task-names": "./dist/src/internal/deprecated-module-imported-from-hardhat2-plugin.js", + "./internal/artifacts": "./dist/src/internal/deprecated-module-imported-from-hardhat2-plugin.js" }, "keywords": [ "ethereum", diff --git a/v-next/hardhat/src/config.ts b/v-next/hardhat/src/config.ts index e0a783371bd..7ffca77e1ea 100644 --- a/v-next/hardhat/src/config.ts +++ b/v-next/hardhat/src/config.ts @@ -8,6 +8,8 @@ export type { HardhatUserConfig } from "./types/config.js"; import "./internal/builtin-plugins/index.js"; import type { HardhatUserConfig } from "./types/config.js"; +import { throwUsingHardhat2PluginError } from "./internal/using-hardhat2-plugin-errors.js"; + /** * Defines a Hardhat user config. * @@ -38,3 +40,38 @@ export function defineConfig(config: HardhatUserConfig): HardhatUserConfig { // use it and have a better user experience. return config; } + +/** + * @deprecated This function is part of the Hardhat 2 plugin API. + */ +export function extendConfig(..._args: any): any { + throwUsingHardhat2PluginError(); +} + +/** + * @deprecated This function is part of the Hardhat 2 plugin API. + */ +export function extendEnvironment(..._args: any): any { + throwUsingHardhat2PluginError(); +} + +/** + * @deprecated This function is part of the Hardhat 2 plugin API. + */ +export function extendProvider(..._args: any): any { + throwUsingHardhat2PluginError(); +} + +/** + * @deprecated This function is part of the Hardhat 2 plugin API. + */ +export function scope(..._args: any): any { + throwUsingHardhat2PluginError(); +} + +/** + * @deprecated This function is part of the Hardhat 2 plugin API. + */ +export function subtask(..._args: any): any { + throwUsingHardhat2PluginError(); +} diff --git a/v-next/hardhat/src/internal/cli/error-handler.ts b/v-next/hardhat/src/internal/cli/error-handler.ts index 6ffa8e746bb..aa8dd5a9797 100644 --- a/v-next/hardhat/src/internal/cli/error-handler.ts +++ b/v-next/hardhat/src/internal/cli/error-handler.ts @@ -5,6 +5,7 @@ import { import chalk from "chalk"; import { HARDHAT_NAME, HARDHAT_WEBSITE_URL } from "../constants.js"; +import { UsingHardhat2PluginError } from "../using-hardhat2-plugin-errors.js"; /** * The different categories of errors that can be handled by hardhat cli. @@ -69,6 +70,11 @@ export function printErrorMessages( shouldShowStackTraces: boolean = false, print: (message: string | Error) => void = console.error, ): void { + if (error instanceof UsingHardhat2PluginError) { + printUsingHardhat2Error(error, print); + return; + } + const showStackTraces = shouldShowStackTraces || getErrorWithCategory(error).category === ErrorCategory.OTHER; @@ -146,3 +152,15 @@ function getErrorMessages(error: Error): ErrorMessages { }; } } +function printUsingHardhat2Error( + error: UsingHardhat2PluginError, + print: (message: string | Error) => void = console.error, +): void { + print(chalk.red.bold(`Hardhat 3 installation error:`)); + print(""); + if (error.callerRelativePath !== undefined) { + print(error.message); + } else { + print(error.stack); + } +} diff --git a/v-next/hardhat/src/internal/cli/telemetry/sentry/reporter.ts b/v-next/hardhat/src/internal/cli/telemetry/sentry/reporter.ts index 89ba018e983..dbde0b1ff9d 100644 --- a/v-next/hardhat/src/internal/cli/telemetry/sentry/reporter.ts +++ b/v-next/hardhat/src/internal/cli/telemetry/sentry/reporter.ts @@ -8,6 +8,7 @@ import { ProviderError, UnknownError, } from "../../../builtin-plugins/network-manager/provider-errors.js"; +import { UsingHardhat2PluginError } from "../../../using-hardhat2-plugin-errors.js"; import { getHardhatVersion } from "../../../utils/package.js"; import { isTelemetryAllowed } from "../telemetry-permissions.js"; @@ -134,6 +135,10 @@ class Reporter { return false; } + if (error instanceof UsingHardhat2PluginError) { + return false; + } + if (HardhatPluginError.isHardhatPluginError(error)) { // Don't log errors from third-party plugins return false; diff --git a/v-next/hardhat/src/internal/deprecated-module-imported-from-hardhat2-plugin.ts b/v-next/hardhat/src/internal/deprecated-module-imported-from-hardhat2-plugin.ts new file mode 100644 index 00000000000..34f844f2ab3 --- /dev/null +++ b/v-next/hardhat/src/internal/deprecated-module-imported-from-hardhat2-plugin.ts @@ -0,0 +1,12 @@ +// This is an empty module that is used to exported it with a subpath that's +// commonly used by Hardhat 2 plugins. This is to avoid the plugins breaking +// when the `require` it, so that they have an opportunity to run a function +// that throws a better error message. + +// The reason this module can be empty is that Hardhat 2 plugins are CJS modules +// so they can destructure the require and get `undefined` values, instead of +// a load-time error. + +// We could also throw from this file, but if it gets imported by an ESM module +// you don't get an import-stack-trace, so you loose the possibility of figuring +// out which plugin is triggering the error. diff --git a/v-next/hardhat/src/internal/using-hardhat2-plugin-errors.ts b/v-next/hardhat/src/internal/using-hardhat2-plugin-errors.ts new file mode 100644 index 00000000000..f44dc2da655 --- /dev/null +++ b/v-next/hardhat/src/internal/using-hardhat2-plugin-errors.ts @@ -0,0 +1,108 @@ +import { fileURLToPath } from "node:url"; + +import { CustomError } from "@nomicfoundation/hardhat-utils/error"; +import { shortenPath } from "@nomicfoundation/hardhat-utils/path"; +import chalk from "chalk"; + +export class UsingHardhat2PluginError extends CustomError { + public readonly callerRelativePath: string | undefined; + constructor() { + const callerPath = getCallerRelativePath(); + + let message: string; + if (callerPath !== undefined) { + message = `You are trying to use a Hardhat 2 plugin in a Hardhat 3 project. + +This file is part of a Hardhat 2 plugin calling an API that was removed in Hardhat 3: ${chalk.bold(callerPath)} + +Please read https://hardhat.org/migrate-from-hardhat2 to learn how to migrate your project to Hardhat 3. +`; + } else { + message = `You are trying to use a Hardhat 2 plugin in a Hardhat 3 project. + +Check the stack trace below to identify which plugin is causing this. + +Please read https://hardhat.org/migrate-from-hardhat2 to learn how to migrate your project to Hardhat 3. +`; + } + + super(message); + this.callerRelativePath = callerPath; + } +} + +/** + * Returns the relative path of the file that called a deprecated Hardhat + * plugin API, based on the stack trace. This helps identify which plugin + * file is triggering usage of Hardhat 2 APIs in a Hardhat 3 project. + * + * @param {number} [depth=5] The stack trace depth to locate the caller's + * source file. By default, depth 5 is used because: + * 0 = message + * 1 = getCallerRelativePath + * 2 = UsingHardhat2PluginError constructor + * 3 = throwUsingHardhat2PluginError + * 4 = deprecated function + * 5 = actual caller (the plugin file) + * + * @returns {string|undefined} The shortened relative path of the caller file, + * or undefined if not found. + * + * @example + * If the stack trace is: + * // Error + * // at getCallerRelativePath (src/internal/using-hardhat2-plugin-errors.ts:34:15) + * // at UsingHardhat2PluginError.constructor (src/internal/using-hardhat2-plugin-errors.ts:7:3) + * // at throwUsingHardhat2PluginError (src/internal/using-hardhat2-plugin-errors.ts:90:3) + * // at deprecatedFunction (plugins/example-plugin/deprecated.js:50:10) + * // at main (plugins/example-plugin/index.js:100:5) + * Calling getCallerRelativePath() returns 'plugins/example-plugin/index.js' + */ +export function getCallerRelativePath(depth: number = 5): string | undefined { + try { + const stack = new Error().stack; + if (stack === undefined) { + return undefined; + } + + const lines = stack.split("\n"); + const callerLine = lines[depth]; + if (callerLine === undefined) { + return undefined; + } + + /** + * Matches a single stack trace line: + * + * at FunctionName (path/to/file.ts:10:5) + * at path/to/file.ts:10:5 + * + * Captures: + * - group 1: file location (without line/column) + */ + const STACK_TRACE_LINE_REGEX = + /^at (?:.+? \()?([^\(].*?)(?::\d+)?(?::\d+)?\)?$/; + + const match = callerLine.trim().match(STACK_TRACE_LINE_REGEX); + if (match === null || match[1] === undefined) { + return undefined; + } + + let filePath = match[1]; + + // Handle file:// URLs from ESM stack traces + if (filePath.startsWith("file://")) { + filePath = fileURLToPath(filePath); + } + + return shortenPath(filePath); + } catch { + return undefined; + } +} + +export function throwUsingHardhat2PluginError(): never { + /* eslint-disable-next-line no-restricted-syntax -- Intentionally throwing a + custom error here so that we always print the stack trace */ + throw new UsingHardhat2PluginError(); +} diff --git a/v-next/hardhat/src/plugins.ts b/v-next/hardhat/src/plugins.ts index 829488c184e..e864cc2b1f5 100644 --- a/v-next/hardhat/src/plugins.ts +++ b/v-next/hardhat/src/plugins.ts @@ -1 +1,17 @@ export { HardhatPluginError } from "@nomicfoundation/hardhat-errors"; + +import { throwUsingHardhat2PluginError } from "./internal/using-hardhat2-plugin-errors.js"; + +/** + * @deprecated This function is part of the Hardhat 2 plugin API. + */ +export function lazyFunction(..._args: any): any { + throwUsingHardhat2PluginError(); +} + +/** + * @deprecated This function is part of the Hardhat 2 plugin API. + */ +export function lazyObject(..._args: any): any { + throwUsingHardhat2PluginError(); +} diff --git a/v-next/hardhat/src/types/index.ts b/v-next/hardhat/src/types/index.ts new file mode 100644 index 00000000000..f30021e0f1b --- /dev/null +++ b/v-next/hardhat/src/types/index.ts @@ -0,0 +1,14 @@ +export * from "./arguments.js"; +export * from "./artifacts.js"; +export * from "./config.js"; +export * from "./global-options.js"; +export * from "./hooks.js"; +export * from "./hre.js"; +export * from "./network.js"; +export * from "./plugins.js"; +export * from "./providers.js"; +export * from "./solidity.js"; +export * from "./tasks.js"; +export * from "./test.js"; +export * from "./user-interruptions.js"; +export * from "./utils.js"; diff --git a/v-next/hardhat/test/config.ts b/v-next/hardhat/test/config.ts new file mode 100644 index 00000000000..13930ab34f3 --- /dev/null +++ b/v-next/hardhat/test/config.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { assertThrows } from "@nomicfoundation/hardhat-test-utils"; + +import { + extendConfig, + extendEnvironment, + extendProvider, + scope, + subtask, +} from "../src/config.js"; +import { UsingHardhat2PluginError } from "../src/internal/using-hardhat2-plugin-errors.js"; + +describe("Hardhat 2 plugin compatibility", () => { + it("should throw when calling extendConfig", () => { + assertThrows( + () => extendConfig(), + (error) => { + assert.ok( + error instanceof UsingHardhat2PluginError, + "Should be a UsingHardhat2PluginError", + ); + assert.ok( + error.callerRelativePath?.includes(path.join("test", "config.ts")) === + true, + "Should have the caller path", + ); + return true; + }, + ); + }); + + it("should throw when calling extendEnvironment", () => { + assertThrows( + () => extendEnvironment(), + (error) => { + assert.ok( + error instanceof UsingHardhat2PluginError, + "Should be a UsingHardhat2PluginError", + ); + assert.ok( + error.callerRelativePath?.includes(path.join("test", "config.ts")) === + true, + "Should have the caller path", + ); + return true; + }, + ); + }); + + it("should throw when calling extendProvider", () => { + assertThrows( + () => extendProvider(), + (error) => { + assert.ok( + error instanceof UsingHardhat2PluginError, + "Should be a UsingHardhat2PluginError", + ); + assert.ok( + error.callerRelativePath?.includes(path.join("test", "config.ts")) === + true, + "Should have the caller path", + ); + return true; + }, + ); + }); + + it("should throw when calling scope", () => { + assertThrows( + () => scope(), + (error) => { + assert.ok( + error instanceof UsingHardhat2PluginError, + "Should be a UsingHardhat2PluginError", + ); + assert.ok( + error.callerRelativePath?.includes(path.join("test", "config.ts")) === + true, + "Should have the caller path", + ); + return true; + }, + ); + }); + + it("should throw when calling subtask", () => { + assertThrows( + () => subtask(), + (error) => { + assert.ok( + error instanceof UsingHardhat2PluginError, + "Should be a UsingHardhat2PluginError", + ); + assert.ok( + error.callerRelativePath?.includes(path.join("test", "config.ts")) === + true, + "Should have the caller path", + ); + return true; + }, + ); + }); +}); diff --git a/v-next/hardhat/test/internal/cli/error-handler.ts b/v-next/hardhat/test/internal/cli/error-handler.ts index cc2c6a92c57..4229ddee8d4 100644 --- a/v-next/hardhat/test/internal/cli/error-handler.ts +++ b/v-next/hardhat/test/internal/cli/error-handler.ts @@ -12,6 +12,7 @@ import { HARDHAT_NAME, HARDHAT_WEBSITE_URL, } from "../../../src/internal/constants.js"; +import { UsingHardhat2PluginError } from "../../../src/internal/using-hardhat2-plugin-errors.js"; const mockCoreErrorDescriptor = { number: 123, @@ -172,5 +173,56 @@ describe("error-handler", () => { ); }); }); + + describe("with a UsingHardhat2PluginError: independent of shouldShowStackTraces and the rest of the handling logic", () => { + it("should print the error message when callerRelativePath is available", () => { + const lines: Array = []; + const error = new UsingHardhat2PluginError(); + + printErrorMessages(error, false, (msg: string | Error) => { + lines.push(msg); + }); + + assert.equal(lines.length, 3); + assert.equal(lines[0], chalk.red.bold(`Hardhat 3 installation error:`)); + assert.equal(lines[1], ""); + assert.equal(lines[2], error.message); + }); + + it("should not print the stack trace even when shouldShowStackTraces is true", () => { + const lines: Array = []; + const error = new UsingHardhat2PluginError(); + + printErrorMessages(error, true, (msg: string | Error) => { + lines.push(msg); + }); + + // UsingHardhat2PluginError is handled separately and always prints + // the same output regardless of shouldShowStackTraces + assert.equal(lines.length, 3); + assert.equal(lines[0], chalk.red.bold(`Hardhat 3 installation error:`)); + assert.equal(lines[1], ""); + assert.equal(lines[2], error.message); + }); + + it("should print the stack trace when the callerRelativePath is not available", () => { + const lines: Array = []; + const error = new UsingHardhat2PluginError(); + + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions + -- Casting for testing purposes, as generating a failure of the logic + that gets the callerRelativePath is not trivial */ + (error as any).callerRelativePath = undefined; + + printErrorMessages(error, true, (msg: string | Error) => { + lines.push(msg); + }); + + assert.equal(lines.length, 3); + assert.equal(lines[0], chalk.red.bold(`Hardhat 3 installation error:`)); + assert.equal(lines[1], ""); + assert.equal(lines[2], error.stack); + }); + }); }); }); diff --git a/v-next/hardhat/test/internal/hardhat2-plugin-errors.ts b/v-next/hardhat/test/internal/hardhat2-plugin-errors.ts new file mode 100644 index 00000000000..e6648397c39 --- /dev/null +++ b/v-next/hardhat/test/internal/hardhat2-plugin-errors.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { getCallerRelativePath } from "../../src/internal/using-hardhat2-plugin-errors.js"; + +// When calling getCallerRelativePath directly: +// 0=message, 1=getCallerRelativePath, 2=actual caller +const DIRECT_CALL_DEPTH = 2; + +describe("getCallerRelativePath", () => { + it("should return a path containing this test file", () => { + const result = getCallerRelativePath(DIRECT_CALL_DEPTH); + assert(result !== undefined, "Result should not be undefined"); + assert.ok( + result.includes( + path.join("test", "internal", "hardhat2-plugin-errors.ts"), + ), + `Expected path to contain the path of "test/internal/hardhat2-plugin-errors.ts", got "${result}"`, + ); + }); + + it("should return a relative path starting with ." + path.sep, () => { + const result = getCallerRelativePath(DIRECT_CALL_DEPTH); + assert(result !== undefined, "Result should not be undefined"); + assert.ok( + result.startsWith("." + path.sep), + `Expected path to start with ".${path.sep}", got "${result}"`, + ); + }); + + it("should return undefined when depth exceeds available stack frames", () => { + const result = getCallerRelativePath(999); + assert.equal(result, undefined); + }); + + it("should resolve the correct caller at different depths", () => { + function wrapper() { + // From inside wrapper: 0=message, 1=getCallerRelativePath, 2=wrapper, 3=actual caller + return getCallerRelativePath(2); + } + const result = wrapper(); + assert(result !== undefined, "Result should not be undefined"); + assert.ok( + result.includes( + path.join("test", "internal", "hardhat2-plugin-errors.ts"), + ), + `Expected path to contain the path of "test/internal/hardhat2-plugin-errors.ts", got "${result}"`, + ); + }); +}); diff --git a/v-next/hardhat/test/plugins.ts b/v-next/hardhat/test/plugins.ts new file mode 100644 index 00000000000..5d698f1fa33 --- /dev/null +++ b/v-next/hardhat/test/plugins.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { assertThrows } from "@nomicfoundation/hardhat-test-utils"; + +import { UsingHardhat2PluginError } from "../src/internal/using-hardhat2-plugin-errors.js"; +import { lazyFunction, lazyObject } from "../src/plugins.js"; + +describe("Hardhat 2 plugin compatibility", () => { + it("should throw when calling lazyFunction", () => { + assertThrows( + () => lazyFunction(), + (error) => { + assert.ok( + error instanceof UsingHardhat2PluginError, + "Should be a UsingHardhat2PluginError", + ); + assert.ok( + error.callerRelativePath?.includes( + path.join("test", "plugins.ts"), + ) === true, + "Should have the caller path", + ); + return true; + }, + ); + }); + + it("should throw when calling lazyObject", () => { + assertThrows( + () => lazyObject(), + (error) => { + assert.ok( + error instanceof UsingHardhat2PluginError, + "Should be a UsingHardhat2PluginError", + ); + assert.ok( + error.callerRelativePath?.includes( + path.join("test", "plugins.ts"), + ) === true, + "Should have the caller path", + ); + return true; + }, + ); + }); +});