From 392fc388556e9ec3ca1309db0ffb2ded24439ee2 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Fri, 13 Mar 2026 23:03:29 +0000 Subject: [PATCH 1/5] feat: add --gas-stats-json global option to export gas usage statistics to a JSON file --- .changeset/wet-apes-kick.md | 7 + .peer-bumps.json | 13 +- v-next/hardhat-mocha/src/task-action.ts | 3 +- .../src/task-action.ts | 3 +- .../gas-analytics/gas-analytics-manager.ts | 70 +++- .../gas-analytics/hook-handlers/hre.ts | 5 +- .../gas-analytics/hook-handlers/test.ts | 17 +- .../builtin-plugins/gas-analytics/index.ts | 10 +- .../gas-analytics/type-extensions.ts | 1 + .../builtin-plugins/gas-analytics/types.ts | 34 ++ .../solidity-test/task-action.ts | 4 +- .../builtin-plugins/test/task-action.ts | 24 +- .../gas-analytics/gas-analytics-manager.ts | 358 +++++++++++++++++- v-next/hardhat/test/internal/cli/main.ts | 3 + .../test/internal/hre-initialization.ts | 1 + 15 files changed, 539 insertions(+), 14 deletions(-) create mode 100644 .changeset/wet-apes-kick.md diff --git a/.changeset/wet-apes-kick.md b/.changeset/wet-apes-kick.md new file mode 100644 index 00000000000..4a3616df889 --- /dev/null +++ b/.changeset/wet-apes-kick.md @@ -0,0 +1,7 @@ +--- +"@nomicfoundation/hardhat-node-test-runner": minor +"@nomicfoundation/hardhat-mocha": minor +"hardhat": minor +--- + +Add `--gas-stats-json ` global option to write gas usage statistics to a JSON file ([#7990](https://github.com/NomicFoundation/hardhat/issues/7990)). diff --git a/.peer-bumps.json b/.peer-bumps.json index 5ed8663a7f8..40926a41b1a 100644 --- a/.peer-bumps.json +++ b/.peer-bumps.json @@ -6,5 +6,16 @@ "v-next/hardhat/templates", "v-next/config" ], - "bumps": [] + "bumps": [ + { + "package": "@nomicfoundation/hardhat-mocha", + "peer": "hardhat", + "reason": "hardhat-mocha now reads hre.globalOptions.gasStatsJson, which is only present in the hardhat version that added the --gas-stats-json global option" + }, + { + "package": "@nomicfoundation/hardhat-node-test-runner", + "peer": "hardhat", + "reason": "hardhat-node-test-runner now reads hre.globalOptions.gasStatsJson, which is only present in the hardhat version that added the --gas-stats-json global option" + } + ] } diff --git a/v-next/hardhat-mocha/src/task-action.ts b/v-next/hardhat-mocha/src/task-action.ts index 3739acd3d4a..f201d8fb4d7 100644 --- a/v-next/hardhat-mocha/src/task-action.ts +++ b/v-next/hardhat-mocha/src/task-action.ts @@ -123,7 +123,8 @@ const testWithHardhat: NewTaskActionFunction = async ( if ( hre.globalOptions.coverage === true || - hre.globalOptions.gasStats === true + hre.globalOptions.gasStats === true || + hre.globalOptions.gasStatsJson !== undefined ) { const testWorkerDone = new URL( import.meta.resolve("@nomicfoundation/hardhat-mocha/test-worker-done"), diff --git a/v-next/hardhat-node-test-runner/src/task-action.ts b/v-next/hardhat-node-test-runner/src/task-action.ts index a1a53db7ba5..238b085e0e1 100644 --- a/v-next/hardhat-node-test-runner/src/task-action.ts +++ b/v-next/hardhat-node-test-runner/src/task-action.ts @@ -95,7 +95,8 @@ const testWithHardhat: NewTaskActionFunction = async ( if ( hre.globalOptions.coverage === true || - hre.globalOptions.gasStats === true + hre.globalOptions.gasStats === true || + hre.globalOptions.gasStatsJson !== undefined ) { const testWorkerDone = new URL( import.meta.resolve( diff --git a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts index 4e69d06765c..2cd46229832 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts @@ -1,4 +1,10 @@ -import type { GasAnalyticsManager, GasMeasurement } from "./types.js"; +import type { + ContractGasStatsJson, + GasAnalyticsManager, + GasMeasurement, + GasStatsJson, + GasStatsJsonEntry, +} from "./types.js"; import type { TableItem } from "@nomicfoundation/hardhat-utils/format"; import crypto from "node:crypto"; @@ -16,6 +22,8 @@ import { findDuplicates } from "@nomicfoundation/hardhat-utils/lang"; import chalk from "chalk"; import debug from "debug"; +import { parseFullyQualifiedName } from "../../../utils/contract-names.js"; + const gasStatsLog = debug( "hardhat:core:gas-analytics:gas-analytics-manager:gas-stats", ); @@ -94,6 +102,26 @@ export class GasAnalyticsManagerImplementation implements GasAnalyticsManager { gasStatsLog("Printed markdown report"); } + public async writeGasStatsJson( + outputPath: string, + ...ids: string[] + ): Promise { + if (!this.#reportEnabled) { + return; + } + + await this._loadGasMeasurements(...ids); + + const gasStatsByContract = this._calculateGasStats(); + + const resolvedPath = path.resolve(outputPath); + await ensureDir(path.dirname(resolvedPath)); + + const json = this._generateGasStatsJson(gasStatsByContract); + await writeJsonFile(resolvedPath, json); + gasStatsLog("Written gas stats JSON to", resolvedPath); + } + public enableReport(): void { this.#reportEnabled = true; } @@ -305,6 +333,46 @@ export class GasAnalyticsManagerImplementation implements GasAnalyticsManager { return formatTable(rows); } + + /** + * @private exposed for testing purposes only + */ + public _generateGasStatsJson( + gasStatsByContract: GasStatsByContract, + ): GasStatsJson { + const sortedContracts = [...gasStatsByContract.entries()] + .map(([internalFqn, stats]) => ({ + userFqn: getUserFqn(internalFqn), + stats, + })) + .sort((a, b) => a.userFqn.localeCompare(b.userFqn)); + + const contracts: Record = {}; + + for (const { userFqn, stats } of sortedContracts) { + const { sourceName, contractName } = parseFullyQualifiedName(userFqn); + + const deployment: GasStatsJsonEntry | null = + stats.deployment !== undefined ? { ...stats.deployment } : null; + + let functions: Record | null = null; + if (stats.functions.size > 0) { + functions = {}; + // Sort functions by removing trailing ) and comparing alphabetically. + // This ensures that overloaded functions with fewer params come first + // (e.g., foo(uint256) comes before foo(uint256,uint256)). In other + // scenarios, removing the trailing ) has no effect on the order. + const sortedFunctions = [...stats.functions.entries()].sort( + ([a], [b]) => a.split(")")[0].localeCompare(b.split(")")[0]), + ); + functions = Object.fromEntries(sortedFunctions); + } + + contracts[userFqn] = { sourceName, contractName, deployment, functions }; + } + + return { contracts }; + } } export function avg(values: number[]): number { diff --git a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/hre.ts b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/hre.ts index 28d2e8f6eee..b68e2e24feb 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/hre.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/hre.ts @@ -5,7 +5,10 @@ import { setGasAnalyticsManager } from "../helpers.js"; export default async (): Promise> => ({ created: async (context, hre) => { - if (context.globalOptions.gasStats) { + if ( + context.globalOptions.gasStats || + context.globalOptions.gasStatsJson !== undefined + ) { const gasAnalyticsManager = new GasAnalyticsManagerImplementation( hre.config.paths.cache, ); diff --git a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/test.ts b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/test.ts index 519a045ea79..c65f18a36d2 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/test.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/test.ts @@ -19,11 +19,18 @@ export default async (): Promise> => ({ }, }); +function isGasStatsEnabled(context: HookContext): boolean { + return ( + context.globalOptions.gasStats === true || + context.globalOptions.gasStatsJson !== undefined + ); +} + export async function testRunStart( context: HookContext, id: string, ): Promise { - if (context.globalOptions.gasStats === true) { + if (isGasStatsEnabled(context)) { await getGasAnalyticsManager(context).clearGasMeasurements(id); } } @@ -32,7 +39,7 @@ export async function testWorkerDone( context: HookContext, id: string, ): Promise { - if (context.globalOptions.gasStats === true) { + if (isGasStatsEnabled(context)) { await getGasAnalyticsManager(context).saveGasMeasurements(id); } } @@ -44,4 +51,10 @@ export async function testRunDone( if (context.globalOptions.gasStats === true) { await getGasAnalyticsManager(context).reportGasStats(id); } + if (context.globalOptions.gasStatsJson !== undefined) { + await getGasAnalyticsManager(context).writeGasStatsJson( + context.globalOptions.gasStatsJson, + id, + ); + } } diff --git a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/index.ts b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/index.ts index e529828909a..757a73c353b 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/index.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/index.ts @@ -1,6 +1,7 @@ import type { HardhatPlugin } from "../../../types/plugins.js"; -import { globalFlag, overrideTask } from "../../core/config.js"; +import { ArgumentType } from "../../../types/arguments.js"; +import { globalFlag, globalOption, overrideTask } from "../../core/config.js"; import "./type-extensions.js"; @@ -43,6 +44,13 @@ const hardhatPlugin: HardhatPlugin = { description: "Collects and displays gas usage statistics for all function calls during tests", }), + globalOption({ + name: "gasStatsJson", + description: + "Write gas usage statistics to a JSON file at the specified path", + type: ArgumentType.STRING_WITHOUT_DEFAULT, + defaultValue: undefined, + }), ], hookHandlers: { hre: () => import("./hook-handlers/hre.js"), diff --git a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/type-extensions.ts b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/type-extensions.ts index ee188375ecf..b17a091546c 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/type-extensions.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/type-extensions.ts @@ -2,5 +2,6 @@ import "../../../types/global-options.js"; declare module "../../../types/global-options.js" { export interface GlobalOptions { gasStats: boolean; + gasStatsJson: string; } } diff --git a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/types.ts b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/types.ts index 1d3b92f524f..01298f2e966 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/types.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/types.ts @@ -1,3 +1,36 @@ +/** + * Gas statistics for a single function call or deployment (min/max/avg/median/count). + */ +export interface GasStatsJsonEntry { + min: number; + max: number; + avg: number; + median: number; + count: number; +} + +/** + * Gas statistics for a single contract in the JSON output. + * `deployment` is null when the contract was never deployed during the test run + * (e.g. deployed by a factory or via forking). `functions` is null when the + * contract was deployed but no functions were called. + */ +export interface ContractGasStatsJson { + sourceName: string; + contractName: string; + deployment: GasStatsJsonEntry | null; + functions: Record | null; +} + +/** + * Top-level JSON output shape produced by `--gas-stats-json`. + * Contract keys are user-friendly FQNs (e.g. `contracts/Foo.sol:Foo`), + * sorted alphabetically. + */ +export interface GasStatsJson { + contracts: Record; +} + interface BaseGasMeasurement { contractFqn: string; gas: number; @@ -21,6 +54,7 @@ export interface GasAnalyticsManager { clearGasMeasurements(id: string): Promise; saveGasMeasurements(id: string): Promise; reportGasStats(...ids: string[]): Promise; + writeGasStatsJson(outputPath: string, ...ids: string[]): Promise; enableReport(): void; disableReport(): void; diff --git a/v-next/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/v-next/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index 8195c105ae2..325ec17feba 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -169,7 +169,9 @@ const runSolidityTests: NewTaskActionFunction = async ( verbosity, observability: observabilityConfig, testPattern: grep, - generateGasReport: hre.globalOptions.gasStats, + generateGasReport: + hre.globalOptions.gasStats || + hre.globalOptions.gasStatsJson !== undefined, }); const tracingConfig: TracingConfigWithBuffers = { buildInfos, diff --git a/v-next/hardhat/src/internal/builtin-plugins/test/task-action.ts b/v-next/hardhat/src/internal/builtin-plugins/test/task-action.ts index 63f6b4c94c7..d7950c4543a 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/test/task-action.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/test/task-action.ts @@ -74,7 +74,10 @@ const runAllTests: NewTaskActionFunction = async ( getCoverageManager(hre).disableReport(); } - if (hre.globalOptions.gasStats === true) { + if ( + hre.globalOptions.gasStats === true || + hre.globalOptions.gasStatsJson !== undefined + ) { getGasAnalyticsManager(hre).disableReport(); } @@ -235,11 +238,24 @@ const runAllTests: NewTaskActionFunction = async ( console.log(); } - if (hre.globalOptions.gasStats === true) { + if ( + hre.globalOptions.gasStats === true || + hre.globalOptions.gasStatsJson !== undefined + ) { const gasAnalytics = getGasAnalyticsManager(hre); gasAnalytics.enableReport(); - await gasAnalytics.reportGasStats(...ranSubtaskIds); - console.log(); + + if (hre.globalOptions.gasStats === true) { + await gasAnalytics.reportGasStats(...ranSubtaskIds); + console.log(); + } + + if (hre.globalOptions.gasStatsJson !== undefined) { + await gasAnalytics.writeGasStatsJson( + hre.globalOptions.gasStatsJson, + ...ranSubtaskIds, + ); + } } if (hasFailures) { diff --git a/v-next/hardhat/test/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts b/v-next/hardhat/test/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts index 2fca80952c6..5a218813ee5 100644 --- a/v-next/hardhat/test/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts +++ b/v-next/hardhat/test/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts @@ -1,4 +1,7 @@ -import type { GasMeasurement } from "../../../../src/internal/builtin-plugins/gas-analytics/types.js"; +import type { + GasMeasurement, + GasStatsJson, +} from "../../../../src/internal/builtin-plugins/gas-analytics/types.js"; import assert from "node:assert/strict"; import path from "node:path"; @@ -20,6 +23,7 @@ import { getFunctionName, GasAnalyticsManagerImplementation, } from "../../../../src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.js"; +import { getFullyQualifiedName } from "../../../../src/utils/contract-names.js"; describe("gas-analytics-manager", () => { disableConsole(); @@ -1094,6 +1098,358 @@ describe("gas-analytics-manager", () => { ); }); }); + + describe("_generateGasStatsJson", () => { + it("should return empty contracts object when no measurements", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + const result = manager._generateGasStatsJson(new Map()); + assert.deepEqual(result, { contracts: {} }); + }); + + it("should include both deployment and functions stats", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "deployment", + contractFqn: "project/contracts/MyContract.sol:MyContract", + gas: 500000, + size: 2048, + }); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/MyContract.sol:MyContract", + functionSig: "transfer(address,uint256)", + gas: 25000, + }); + const stats = manager._calculateGasStats(); + const result = manager._generateGasStatsJson(stats); + + const contract = + result.contracts["contracts/MyContract.sol:MyContract"]; + assert.ok(contract !== undefined, "contract entry should exist"); + assert.equal(contract.sourceName, "contracts/MyContract.sol"); + assert.equal(contract.contractName, "MyContract"); + assert.deepEqual(contract.deployment, { + min: 500000, + max: 500000, + avg: 500000, + median: 500000, + count: 1, + }); + assert.ok(contract.functions !== null, "functions should not be null"); + assert.deepEqual(contract.functions.transfer, { + min: 25000, + max: 25000, + avg: 25000, + median: 25000, + count: 1, + }); + }); + + it("should set deployment to null when contract has only function calls", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Token.sol:Token", + functionSig: "balanceOf(address)", + gas: 15000, + }); + const stats = manager._calculateGasStats(); + const result = manager._generateGasStatsJson(stats); + + const contract = result.contracts["contracts/Token.sol:Token"]; + assert.ok(contract !== undefined, "contract entry should exist"); + assert.equal(contract.deployment, null); + assert.ok(contract.functions !== null, "functions should not be null"); + }); + + it("should set functions to null when contract has only deployments", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "deployment", + contractFqn: "project/contracts/Factory.sol:Factory", + gas: 300000, + size: 1024, + }); + const stats = manager._calculateGasStats(); + const result = manager._generateGasStatsJson(stats); + + const contract = result.contracts["contracts/Factory.sol:Factory"]; + assert.ok(contract !== undefined, "contract entry should exist"); + assert.ok( + contract.deployment !== null, + "deployment should not be null", + ); + assert.equal(contract.functions, null); + }); + + it("should sort contracts alphabetically by user-friendly FQN", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "deployment", + contractFqn: "project/contracts/ZContract.sol:ZContract", + gas: 100000, + size: 512, + }); + manager.addGasMeasurement({ + type: "deployment", + contractFqn: "project/contracts/AContract.sol:AContract", + gas: 200000, + size: 512, + }); + const stats = manager._calculateGasStats(); + const result = manager._generateGasStatsJson(stats); + + const keys = Object.keys(result.contracts); + assert.equal(keys[0], "contracts/AContract.sol:AContract"); + assert.equal(keys[1], "contracts/ZContract.sol:ZContract"); + }); + + it("should sort functions alphabetically within a contract", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Token.sol:Token", + functionSig: "transfer(address,uint256)", + gas: 25000, + }); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Token.sol:Token", + functionSig: "approve(address,uint256)", + gas: 46000, + }); + const stats = manager._calculateGasStats(); + const result = manager._generateGasStatsJson(stats); + + const tokenContract = result.contracts["contracts/Token.sol:Token"]; + assert.ok(tokenContract !== undefined, "token contract should exist"); + assert.ok( + tokenContract.functions !== null, + "functions should not be null", + ); + const fns = Object.keys(tokenContract.functions); + assert.equal(fns[0], "approve"); + assert.equal(fns[1], "transfer"); + }); + + it("should use full signatures as keys for overloaded functions", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Token.sol:Token", + functionSig: "transfer(address,uint256)", + gas: 25000, + }); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Token.sol:Token", + functionSig: "transfer(address,uint256,bytes)", + gas: 35000, + }); + const stats = manager._calculateGasStats(); + const result = manager._generateGasStatsJson(stats); + + const overloadedContract = + result.contracts["contracts/Token.sol:Token"]; + assert.ok(overloadedContract !== undefined, "contract should exist"); + assert.ok( + overloadedContract.functions !== null, + "functions should not be null", + ); + assert.ok( + "transfer(address,uint256)" in overloadedContract.functions, + "overloaded function should use full signature", + ); + assert.ok( + "transfer(address,uint256,bytes)" in overloadedContract.functions, + "overloaded function should use full signature", + ); + }); + + it("should strip project/ prefix from contract keys via getUserFqn", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "deployment", + contractFqn: "project/contracts/MyContract.sol:MyContract", + gas: 100000, + size: 512, + }); + const stats = manager._calculateGasStats(); + const result = manager._generateGasStatsJson(stats); + + assert.ok( + "contracts/MyContract.sol:MyContract" in result.contracts, + "key should not have project/ prefix", + ); + assert.ok( + !("project/contracts/MyContract.sol:MyContract" in result.contracts), + "key with project/ prefix should not exist", + ); + }); + + it("should strip npm package version from contract keys", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "function", + contractFqn: + "npm/@openzeppelin/contracts@5.0.0/token/ERC20/ERC20.sol:ERC20", + functionSig: "approve(address,uint256)", + gas: 46200, + }); + const stats = manager._calculateGasStats(); + const result = manager._generateGasStatsJson(stats); + + const key = "@openzeppelin/contracts/token/ERC20/ERC20.sol:ERC20"; + assert.ok(key in result.contracts, "key should not have npm version"); + const erc20Contract = result.contracts[key]; + assert.ok(erc20Contract !== undefined, "ERC20 contract should exist"); + assert.equal( + erc20Contract.sourceName, + "@openzeppelin/contracts/token/ERC20/ERC20.sol", + ); + assert.equal(erc20Contract.contractName, "ERC20"); + }); + + it("should match artifact format — FQN key equals getFullyQualifiedName(sourceName, contractName)", () => { + const sourceName = "contracts/MyToken.sol"; + const contractName = "MyToken"; + const internalFqn = `project/${sourceName}:${contractName}`; + + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "deployment", + contractFqn: internalFqn, + gas: 250000, + size: 1024, + }); + const stats = manager._calculateGasStats(); + const result = manager._generateGasStatsJson(stats); + + const expectedKey = getFullyQualifiedName(sourceName, contractName); + const contract = result.contracts[expectedKey]; + + assert.ok( + contract !== undefined, + `contract should exist at key ${expectedKey}`, + ); + assert.equal(contract.sourceName, sourceName); + assert.equal(contract.contractName, contractName); + }); + }); + + describe("writeGasStatsJson", () => { + afterEach(async () => { + await emptyDir(tmpDir); + }); + + it("should write JSON file at the specified path", async () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/MyContract.sol:MyContract", + functionSig: "transfer(address,uint256)", + gas: 25000, + }); + await manager.saveGasMeasurements("test-id"); + + const outputPath = path.join(tmpDir, "gas-output.json"); + await manager.writeGasStatsJson(outputPath, "test-id"); + + const json = await readJsonFile(outputPath); + assert.ok( + "contracts/MyContract.sol:MyContract" in json.contracts, + "output should contain the contract", + ); + const writtenContract = + json.contracts["contracts/MyContract.sol:MyContract"]; + assert.ok( + writtenContract !== undefined, + "contract should exist in output", + ); + assert.ok( + writtenContract.functions !== null, + "functions should not be null", + ); + assert.deepEqual(writtenContract.functions.transfer, { + min: 25000, + max: 25000, + avg: 25000, + median: 25000, + count: 1, + }); + }); + + it("should create parent directories if they do not exist", async () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "deployment", + contractFqn: "project/contracts/MyContract.sol:MyContract", + gas: 500000, + size: 2048, + }); + await manager.saveGasMeasurements("test-id"); + + const outputPath = path.join( + tmpDir, + "nested", + "deep", + "gas-output.json", + ); + await manager.writeGasStatsJson(outputPath, "test-id"); + + const json = await readJsonFile(outputPath); + assert.ok( + "contracts/MyContract.sol:MyContract" in json.contracts, + "written JSON should contain the contract", + ); + }); + + it("should resolve a relative path against process.cwd()", async () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/MyContract.sol:MyContract", + functionSig: "transfer(address,uint256)", + gas: 25000, + }); + await manager.saveGasMeasurements("test-id"); + + const originalCwd = process.cwd(); + process.chdir(tmpDir); + try { + await manager.writeGasStatsJson("relative-output.json", "test-id"); + } finally { + process.chdir(originalCwd); + } + + const expectedPath = path.join(tmpDir, "relative-output.json"); + const json = await readJsonFile(expectedPath); + assert.ok( + "contracts/MyContract.sol:MyContract" in json.contracts, + "output should contain the contract", + ); + }); + + it("should not write file when report is disabled", async () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/MyContract.sol:MyContract", + functionSig: "transfer(address,uint256)", + gas: 25000, + }); + await manager.saveGasMeasurements("test-id"); + + manager.disableReport(); + const outputPath = path.join(tmpDir, "should-not-exist.json"); + await manager.writeGasStatsJson(outputPath, "test-id"); + + const files = await getAllFilesMatching(tmpDir, (f) => + f.endsWith("should-not-exist.json"), + ); + assert.equal(files.length, 0, "file should not have been written"); + }); + }); }); describe("helpers", () => { diff --git a/v-next/hardhat/test/internal/cli/main.ts b/v-next/hardhat/test/internal/cli/main.ts index 3e2defdd944..229abcc29ec 100644 --- a/v-next/hardhat/test/internal/cli/main.ts +++ b/v-next/hardhat/test/internal/cli/main.ts @@ -290,6 +290,7 @@ GLOBAL OPTIONS: --config A Hardhat config file --coverage Enables code coverage --gas-stats Collects and displays gas usage statistics for all function calls during tests + --gas-stats-json Write gas usage statistics to a JSON file at the specified path --help, -h Show this message, or a task's help if its name is provided --init Initializes a Hardhat project --network The network to connect to @@ -363,6 +364,7 @@ GLOBAL OPTIONS: --config A Hardhat config file --coverage Enables code coverage --gas-stats Collects and displays gas usage statistics for all function calls during tests + --gas-stats-json Write gas usage statistics to a JSON file at the specified path --help, -h Show this message, or a task's help if its name is provided --init Initializes a Hardhat project --network The network to connect to @@ -403,6 +405,7 @@ GLOBAL OPTIONS: --config A Hardhat config file --coverage Enables code coverage --gas-stats Collects and displays gas usage statistics for all function calls during tests + --gas-stats-json Write gas usage statistics to a JSON file at the specified path --help, -h Show this message, or a task's help if its name is provided --init Initializes a Hardhat project --network The network to connect to diff --git a/v-next/hardhat/test/internal/hre-initialization.ts b/v-next/hardhat/test/internal/hre-initialization.ts index 7f3b5ff8ad7..762c77d3215 100644 --- a/v-next/hardhat/test/internal/hre-initialization.ts +++ b/v-next/hardhat/test/internal/hre-initialization.ts @@ -248,6 +248,7 @@ describe("HRE initialization", () => { config: configPath, coverage: false, gasStats: false, + gasStatsJson: undefined, help: false, init: false, showStackTraces: false, From 66233b3fde665178a8ee9714e4b0c7cce514fe43 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Mon, 16 Mar 2026 14:01:41 +0000 Subject: [PATCH 2/5] feat: validate --gas-stats-json output path is not a directory --- .changeset/wet-apes-kick.md | 1 + v-next/hardhat-errors/src/descriptors.ts | 8 ++++++++ .../gas-analytics/gas-analytics-manager.ts | 9 +++++++++ .../gas-analytics/gas-analytics-manager.ts | 15 ++++++++++++++- 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.changeset/wet-apes-kick.md b/.changeset/wet-apes-kick.md index 4a3616df889..ff7bdd78374 100644 --- a/.changeset/wet-apes-kick.md +++ b/.changeset/wet-apes-kick.md @@ -1,6 +1,7 @@ --- "@nomicfoundation/hardhat-node-test-runner": minor "@nomicfoundation/hardhat-mocha": minor +"@nomicfoundation/hardhat-errors": minor "hardhat": minor --- diff --git a/v-next/hardhat-errors/src/descriptors.ts b/v-next/hardhat-errors/src/descriptors.ts index 67a561efd3d..c5ae270c3ce 100644 --- a/v-next/hardhat-errors/src/descriptors.ts +++ b/v-next/hardhat-errors/src/descriptors.ts @@ -855,6 +855,14 @@ Please double check your arguments.`, Please double check your script's path.`, }, + INVALID_FILE_PATH: { + number: 601, + messageTemplate: `The path "{path}" is a directory. A file path was expected.`, + websiteTitle: "Invalid file path", + websiteDescription: `A file path was expected, but a directory was provided. + +Please specify a valid file path.`, + }, }, NETWORK: { INVALID_URL: { diff --git a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts index 2cd46229832..b6c84bf0189 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts @@ -10,10 +10,13 @@ import type { TableItem } from "@nomicfoundation/hardhat-utils/format"; import crypto from "node:crypto"; import path from "node:path"; +import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { formatTable } from "@nomicfoundation/hardhat-utils/format"; import { ensureDir, + exists, getAllFilesMatching, + isDirectory, readJsonFile, remove, writeJsonFile, @@ -115,6 +118,12 @@ export class GasAnalyticsManagerImplementation implements GasAnalyticsManager { const gasStatsByContract = this._calculateGasStats(); const resolvedPath = path.resolve(outputPath); + if ((await exists(resolvedPath)) && (await isDirectory(resolvedPath))) { + throw new HardhatError( + HardhatError.ERRORS.CORE.BUILTIN_TASKS.INVALID_FILE_PATH, + { path: outputPath }, + ); + } await ensureDir(path.dirname(resolvedPath)); const json = this._generateGasStatsJson(gasStatsByContract); diff --git a/v-next/hardhat/test/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts b/v-next/hardhat/test/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts index 5a218813ee5..f27d635dd6a 100644 --- a/v-next/hardhat/test/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts +++ b/v-next/hardhat/test/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts @@ -7,7 +7,11 @@ import assert from "node:assert/strict"; import path from "node:path"; import { afterEach, before, describe, it } from "node:test"; -import { disableConsole } from "@nomicfoundation/hardhat-test-utils"; +import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { + assertRejectsWithHardhatError, + disableConsole, +} from "@nomicfoundation/hardhat-test-utils"; import { emptyDir, getAllFilesMatching, @@ -1342,6 +1346,15 @@ describe("gas-analytics-manager", () => { await emptyDir(tmpDir); }); + it("should throw if outputPath is a directory", async () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + await assertRejectsWithHardhatError( + manager.writeGasStatsJson(tmpDir, "test-id"), + HardhatError.ERRORS.CORE.BUILTIN_TASKS.INVALID_FILE_PATH, + { path: tmpDir }, + ); + }); + it("should write JSON file at the specified path", async () => { const manager = new GasAnalyticsManagerImplementation(tmpDir); manager.addGasMeasurement({ From 7fb7c426cb846e7ec82120b1a08a92a8965eec1d Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Mon, 16 Mar 2026 16:20:15 +0000 Subject: [PATCH 3/5] Fix gasStatsJson type extension to string | undefined to match its STRING_WITHOUT_DEFAULT argument type --- v-next/hardhat-utils/src/env.ts | 2 +- .../internal/builtin-plugins/gas-analytics/type-extensions.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/v-next/hardhat-utils/src/env.ts b/v-next/hardhat-utils/src/env.ts index f9a3da17d82..ba2fb90ee0f 100644 --- a/v-next/hardhat-utils/src/env.ts +++ b/v-next/hardhat-utils/src/env.ts @@ -7,7 +7,7 @@ import { camelToSnakeCase } from "./string.js"; * with each option adhering to its definition in the globalOptionDefinitions. */ export function setGlobalOptionsAsEnvVariables< - T extends Record, + T extends Record, >(globalOptions: T): void { for (const [name, value] of Object.entries(globalOptions)) { const envName = getEnvVariableNameFromGlobalOption(name); diff --git a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/type-extensions.ts b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/type-extensions.ts index b17a091546c..8f8c37b3311 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/type-extensions.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/type-extensions.ts @@ -2,6 +2,6 @@ import "../../../types/global-options.js"; declare module "../../../types/global-options.js" { export interface GlobalOptions { gasStats: boolean; - gasStatsJson: string; + gasStatsJson: string | undefined; } } From f71bae443fda2b1c4a604ed886c585e8afc84329 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Mon, 16 Mar 2026 16:25:15 +0000 Subject: [PATCH 4/5] refactor: change gasStatsJson argument type to FILE_WITHOUT_DEFAULT --- .../hardhat/src/internal/builtin-plugins/gas-analytics/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/index.ts b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/index.ts index 757a73c353b..942b619d699 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/index.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/gas-analytics/index.ts @@ -48,7 +48,7 @@ const hardhatPlugin: HardhatPlugin = { name: "gasStatsJson", description: "Write gas usage statistics to a JSON file at the specified path", - type: ArgumentType.STRING_WITHOUT_DEFAULT, + type: ArgumentType.FILE_WITHOUT_DEFAULT, defaultValue: undefined, }), ], From 52b1a795eeaa44bde81b0486c346f222af182ad4 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Wed, 18 Mar 2026 14:45:54 +0000 Subject: [PATCH 5/5] fix: bump patch for plugins --- .changeset/wet-apes-kick.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/wet-apes-kick.md b/.changeset/wet-apes-kick.md index ff7bdd78374..ef4f0189291 100644 --- a/.changeset/wet-apes-kick.md +++ b/.changeset/wet-apes-kick.md @@ -1,7 +1,7 @@ --- -"@nomicfoundation/hardhat-node-test-runner": minor -"@nomicfoundation/hardhat-mocha": minor -"@nomicfoundation/hardhat-errors": minor +"@nomicfoundation/hardhat-node-test-runner": patch +"@nomicfoundation/hardhat-mocha": patch +"@nomicfoundation/hardhat-errors": patch "hardhat": minor ---