From 8ada49c224e9bb086af421d1e42b81ebe7e7489a Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 30 Mar 2026 20:16:21 -0300 Subject: [PATCH 01/83] Add spec --- SPLIT_TESTS_COMPILATION_SPEC.md | 815 ++++++++++++++++++++++++++++++++ 1 file changed, 815 insertions(+) create mode 100644 SPLIT_TESTS_COMPILATION_SPEC.md diff --git a/SPLIT_TESTS_COMPILATION_SPEC.md b/SPLIT_TESTS_COMPILATION_SPEC.md new file mode 100644 index 00000000000..e48a5516e3a --- /dev/null +++ b/SPLIT_TESTS_COMPILATION_SPEC.md @@ -0,0 +1,815 @@ +# Spec: `splitTestsCompilation` Config Field + +## Overview + +Add a `splitTestsCompilation` boolean field to the Solidity user config that controls whether Solidity test files are compiled in a separate pass from contract files. + +- Default: `false` +- When `false`: contracts and tests are compiled together by default +- When `true`: current behavior is preserved + +The long-term goal is to keep the split available for future dynamic-linking work, but stop paying for it by default until that functionality exists. + +--- + +# Part 1: Behavioral Changes + +## `hardhat` package + +### Configuration + +A new optional boolean field `splitTestsCompilation` is accepted in all object-typed Solidity user configs: + +- `SingleVersionSolidityUserConfig` +- `MultiVersionSolidityUserConfig` +- `BuildProfilesSolidityUserConfig` + +It defaults to `false`. + +```typescript +export default { + solidity: { + version: "0.8.28", + splitTestsCompilation: true, + }, +}; +``` + +String-typed and string-array Solidity configs do not accept the field and always resolve it to `false`. + +### Build System (`hre.solidity`) + +#### `getScope(fsPath)` — Unchanged + +File classification into `"contracts"` and `"tests"` is unchanged. + +- Files under `paths.tests.solidity` ending in `.sol` are tests +- Files under `paths.sources.solidity` ending in `.t.sol` are tests +- Everything else falls back to contracts + +This remains the source of truth for classifying local Solidity files. + +#### `getRootFilePaths({ scope })` + +| `splitTestsCompilation` | `scope: "contracts"` | `scope: "tests"` | +| --- | --- | --- | +| `false` | Returns all build roots: contract roots, test roots, and `npmFilesToBuild` roots | Throws `HardhatError` | +| `true` | Returns contract roots only | Returns test roots only | + +When `splitTestsCompilation === false`, `getRootFilePaths({ scope: "tests" })` is a logic error and throws a new `HardhatError` (for example `SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED`). + +#### Low-Level `build`, `getCompilationJobs`, `emitArtifacts`, and `cleanupArtifacts` + +When `splitTestsCompilation === false`, the low-level Solidity build-system API also rejects `scope: "tests"` as a logic error, using the same `HardhatError` family as `getRootFilePaths({ scope: "tests" })`. + +Affected methods: + +- `hre.solidity.build(rootFiles, { scope: "tests" })` +- `hre.solidity.getCompilationJobs(rootFiles, { scope: "tests" })` +- `hre.solidity.emitArtifacts(compilationJob, compilerOutput, { scope: "tests" })` +- `hre.solidity.cleanupArtifacts(rootFiles, { scope: "tests" })` + +`runCompilationJob` is not affected because its options type (`RunCompilationJobOptions`) does not include `scope` — by the time a compilation job runs, scope selection has already happened. + +When `splitTestsCompilation === false` and `scope: "contracts"` is used: + +- `scope` still influences user-facing logging and hook behavior +- artifacts are written to the main artifacts directory +- whether a root emits per-source `artifacts.d.ts` is decided per root file using `getScope()` +- low-level callers cannot opt into contracts/tests separation + +When `splitTestsCompilation === true`, low-level behavior is unchanged. + +#### `getArtifactsDirectory(scope)` + +| `splitTestsCompilation` | `scope: "contracts"` | `scope: "tests"` | +| ----------------------- | -------------------- | -------------------------- | +| `false` | `artifactsPath` | `artifactsPath` | +| `true` | `artifactsPath` | `cachePath/test-artifacts` | + +When `splitTestsCompilation === false`, both scopes point to the main artifacts directory because both contract and test artifacts live there. + +Unlike `getRootFilePaths`, `build`, `getCompilationJobs`, `emitArtifacts`, and `cleanupArtifacts`, `getArtifactsDirectory` does **not** throw when called with `scope: "tests"` in unified mode. It is a read-only query with no side effects, so returning the shared artifacts path is safe and minimizes migration friction for plugins that only need to know where artifacts live. + +#### `emitArtifacts()` + +When `splitTestsCompilation === false`: + +- All contract JSON artifacts are emitted into the main artifacts directory +- Build info and build info output files are emitted into `artifacts/build-info` +- Test roots do not emit per-source `artifacts.d.ts`. No typescript types for tests +- Contract roots still emit per-source `artifacts.d.ts` when built under `scope: "contracts"` + +When `splitTestsCompilation === true`, behavior is unchanged. + +#### `cleanupArtifacts()` + +When `splitTestsCompilation === false`: + +- Cleanup operates on the main artifacts directory +- Reachability is computed against the root-file set passed to cleanup, exactly as today +- Duplicate contract-name detection includes both contract and test artifacts +- The top-level `artifacts.d.ts` is written from the mixed artifact set when cleanup runs in `scope: "contracts"` to still type repeated bare names as `never`, as they can still create collisions of bare names with respect to `hre.artifacts` +- `onCleanUpArtifacts` receives mixed contract and test artifact paths when cleanup runs in `scope: "contracts"` + +When `splitTestsCompilation === true`, behavior is unchanged. + +### Artifact Manager APIs (`hre.artifacts` / `context.artifacts`) + +When `splitTestsCompilation === false`, `hre.artifacts` and `context.artifacts` expose both contract and test artifacts because both are stored under `paths.artifacts`. + +This is an accepted behavior change. + +Consequences: + +- `getAllArtifactPaths()` includes test artifacts +- `getAllFullyQualifiedNames()` includes test artifacts +- bare-name artifact lookup can become ambiguous if a test contract and a contract share the same name. Ambiguos names still type to `never` in the generated `artifacts.d.ts` +- plugins using `context.artifacts` must no longer assume that "artifacts path" means "contracts only" + +Test artifacts still do not receive TypeScript support: + +- no per-source `artifacts.d.ts` is emitted for test roots +- no TypeChain-generated types are emitted for test artifacts + +### Compile Cache + +`splitTestsCompilation` changes the output layout, so the compile cache needs explicit output-layout validation. + +When `splitTestsCompilation === false`, compile-cache entries must store per-root output metadata: + +- the artifacts directory used for that root +- whether that root emitted a TypeScript declaration file + +Cache-hit validation must compare the cached output layout against the expected layout for the current build before checking file existence. + +Expected output layout is derived from: + +- `splitTestsCompilation` +- build `scope` +- the root file path, classified with the same logic as `getScope()` + +Additional rules: + +- old cache entries that do not have the new metadata are treated as misses +- toggling `splitTestsCompilation` invalidates cache hits through output-layout mismatch, not through the compilation-job `buildId` +- toggling from `true` to `false` may leave an orphaned `cache/test-artifacts/` directory; this is cleaned up by `hardhat clean` but not by a regular build + +This keeps cache hits fast: + +- expected layout is computed from strings and config, without extra filesystem traversal +- the fast path remains "compare cached metadata, then perform the existing output-file existence checks" + +### Build Task (`hardhat build` / `hardhat compile`) + +The high-level build task becomes mode-dependent. + +When `splitTestsCompilation === false`: + +- `build` uses a single compilation pass +- the pass runs with `scope: "contracts"` +- the existing contracts-scope log text is kept + +Behavior by input mode when `splitTestsCompilation === false`: + +1. Full build: `files.length === 0`, `noTests === false`, `noContracts === false` + + - Build all contract and test roots together + - Run cleanup once on the main artifacts directory + - Regenerate the top-level `artifacts.d.ts` + - Invoke `onCleanUpArtifacts` once with the mixed artifact set + +2. Explicit files: `files.length > 0` + + - Build exactly the provided files in a single pass, regardless of scope + - This is a partial build + - No cleanup runs + - No top-level `artifacts.d.ts` regeneration occurs + - `onCleanUpArtifacts` does not run + - Return values are partitioned into `contractRootPaths` and `testRootPaths` using `getScope()` + +3. `--no-tests` with no explicit `files` + + - Behaves as if all contract roots had been passed through `files` + - This is a partial build + - No cleanup runs + - No top-level `artifacts.d.ts` regeneration occurs + - `onCleanUpArtifacts` does not run + - Any stale artifacts or stale build-info files remain, exactly like any other partial build + - Note: this is a behavior change from `splitTestsCompilation === true`, where `--no-tests` runs a full contracts build with cleanup. The migration guide (Phase 11) should call this out. + +4. `--no-contracts` with no explicit `files` + + - Behaves as if all test roots had been passed through `files` + - This is a partial build + - No cleanup runs + - No top-level `artifacts.d.ts` regeneration occurs + - `onCleanUpArtifacts` does not run + - Any stale artifacts or stale build-info files remain, exactly like any other partial build + +5. Explicit `files` cannot be combined with `--no-tests` or `--no-contracts` + + - If `files.length > 0` and either `--no-tests` or `--no-contracts` is used, the task throws + +When `splitTestsCompilation === true`: + +- current behavior is preserved +- The task builds contracts and tests in two separate passes +- `--no-tests` and `--no-contracts` skip a scope exactly as they do today +- Otherwise, explicit `files` are routed to the matching scope with `getScope()` +- Scope-specific cleanup remains unchanged + +Both modes return: + +```typescript +{ + contractRootPaths: string[]; + testRootPaths: string[]; +} +``` + +The arrays always reflect the roots actually built by the task. + +### Other Built-In Tasks That Call `build` + +`run`, top-level `test`, and `console` currently compile only contracts by passing `noTests: true` to `build`. + +When `splitTestsCompilation === false`: + +- they must call `build()` without `noTests` +- they therefore compile Solidity tests too as part of the unified build + +When `splitTestsCompilation === true`: + +- current behavior is preserved +- they continue to pass `noTests: true` + +### Solidity Test Runner (`hardhat test solidity`) + +When `splitTestsCompilation === false`: + +- `noCompile === true` skips compilation entirely +- `noCompile !== true` performs one full unified build +- `testFiles` only controls which tests are executed +- partial Solidity test runs may still compile all Solidity tests as a temporary limitation +- the runner must compute the selected test roots independently from the build return value +- when `noCompile === true`, selected test roots must still be validated against the compiled artifacts available on disk +- if a selected Solidity test file exists but has not been compiled, the task throws a `HardhatError` +- only the selected test roots are used for: + - deciding which suites to execute + - deprecated-test warnings +- artifacts and build info are read from a single directory: `getArtifactsDirectory("contracts")` + +Important distinction in unified mode: + +- compiled test roots: all test roots produced by the unified build +- executed test roots: the tests requested by the user, or all test roots when no specific `testFiles` are provided + +When `splitTestsCompilation === true`, current behavior is preserved: + +- the first build (contracts) is guarded by `noCompile` +- the second build (tests) is unconditional — Solidity tests are always compiled regardless of `--no-compile` +- this is intentional: in split mode, `--no-compile` means "do not compile contracts", not "skip all compilation" + +### Warning Suppression — Unchanged + +Warning suppression continues to identify test files by path and `.t.sol` suffix, independently of whether compilation is split. + +### Coverage Plugin — Unchanged + +The coverage plugin already uses `context.solidity.getScope(fsPath)` to skip test files. Since classification is unchanged, test files continue to be excluded from instrumentation in both modes. + +### Gas Analytics — Unchanged + +Gas analytics behavior is not affected by `splitTestsCompilation`. + +- It does not depend on Solidity compile-time scope partitioning +- It operates on executed tests and their gas reports +- In unified mode, if a selected Solidity test run compiles more tests than it executes, gas analytics still reflects only the suites that actually ran +- Snapshot and snapshot-check behavior is unchanged + +## `hardhat-typechain` package + +### Type Generation + +When `splitTestsCompilation === false`, unified builds run with `scope: "contracts"`, and `context.artifacts` sees both contract and test artifacts. TypeChain must therefore filter test artifacts before generating types. + +Updated behavior: + +- keep the existing `options?.scope === "tests"` early return + - this preserves current split behavior +- after a successful build, collect all artifact paths from `context.artifacts` +- classify each artifact by its source file using `context.solidity.getScope()`: + - derive the source file path from the artifact path by computing its path relative to `context.config.paths.artifacts` and resolving that against `context.config.paths.root` + - this derivation works because of a known invariant in the artifact layout: each local Solidity source file produces a directory in the artifacts folder whose relative path from the artifacts root mirrors the source file's relative path from the project root + - `getScope()` defaults to `"contracts"` for files that don't exist on disk, so non-local artifacts (e.g. npm dependencies) are never filtered out +- pass only contract-scope artifact paths to `generateTypes()` + +Test artifacts never receive TypeChain output. + +## `hardhat-mocha` package + +When `splitTestsCompilation === false`: + +- `noCompile === true` skips compilation entirely +- `noCompile !== true` calls `build()` without `noTests` +- JS/TS test runs therefore compile Solidity tests too as part of the unified build + +When `splitTestsCompilation === true`, current behavior is preserved: + +- `noCompile === true` skips compilation entirely +- `noCompile !== true` keeps calling `build({ noTests: true })` + +## `hardhat-node-test-runner` package + +When `splitTestsCompilation === false`: + +- `noCompile === true` skips compilation entirely +- `noCompile !== true` calls `build()` without `noTests` +- JS/TS test runs therefore compile Solidity tests too as part of the unified build + +When `splitTestsCompilation === true`, current behavior is preserved: + +- `noCompile === true` skips compilation entirely +- `noCompile !== true` keeps calling `build({ noTests: true })` + +## `hardhat-ignition` package + +When `splitTestsCompilation === false`: + +- `deploy` calls `build()` without `noTests` +- `deploy` still passes `quiet: true` and `defaultBuildProfile: "production"` +- `visualize` calls `build()` without `noTests` +- `visualize` still passes `quiet: true` +- these tasks therefore compile Solidity tests too as part of the unified build +- artifact resolution through `hre.artifacts` sees test artifacts too, so bare-name resolution and build-info lookup can become ambiguous in the same way as other artifact consumers + +Note that by using `defaultBuildProfile: "production"` we still get isolated builds, so the extra tests being compiled won't be present in the deployed contract's build-info files. + +When `splitTestsCompilation === true`, current behavior is preserved: + +- `deploy` keeps calling `build({ noTests: true, quiet: true, defaultBuildProfile: "production" })` +- `visualize` keeps calling `build({ noTests: true, quiet: true })` + +## Solidity Hooks Impact Summary + +| Hook | Impact | Details | +| --- | --- | --- | +| `build` | Receives different scope/root files in unified mode | Full unified builds call the hook once with `scope: "contracts"` and mixed contract/test roots. Synthetic partial builds (`--no-tests`, `--no-contracts`) and explicit-file builds call it once with only the selected roots. Plugins that need contract-only behavior must filter per file with `getScope()`. | +| `preprocessProjectFileBeforeBuilding` | Mixed sources possible | In unified mode, the same compilation may include both contract and test files. Plugins can distinguish with `context.solidity.getScope(fsPath)`. | +| `preprocessSolcInputBeforeBuilding` | Mixed sources possible | In unified mode, `solcInput.sources` may contain both contract and test sources together. | +| `onCleanUpArtifacts` | Mixed artifact set, but only on full unified cleanup | In unified mode, this hook only runs for the full-build path. It receives mixed contract/test artifact paths. Partial builds do not trigger it. | +| `downloadCompilers` | No change | Compiler download is still driven by resolved compiler configs. | +| `getCompiler` | No change | Compiler selection is unchanged. | +| `invokeSolc` | No change | Compiler invocation remains scope-unaware. | +| `readSourceFile` | No change | File reading is unchanged. | +| `readNpmPackageRemappings` | No change | NPM remapping resolution is unchanged. | + +## Unaffected areas + +- `builtin:clean`: It deletes the `cache` and `artifacts` directories entirely, so it does not depend on how Solidity outputs were partitioned before cleanup. +- `builtin:telemetry`: It only exposes telemetry configuration tasks and does not interact with Solidity root discovery, build scopes, artifacts, or cleanup. +- `@nomicfoundation/hardhat-keystore`: It only adds config/configuration-variable hooks and keystore tasks. It does not compile Solidity or read artifacts. +- `@nomicfoundation/hardhat-ledger`: It only affects network/account configuration and request handling. It does not interact with Solidity build scopes or artifact layout. +- `@nomicfoundation/hardhat-network-helpers`: It only augments network connections with helper methods. It does not trigger builds, classify Solidity files, or consume artifacts/build info. +- `@nomicfoundation/hardhat-ethers-chai-matchers`: It only registers Chai matchers at network-connection time. It does not inspect build scopes, root-file discovery, or artifact trees itself. +- `@nomicfoundation/hardhat-viem-assertions`: It only registers viem assertion helpers at network-connection time. It does not inspect build scopes, root-file discovery, or artifact trees itself. + +Toolbox packages are not listed separately because they are meta-plugins: they inherit whatever behavior changes apply to the plugins they bundle. + +## Indirectly affected integrations + +These packages do not define Solidity build scopes themselves, but they call APIs whose behavior changes in unified mode. They therefore inherit user-visible behavior changes even though they are not the source of the contracts-vs-tests split. + +- `builtin:flatten`: `hardhat flatten` without explicit `files` calls `solidity.getRootFilePaths()` with the default scope. When `splitTestsCompilation === false`, that now returns both contract and test roots, so the default flatten target set expands to include Solidity tests. Explicit `files` behavior is unchanged. +- `builtin:network-manager`: the EDR contract decoder is initialized from all build infos visible through `context.artifacts`. In unified mode, once contract and test build infos live under the same artifacts tree, the decoder sees both. This does not change scope logic, but it means decoding/metadata availability follows the mixed artifact set on disk. This is inevitable if we want to unify the compilation. In a future iteration, Hardhat could tell EDR which files to ignore from each build info based on `getScope()`, but for now the decoder just sees everything. +- `builtin:node`: it inherits the `builtin:network-manager` behavior above because starting the node creates an EDR provider through `hre.network.connect()`. In unified mode, the node's decoder therefore sees the mixed build-info set. Its separate build-info watcher behavior is otherwise unchanged. +- `@nomicfoundation/hardhat-ethers`: its helpers resolve artifacts through `context.artifacts` / `hre.artifacts`. In unified mode, test artifacts are visible there too, so bare-name helpers like `getContractFactory`, `getContractAt`, and `deployContract` can now become ambiguous if a test contract and a contract share the same name. Fully qualified names continue to work. (\*) +- `@nomicfoundation/hardhat-viem`: same artifact-resolution effect as `@nomicfoundation/hardhat-ethers`. Bare-name helpers like `deployContract`, `sendDeploymentTransaction`, and `getContractAt` can now see test artifacts in unified mode, so ambiguity behavior may change. (\*) +- `@nomicfoundation/hardhat-verify`: explicit `--contract ` verification remains predictable, but inference mode scans `hre.artifacts.getAllFullyQualifiedNames()`. In unified mode that candidate set includes test artifacts too, so automatic contract inference and multiple-match errors can now involve test contracts. The mitigation remains to pass an explicit fully qualified name when inference becomes ambiguous. +- `@nomicfoundation/hardhat-ignition-ethers`: it adds no new scope logic of its own, but it inherits the dedicated `hardhat-ignition` behavior above and the `hardhat-ethers` behavior above. +- `@nomicfoundation/hardhat-ignition-viem`: it adds no new scope logic of its own, but it inherits the dedicated `hardhat-ignition` behavior above and the `hardhat-viem` behavior above. + +(\*) Note that helper-level bare-name ambiguity errors provide enough information for users to fix the issue by switching to fully qualified names, so this is an accepted behavior change. If a user has a test contract that shares a name with a contract, they will need to disambiguate with fully qualified names when `splitTestsCompilation === false`. When `splitTestsCompilation === true`, this ambiguity cannot arise because test artifacts are stored separately and not visible to helpers that only look at the main artifacts directory. + +Toolbox packages (`@nomicfoundation/hardhat-toolbox-mocha-ethers` and `@nomicfoundation/hardhat-toolbox-viem`) are still not listed separately as independent integration surfaces. They just re-export bundles of plugins, so they inherit the combined behavior changes of the plugins above. + +--- + +# Part 2: Phased Implementation Plan + +The implementation should be split into smaller phases so that each phase introduces one coherent behavior change and has its own validation surface. + +## Phase 1: Config And Plumbing + +Add the new config field, validate it, resolve it, and pass it through to the build-system constructor. No behavior changes yet. + +### Changes + +1. `packages/hardhat/src/internal/builtin-plugins/solidity/type-extensions.ts` + + - Add `splitTestsCompilation?: boolean` to `CommonSolidityUserConfig` + - Add `splitTestsCompilation: boolean` to `SolidityConfig` + - Add inline JSDoc explaining the field + +2. `packages/hardhat/src/internal/builtin-plugins/solidity/config.ts` + + - Add `splitTestsCompilation: z.boolean().optional()` to all object-typed Solidity user-config schemas + - Resolve object configs with `solidityConfig.splitTestsCompilation ?? false` + - Resolve string and string-array configs with `false` + +The build system accesses `splitTestsCompilation` through `this.#options.solidityConfig.splitTestsCompilation` — no separate field in `SolidityBuildSystemOptions` is needed since `solidityConfig` already carries the resolved value. + +### Validation + +- Run `pnpm lint` in `packages/hardhat` +- Run `pnpm build` in `packages/hardhat` +- Run existing config tests: `packages/hardhat/test/internal/builtin-plugins/solidity/config.ts` +- Add config tests for: + - `splitTestsCompilation: true` + - `splitTestsCompilation: false` + - omitted field defaults to `false` + - invalid non-boolean values fail validation +- Run `pnpm test` in `packages/hardhat` + +## Phase 2: Build-System Core Semantics + +Implement the new root-discovery, artifact-layout, cleanup, and low-level scope behavior in the Solidity build system. + +### Changes + +1. `packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts` + + - Update `getRootFilePaths()`: + - in unified mode, `scope: "contracts"` returns all roots, including contract roots, test roots, and `npmFilesToBuild` roots + - in unified mode, `scope: "tests"` throws + - Update `getArtifactsDirectory()`: + - in unified mode, both scopes return `artifactsPath` + - Update `emitArtifacts()`: + - emit all artifacts to the shared directory in unified mode + - emit per-source `artifacts.d.ts` only for contract roots in unified `scope: "contracts"` builds + - Update `cleanupArtifacts()`: + - operate on the shared directory in unified mode + - include test artifacts in duplicate-name detection + - pass mixed artifact paths to `onCleanUpArtifacts` for unified contracts-scope cleanup + - Reject unified low-level `scope: "tests"` calls in: + - `build()` + - `getCompilationJobs()` + - `emitArtifacts()` + - `cleanupArtifacts()` + - Delete the scope-level type-file assertion in `#cacheCompilationResult()` (`scope === "tests" || typeFilePath !== undefined`). In unified mode, test roots under `scope: "contracts"` have no type file, so this assertion no longer holds. The cache entry already accepts `typeFilePath?: string` and the cache-hit path already skips `undefined` entries, so removing it is safe. Phase 3 replaces this with proper per-root output-layout validation. + +2. `packages/hardhat/src/types/solidity/build-system.ts` + + - Update JSDoc on: + - `getRootFilePaths()` + - `emitArtifacts()` + - `cleanupArtifacts()` + - `getArtifactsDirectory()` + - `BuildOptions.scope` + - Document the unified-mode behavior and the new `getRootFilePaths({ scope: "tests" })` error + +3. `packages/hardhat/src/internal/builtin-plugins/solidity/type-extensions.ts` + + - Update hook JSDoc for: + - `build` + - `onCleanUpArtifacts` + - `preprocessProjectFileBeforeBuilding` + - `preprocessSolcInputBeforeBuilding` + +4. `packages/hardhat/src/internal/builtin-plugins/solidity/hook-handlers/hre.ts` + - No changes needed — the existing `solidityConfig` field in `SolidityBuildSystemOptions` already carries the resolved `splitTestsCompilation` value + +5. New `HardhatError` + - Add an error code for calling `getRootFilePaths({ scope: "tests" })` when unified compilation is enabled + - Use the first free code number in the `CORE.SOLIDITY` category in `packages/hardhat-errors/src/descriptors.ts` + +6. `LazySolidityBuildSystem` + - `LazySolidityBuildSystem` (in `packages/hardhat/src/internal/builtin-plugins/solidity/hook-handlers/hre.ts`) is a pure pass-through wrapper that delegates all calls to the underlying `SolidityBuildSystemImplementation`. It requires no changes itself — all new behavior is handled by the implementation it wraps. + +### Validation + +- Run `pnpm lint` in `packages/hardhat` +- Run `pnpm build` in `packages/hardhat` +- Run existing build-system and scope tests: + - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/build-system.ts` + - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts` +- Add tests for: + - unified `getRootFilePaths({ scope: "contracts" })` returns contract, test, and `npmFilesToBuild` roots together + - unified `getRootFilePaths({ scope: "tests" })` throws + - unified `getArtifactsDirectory("tests")` returns the main artifacts dir + - unified `emitArtifacts()` skips type declarations for test roots + - unified low-level `scope: "tests"` calls throw the new error for: + - `build` + - `getCompilationJobs` + - `emitArtifacts` + - `cleanupArtifacts` + - unified contracts-scope cleanup includes test artifacts in duplicate-name handling +- Run `pnpm test` in `packages/hardhat` + +## Phase 3: Compile Cache + +Update the compile cache so cache hits remain correct and fast when the output layout changes. + +### Changes + +1. `packages/hardhat/src/internal/builtin-plugins/solidity/build-system/cache.ts` + + - Extend `CompileCacheEntry` with: + - `artifactsDirectory` + - `emitsTypeDeclarations` + +2. `packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts` + - Add an internal helper that computes the expected output layout for a root from: + - `splitTestsCompilation` + - build `scope` + - root path classification + - Use that helper during cache-hit validation before file existence checks + - Treat cache entries missing the new fields as misses + - Update `#cacheCompilationResult()` to store the per-root output layout + +### Validation + +- Run `pnpm lint` in `packages/hardhat` +- Run `pnpm build` in `packages/hardhat` +- Run existing partial-compilation tests: + - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/get-compilation-jobs-cache-hits.ts` + - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/cache-hit-results.ts` + - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/npm-cache-hits.ts` +- Add tests for: + - unified-mode second builds cache-hit both contract and test roots + - unified-mode test roots cache-hit correctly without type declarations + - toggling `splitTestsCompilation` invalidates through output-layout mismatch + - pre-existing cache entries without the new fields are treated as misses +- Run `pnpm test` in `packages/hardhat` + +## Phase 4: Build Task Semantics + +Rewrite the high-level build task to implement the new unified-mode semantics. + +### Changes + +1. `packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts` + - Branch on `splitTestsCompilation` + - Unified mode: + - full build when no `files` and no scope-skipping flags + - exact partial build when explicit `files` are provided + - synthetic partial build of all contracts for `--no-tests`: call `getRootFilePaths({ scope: "contracts" })` to get all roots, filter to contract roots using `getScope()`, and pass them as `rootFilePaths` to `build()` + - synthetic partial build of all tests for `--no-contracts`: call `getRootFilePaths({ scope: "contracts" })` to get all roots, filter to test roots using `getScope()`, and pass them as `rootFilePaths` to `build()` + - all low-level `solidity.build()` and `solidity.cleanupArtifacts()` calls use `scope: "contracts"`, even when the selected roots are all tests + - the task must never call low-level Solidity build-system APIs with `scope: "tests"` in unified mode + - reject `files` combined with `--no-tests` or `--no-contracts` + - cleanup runs only for the full unified build + - Split mode: + - preserve the current two-pass behavior + - preserve the current explicit-file routing behavior + - preserve the current unused-file validation after scope routing + - Return `{ contractRootPaths, testRootPaths }` from the roots actually built, partitioning them with `getScope()` + +### Validation + +- Run `pnpm lint` in `packages/hardhat` +- Run `pnpm build` in `packages/hardhat` +- Run existing scope and cleanup tests: + - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts` + - `packages/hardhat/test/internal/builtin-plugins/solidity/tasks/build-cleanup-artifacts.ts` +- Add tests for: + - unified full build compiles contracts and tests together + - unified explicit-file builds compile exactly the provided files + - unified explicit-file builds still use low-level `scope: "contracts"` + - unified explicit `files` + `--no-tests` throws + - unified explicit `files` + `--no-contracts` throws + - unified `--no-tests` behaves like a partial build over all contracts + - unified `--no-contracts` behaves like a partial build over all tests + - unified `--no-contracts` still uses low-level `scope: "contracts"` + - unified `--no-tests` / `--no-contracts` do not run cleanup side effects + - unified full-build cleanup still uses low-level `scope: "contracts"` + - unified mode partitions returned `contractRootPaths` and `testRootPaths` with `getScope()` from the actual roots built + - split mode preserves explicit contract-file builds with `--no-tests` + - split mode preserves explicit test-file builds with `--no-contracts` + - split mode preserves the current unused-file error when explicit files fall only in a disabled scope + - other split-mode regressions for current behavior +- Run `pnpm test` in `packages/hardhat` + +## Phase 5: Other Built-In Task Callers + +Update the built-in tasks that currently call `build({ noTests: true })`. + +### Changes + +1. `packages/hardhat/src/internal/builtin-plugins/run/task-action.ts` + + - In unified mode, call plain `build()` + - In split mode, keep `noTests: true` + +2. `packages/hardhat/src/internal/builtin-plugins/test/task-action.ts` + + - In unified mode, call plain `build()` + - In split mode, keep `noTests: true` + +3. `packages/hardhat/src/internal/builtin-plugins/console/task-action.ts` + - In unified mode, call plain `build()` + - In split mode, keep `noTests: true` + +### Validation + +- Run `pnpm lint` in `packages/hardhat` +- Run `pnpm build` in `packages/hardhat` +- Run existing task tests: + - `packages/hardhat/test/internal/builtin-plugins/run/task-action.ts` + - `packages/hardhat/test/internal/builtin-plugins/test/task-action.ts` + - `packages/hardhat/test/internal/builtin-plugins/console/task-action.ts` +- Add tests that verify build invocation arguments in both modes +- Run `pnpm test` in `packages/hardhat` + +## Phase 6: Solidity Test Runner + +Update the Solidity test runner for unified builds while preserving selected test execution. + +### Changes + +1. `packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts` + - Branch on `hre.config.solidity.splitTestsCompilation` + - Unified mode: + - if `noCompile !== true`, call `build()` once without `noTests` or `noContracts` + - compute selected test roots independently from the build return value + - when `noCompile === true`, validate that every selected Solidity test root has compiled artifacts available + - throw a `HardhatError` if a selected Solidity test file exists but was not compiled + - use selected test roots for suite execution and deprecated-test warnings + - read artifacts and build info from the main artifacts directory only + - accept the temporary limitation that selected runs may still compile all Solidity tests + - Split mode: + - preserve the current two-build behavior + +### Validation + +- Run `pnpm lint` in `packages/hardhat` +- Run `pnpm build` in `packages/hardhat` +- Run existing Solidity test runner tests: `packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts` +- Add tests for: + - unified mode performs one build + - unified mode reads artifacts from a single directory + - unified mode executes only the selected test files + - a non-selected failing test may be compiled but is not executed + - deprecated-test warnings are emitted only for selected tests + - unified `noCompile === true` throws a `HardhatError` when a selected Solidity test file exists but has not been compiled + - `noCompile === true` works in both modes + - split-mode behavior remains unchanged +- Run `pnpm test` in `packages/hardhat` + +## Phase 7: Artifact API Consumers And TypeChain + +Lock in the accepted artifact-manager behavior change and update TypeChain. + +### Changes + +1. Hardhat package + + - No artifact-manager code changes are required beyond the shared-directory behavior introduced earlier + - Add regressions that make the new behavior explicit + +2. `packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts` + - Keep the existing `options?.scope === "tests"` early return + - In unified mode, collect all artifact paths from `context.artifacts.getAllArtifactPaths()` + - For each artifact path, classify its source file with `context.solidity.getScope()` + - Pass only contract-scope artifact paths to `generateTypes()` + - Do not infer "test artifact" from the artifact path layout alone + +### Validation + +- Run `pnpm lint` and `pnpm build` in `packages/hardhat` +- Run `pnpm lint` and `pnpm build` in `packages/hardhat-typechain` +- Run existing tests: + - `packages/hardhat/test/internal/builtin-plugins/artifacts/artifact-manager.ts` + - `packages/hardhat-typechain/test/index.ts` +- Add tests for: + - unified `hre.artifacts.getAllArtifactPaths()` includes test artifacts + - unified `hre.artifacts.getAllFullyQualifiedNames()` includes test artifacts + - bare-name artifact lookup becomes ambiguous when a test contract and a contract share a name + - fully qualified name lookup still works when a test contract and a contract share a name + - test roots still do not get per-source `artifacts.d.ts` + - TypeChain does not generate types for test artifacts in unified mode + - TypeChain classifies artifacts with `context.solidity.getScope()` rather than artifact-path heuristics + - TypeChain correctly classifies npm-dependency artifacts as contracts (since their source files don't exist on disk, `getScope()` defaults to `"contracts"`) + - TypeChain still skips explicit test-scope builds in split mode +- Run `pnpm test` in `packages/hardhat` +- Run `pnpm test` in `packages/hardhat-typechain` + +## Phase 8: `@nomicfoundation/hardhat-mocha` + +Update the Mocha test runner plugin so its pre-test compilation matches the new unified-build semantics. + +### Changes + +1. `packages/hardhat-mocha/src/task-action.ts` + - Branch on `hre.config.solidity.splitTestsCompilation` + - When `noCompile === true`, preserve the current "skip compilation entirely" behavior + - Unified mode: + - call plain `build()` without `noTests` + - Split mode: + - preserve the current `build({ noTests: true })` behavior + +### Validation + +- Run `pnpm lint` in `packages/hardhat-mocha` +- Run `pnpm build` in `packages/hardhat-mocha` +- Run existing tests: + - `packages/hardhat-mocha/test/index.ts` + - `packages/hardhat-mocha/test/registerFileForTestRunner.ts` +- Add tests for: + - unified mode invokes `build()` without `noTests` + - split mode preserves `build({ noTests: true })` + - `noCompile === true` skips compilation in both modes +- Run `pnpm test` in `packages/hardhat-mocha` + +## Phase 9: `@nomicfoundation/hardhat-node-test-runner` + +Update the node:test runner plugin so its pre-test compilation matches the new unified-build semantics. + +### Changes + +1. `packages/hardhat-node-test-runner/src/task-action.ts` + - Branch on `hre.config.solidity.splitTestsCompilation` + - When `noCompile === true`, preserve the current "skip compilation entirely" behavior + - Unified mode: + - call plain `build()` without `noTests` + - Split mode: + - preserve the current `build({ noTests: true })` behavior + +### Validation + +- Run `pnpm lint` in `packages/hardhat-node-test-runner` +- Run `pnpm build` in `packages/hardhat-node-test-runner` +- Run existing tests: + - `packages/hardhat-node-test-runner/test/index.ts` + - `packages/hardhat-node-test-runner/test/registerFileForTestRunner.ts` +- Add tests for: + - unified mode invokes `build()` without `noTests` + - split mode preserves `build({ noTests: true })` + - `noCompile === true` skips compilation in both modes +- Run `pnpm test` in `packages/hardhat-node-test-runner` + +## Phase 10: `@nomicfoundation/hardhat-ignition` + +Update Ignition's task-level prebuild behavior so it matches the new unified-build semantics. + +### Changes + +1. `packages/hardhat-ignition/src/internal/tasks/deploy.ts` + + - Branch on `hre.config.solidity.splitTestsCompilation` + - Unified mode: + - call `build()` without `noTests` + - preserve `quiet: true` + - preserve `defaultBuildProfile: "production"` + - Split mode: + - preserve `build({ noTests: true, quiet: true, defaultBuildProfile: "production" })` + +2. `packages/hardhat-ignition/src/internal/tasks/visualize.ts` + - Branch on `hre.config.solidity.splitTestsCompilation` + - Unified mode: + - call `build()` without `noTests` + - preserve `quiet: true` + - Split mode: + - preserve `build({ noTests: true, quiet: true })` + +### Validation + +- Run `pnpm lint` in `packages/hardhat-ignition` +- Run `pnpm build` in `packages/hardhat-ignition` +- Run existing tests: + - `packages/hardhat-ignition/test/deploy/build-profile.ts` + - `packages/hardhat-ignition/test/plan/index.ts` +- Add tests for: + - unified `deploy` invokes `build()` without `noTests` + - unified `deploy` still passes `defaultBuildProfile: "production"` + - unified `visualize` invokes `build()` without `noTests` + - split `deploy` preserves `build({ noTests: true, quiet: true, defaultBuildProfile: "production" })` + - split `visualize` preserves `build({ noTests: true, quiet: true })` +- Run `pnpm test` in `packages/hardhat-ignition` + +## Phase 11: Docs And Migration + +Document the shipped behavior for plugin authors and future maintainers. + +### Changes + +1. `PLUGIN_MIGRATION_GUIDE.md` + - Explain `splitTestsCompilation` and the new default + - Document the unified `hre.artifacts` behavior change + - Document the no-test-types rule for test artifacts + - Document that low-level `scope: "tests"` Solidity build-system APIs throw when unified compilation is enabled + - Document unified build-hook behavior + - Document unified cleanup-hook behavior + - Document the synthetic partial-build behavior of `--no-tests` and `--no-contracts` + - Document that `run`, top-level `test`, `console`, `@nomicfoundation/hardhat-mocha`, and `@nomicfoundation/hardhat-node-test-runner` compile Solidity tests in unified mode + - Document that `ignition deploy` and `ignition visualize` compile Solidity tests in unified mode + - Document the accepted bare-name ambiguity changes for artifact consumers, including Ignition + - Document the new compile-cache output-layout behavior + - Provide before/after plugin examples where helpful + +### Validation + +- Review both documents against the implemented behavior +- Ensure all code examples compile and match the real API signatures +- Cross-check the docs against the final tests added in the earlier phases From a99a5b9778d4b1777f6ef72704f6098c96cba793 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 06:27:56 -0300 Subject: [PATCH 02/83] Fix typos and cspell config --- SPLIT_TESTS_COMPILATION_SPEC.md | 4 +++- cspell.config.mts | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/SPLIT_TESTS_COMPILATION_SPEC.md b/SPLIT_TESTS_COMPILATION_SPEC.md index e48a5516e3a..655eb61f936 100644 --- a/SPLIT_TESTS_COMPILATION_SPEC.md +++ b/SPLIT_TESTS_COMPILATION_SPEC.md @@ -124,7 +124,7 @@ Consequences: - `getAllArtifactPaths()` includes test artifacts - `getAllFullyQualifiedNames()` includes test artifacts -- bare-name artifact lookup can become ambiguous if a test contract and a contract share the same name. Ambiguos names still type to `never` in the generated `artifacts.d.ts` +- bare-name artifact lookup can become ambiguous if a test contract and a contract share the same name. Ambiguous names still type to `never` in the generated `artifacts.d.ts` - plugins using `context.artifacts` must no longer assume that "artifacts path" means "contracts only" Test artifacts still do not receive TypeScript support: @@ -478,9 +478,11 @@ Implement the new root-discovery, artifact-layout, cleanup, and low-level scope - `preprocessSolcInputBeforeBuilding` 4. `packages/hardhat/src/internal/builtin-plugins/solidity/hook-handlers/hre.ts` + - No changes needed — the existing `solidityConfig` field in `SolidityBuildSystemOptions` already carries the resolved `splitTestsCompilation` value 5. New `HardhatError` + - Add an error code for calling `getRootFilePaths({ scope: "tests" })` when unified compilation is enabled - Use the first free code number in the `CORE.SOLIDITY` category in `packages/hardhat-errors/src/descriptors.ts` diff --git a/cspell.config.mts b/cspell.config.mts index 186d6354426..5e0b430b3ce 100644 --- a/cspell.config.mts +++ b/cspell.config.mts @@ -31,5 +31,9 @@ export default defineConfig({ "**/artifacts/**/*.json", "**/artifacts/**/*.d.ts", "**/build-info/**/*", + "packages/*/artifacts", + "packages/*/cache", + "packages/*/test/fixture-projects/**/artifacts", + "packages/*/test/fixture-projects/**/cache", ], }); From d72d7c1f16630da4aaff45bdfb4c297cf7727efb Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Tue, 31 Mar 2026 17:37:25 -0300 Subject: [PATCH 03/83] Add config field to the builtin:solidity type extensions --- .../builtin-plugins/solidity/type-extensions.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/type-extensions.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/type-extensions.ts index b0c4c915f06..9ff8b053897 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/type-extensions.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/type-extensions.ts @@ -51,6 +51,16 @@ declare module "../../../types/config.js" { export interface CommonSolidityUserConfig { isolated?: boolean; npmFilesToBuild?: string[]; + /** + * Controls whether Solidity test files are compiled in a separate pass + * from contract files. + * + * When `false` (the default), contracts and tests are compiled together + * in a single pass. + * + * When `true`, contracts and tests are compiled separately. + */ + splitTestsCompilation?: boolean; } /** @@ -289,6 +299,7 @@ declare module "../../../types/config.js" { profiles: Record; npmFilesToBuild: string[]; registeredCompilerTypes: SolidityCompilerType[]; + splitTestsCompilation: boolean; } /** From dbaec848dafcfbfd624cec7e729890e3260e3771 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Tue, 31 Mar 2026 17:41:06 -0300 Subject: [PATCH 04/83] Add config validation and resolution --- .../builtin-plugins/solidity/config.ts | 5 + .../builtin-plugins/solidity/config.ts | 169 ++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/config.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/config.ts index 48c89c8b2bc..185184e2cc5 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/config.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/config.ts @@ -81,6 +81,7 @@ const incompatibleCompilerFields = { const commonSolidityUserConfigFields = { isolated: z.boolean().optional(), npmFilesToBuild: z.array(z.string()).optional(), + splitTestsCompilation: z.boolean().optional(), }; const commonSolidityCompilerUserConfigFields = { @@ -209,6 +210,7 @@ const buildProfilesSolidityUserConfigType = z.object({ "Expected an object configuring one or more versions of Solidity", ), ), + ...commonSolidityUserConfigFields, ...incompatibleProfileFields, }); @@ -437,6 +439,7 @@ function resolveSolidityConfig( }, npmFilesToBuild: [], registeredCompilerTypes: ["solc"], + splitTestsCompilation: false, }; } @@ -457,6 +460,7 @@ function resolveSolidityConfig( }, npmFilesToBuild: solidityConfig.npmFilesToBuild ?? [], registeredCompilerTypes: ["solc"], + splitTestsCompilation: solidityConfig.splitTestsCompilation ?? false, }; } @@ -488,6 +492,7 @@ function resolveSolidityConfig( profiles, npmFilesToBuild: solidityConfig.npmFilesToBuild ?? [], registeredCompilerTypes: ["solc"], + splitTestsCompilation: solidityConfig.splitTestsCompilation ?? false, }; } diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/config.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/config.ts index 76cf9693118..fbcefbd106b 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/config.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/config.ts @@ -841,6 +841,103 @@ describe("solidity plugin config validation", () => { ); }); }); + + describe("splitTestsCompilation validation", () => { + it("Should accept splitTestsCompilation: true in a single-version config", () => { + assert.deepEqual( + validateSolidityUserConfig({ + solidity: { + version: "0.8.28", + splitTestsCompilation: true, + }, + }), + [], + ); + }); + + it("Should accept splitTestsCompilation: false in a single-version config", () => { + assert.deepEqual( + validateSolidityUserConfig({ + solidity: { + version: "0.8.28", + splitTestsCompilation: false, + }, + }), + [], + ); + }); + + it("Should accept omitted splitTestsCompilation", () => { + assert.deepEqual( + validateSolidityUserConfig({ + solidity: { + version: "0.8.28", + }, + }), + [], + ); + }); + + it("Should reject invalid non-boolean splitTestsCompilation values", () => { + assert.deepEqual( + validateSolidityUserConfig({ + solidity: { + version: "0.8.28", + splitTestsCompilation: "yes", + } as any, + }), + [ + { + path: ["solidity", "splitTestsCompilation"], + message: "Expected boolean, received string", + }, + ], + ); + + assert.deepEqual( + validateSolidityUserConfig({ + solidity: { + version: "0.8.28", + splitTestsCompilation: 1, + } as any, + }), + [ + { + path: ["solidity", "splitTestsCompilation"], + message: "Expected boolean, received number", + }, + ], + ); + }); + + it("Should accept splitTestsCompilation in a multi-version config", () => { + assert.deepEqual( + validateSolidityUserConfig({ + solidity: { + compilers: [{ version: "0.8.28" }], + splitTestsCompilation: true, + }, + }), + [], + ); + }); + + it("Should accept splitTestsCompilation in a build-profiles config", () => { + assert.deepEqual( + validateSolidityUserConfig({ + solidity: { + profiles: { + default: { + version: "0.8.28", + }, + }, + splitTestsCompilation: true, + }, + }), + [], + ); + }); + }); }); describe("solidity plugin config resolution", () => { @@ -854,6 +951,78 @@ describe("solidity plugin config resolution", () => { it.todo("should resolve a BuildProfilesSolidityUserConfig value", () => {}); + describe("splitTestsCompilation resolution", () => { + const otherResolvedConfig = { paths: { root: process.cwd() } } as any; + + it("should default splitTestsCompilation to false for a string config", async () => { + const resolvedConfig = await resolveSolidityUserConfig( + { solidity: "0.8.28" }, + otherResolvedConfig, + ); + assert.equal(resolvedConfig.solidity.splitTestsCompilation, false); + }); + + it("should default splitTestsCompilation to false for a string-array config", async () => { + const resolvedConfig = await resolveSolidityUserConfig( + { solidity: ["0.8.28"] }, + otherResolvedConfig, + ); + assert.equal(resolvedConfig.solidity.splitTestsCompilation, false); + }); + + it("should default splitTestsCompilation to false when omitted from an object config", async () => { + const resolvedConfig = await resolveSolidityUserConfig( + { solidity: { version: "0.8.28" } }, + otherResolvedConfig, + ); + assert.equal(resolvedConfig.solidity.splitTestsCompilation, false); + }); + + it("should resolve splitTestsCompilation: true from a single-version config", async () => { + const resolvedConfig = await resolveSolidityUserConfig( + { solidity: { version: "0.8.28", splitTestsCompilation: true } }, + otherResolvedConfig, + ); + assert.equal(resolvedConfig.solidity.splitTestsCompilation, true); + }); + + it("should resolve splitTestsCompilation: false from a single-version config", async () => { + const resolvedConfig = await resolveSolidityUserConfig( + { solidity: { version: "0.8.28", splitTestsCompilation: false } }, + otherResolvedConfig, + ); + assert.equal(resolvedConfig.solidity.splitTestsCompilation, false); + }); + + it("should resolve splitTestsCompilation from a multi-version config", async () => { + const resolvedConfig = await resolveSolidityUserConfig( + { + solidity: { + compilers: [{ version: "0.8.28" }], + splitTestsCompilation: true, + }, + }, + otherResolvedConfig, + ); + assert.equal(resolvedConfig.solidity.splitTestsCompilation, true); + }); + + it("should resolve splitTestsCompilation from a build-profiles config", async () => { + const resolvedConfig = await resolveSolidityUserConfig( + { + solidity: { + profiles: { + default: { version: "0.8.28" }, + }, + splitTestsCompilation: true, + }, + }, + otherResolvedConfig, + ); + assert.equal(resolvedConfig.solidity.splitTestsCompilation, true); + }); + }); + describe("profile-level preferWasm setting resolution", function () { const otherResolvedConfig = { paths: { root: process.cwd() } } as any; From be8bf7150a07dd9842a3c7f6bb9d0ddd7f209bcc Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Tue, 31 Mar 2026 17:41:36 -0300 Subject: [PATCH 05/83] Fix some tests that construct SolidityConfig objects manually --- .../builtin-plugins/solidity/build-system/build-system.ts | 1 + .../hardhat/test/internal/builtin-plugins/solidity/config.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/build-system.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/build-system.ts index 663fd97e81a..a007af44c35 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/build-system.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/build-system.ts @@ -98,6 +98,7 @@ describe( }, npmFilesToBuild: [], registeredCompilerTypes: ["solc"], + splitTestsCompilation: false, }; before(async () => { diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/config.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/config.ts index fbcefbd106b..93b45aba628 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/config.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/config.ts @@ -1764,6 +1764,7 @@ describe("validateResolvedConfig", () => { profiles, npmFilesToBuild: [], registeredCompilerTypes, + splitTestsCompilation: false, }, }) as unknown as HardhatConfig; From 8f5c52b6dfb7bd37a60300aedeae7fa7cfcab9f2 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Tue, 31 Mar 2026 18:43:20 -0300 Subject: [PATCH 06/83] Throw in the disabled apis --- .../solidity/build-system/build-system.ts | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts index 7c5fb56fdcd..d094119aab4 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts @@ -223,6 +223,15 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { rootFilePaths: string[], _options?: BuildOptions, ): Promise> { + if ( + !this.#options.solidityConfig.splitTestsCompilation && + _options?.scope === "tests" + ) { + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + ); + } + return this.#hooks.runHandlerChain( "solidity", "build", @@ -423,6 +432,15 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { rootFilePaths: string[], options?: GetCompilationJobsOptions, ): Promise { + if ( + !this.#options.solidityConfig.splitTestsCompilation && + options?.scope === "tests" + ) { + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + ); + } + await this.#downloadConfiguredCompilers(options?.quiet); const dependencyGraph = await buildDependencyGraph( @@ -816,6 +834,13 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { options: { scope?: BuildScope } = {}, ): Promise { const scope = options.scope ?? "contracts"; + const unified = !this.#options.solidityConfig.splitTestsCompilation; + + if (unified && scope === "tests") { + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + ); + } const artifactsPerFile = new Map(); const typeFilePaths = new Map(); @@ -972,9 +997,18 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { rootFilePaths: string[], options: { scope?: BuildScope } = {}, ): Promise { - log(`Cleaning up artifacts`); - const scope = options.scope ?? "contracts"; + + if ( + !this.#options.solidityConfig.splitTestsCompilation && + scope === "tests" + ) { + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + ); + } + + log(`Cleaning up artifacts`); const artifactsDirectory = await this.getArtifactsDirectory(scope); const userSourceNames = rootFilePaths.map((rootFilePath) => { From b6c93bf286df49305cf0b2e852f3ed4f14ab838b Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Tue, 31 Mar 2026 18:47:08 -0300 Subject: [PATCH 07/83] Update getRootFilePaths() --- .../solidity/build-system/build-system.ts | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts index d094119aab4..a34b5229220 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts @@ -174,10 +174,11 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { options: { scope?: BuildScope } = {}, ): Promise { const scope = options.scope ?? "contracts"; + const unified = !this.#options.solidityConfig.splitTestsCompilation; switch (scope) { - case "contracts": - const localFilesToCompile = ( + case "contracts": { + const localContractFiles = ( await Promise.all( this.#options.soliditySourcesPaths.map((dir) => getAllFilesMatching( @@ -193,8 +194,36 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { npmModuleToNpmRootPath, ); - return [...localFilesToCompile, ...npmFilesToBuild]; - case "tests": + if (!unified) { + return [...localContractFiles, ...npmFilesToBuild]; + } + + // In unified mode, contracts scope returns all roots: contracts, + // tests, and npm files. + const testFiles = ( + await Promise.all([ + getAllFilesMatching(this.#options.solidityTestsPath, (f) => + f.endsWith(".sol"), + ), + ...this.#options.soliditySourcesPaths.map(async (dir) => { + return getAllFilesMatching(dir, (f) => f.endsWith(".t.sol")); + }), + ]) + ).flat(1); + + // Remove duplicates in case there is an intersection between + // the tests.solidity paths and the sources paths + return Array.from( + new Set([...localContractFiles, ...npmFilesToBuild, ...testFiles]), + ); + } + case "tests": { + if (unified) { + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + ); + } + let rootFilePaths = ( await Promise.all([ getAllFilesMatching(this.#options.solidityTestsPath, (f) => @@ -210,6 +239,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { // the tests.solidity paths and the sources paths rootFilePaths = Array.from(new Set(rootFilePaths)); return rootFilePaths; + } } } From 7fd2e2bfc9b0e0621b46ca638e9f4c293f444432 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Tue, 31 Mar 2026 18:48:46 -0300 Subject: [PATCH 08/83] Update emitArtifacts() --- .../builtin-plugins/solidity/build-system/build-system.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts index a34b5229220..df33fc39190 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts @@ -922,8 +922,12 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { artifactsPerFile.set(formatRootPath(userSourceName, root), paths); - // Write the type declaration file, only for contracts - if (scope === "contracts") { + const isTestRoot = unified + ? (await this.getScope(root.fsPath)) === "tests" + : false; + + // Write the type declaration file for contract roots only. + if (scope === "contracts" && !isTestRoot) { const artifactsDeclarationFilePath = path.join( fileFolder, "artifacts.d.ts", From 35752f68f0af5c36573a47a24ea3d39f72cdfd33 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Tue, 31 Mar 2026 18:49:11 -0300 Subject: [PATCH 09/83] Update getArtifactsDirectory() --- .../builtin-plugins/solidity/build-system/build-system.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts index df33fc39190..21458bf6c31 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts @@ -1022,6 +1022,12 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { } public async getArtifactsDirectory(scope: BuildScope): Promise { + // In unified mode, both scopes point to the main artifacts directory + // because contract and test artifacts live together. + if (!this.#options.solidityConfig.splitTestsCompilation) { + return this.#options.artifactsPath; + } + return scope === "contracts" ? this.#options.artifactsPath : path.join(this.#options.cachePath, "test-artifacts"); From 6f75b54c9d8c4d57b194776dc5c5cf7ffa476264 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Tue, 31 Mar 2026 18:50:19 -0300 Subject: [PATCH 10/83] Remove outdated assertion --- .../builtin-plugins/solidity/build-system/build-system.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts index 21458bf6c31..0674ef2c8cd 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts @@ -376,7 +376,6 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { compilationResult, emitArtifactsResult, buildProfile.isolated, - options.scope, ); }), ); @@ -1229,7 +1228,6 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { result: CompilationResult, emitArtifactsResult: EmitArtifactsResult, isolated: boolean, - scope: BuildScope, ): Promise { for (const [userSourceName, root] of result.compilationJob.dependencyGraph .getRoots() @@ -1252,12 +1250,6 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { const typeFilePath = emitArtifactsResult.typeFilePaths.get(rootFilePath); - // Type declaration file is not generated for solidity tests - assertHardhatInvariant( - scope === "tests" || typeFilePath !== undefined, - `No type file found on map for contract ${rootFilePath}`, - ); - const jobHash = await individualJob.getBuildId(); this.#compileCache[rootFilePath] = { From 2e1a389150b3b940f29a1b4c0e591e5acd126983 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Tue, 31 Mar 2026 18:52:19 -0300 Subject: [PATCH 11/83] Fix existing build scope tests, as they were based on split mode --- .../build-system/integration/build-scopes.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts index f6eda4ed8d8..1b294dbbddb 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts @@ -62,7 +62,7 @@ describe("build system - build task - behavior on build scope", function () { it("compiles and generates artifacts for all contracts and tests", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(); + const hre = await project.getHRE(solidityCompilationConfig); await hre.tasks.getTask("build").run(); @@ -97,7 +97,7 @@ describe("build system - build task - behavior on build scope", function () { it("performs cleanup on both contracts and tests artifacts and build infos", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(); + const hre = await project.getHRE(solidityCompilationConfig); // Create test build info and artifact file that should be cleaned up const contractsArtifactsPath = @@ -155,7 +155,7 @@ describe("build system - build task - behavior on build scope", function () { it("identifies when a file is a contract", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(); + const hre = await project.getHRE(solidityCompilationConfig); process.chdir(project.path); await hre.tasks.getTask("build").run({ files: ["contracts/Foo.sol"] }); @@ -171,7 +171,7 @@ describe("build system - build task - behavior on build scope", function () { it("identifies when a file is a test", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(); + const hre = await project.getHRE(solidityCompilationConfig); process.chdir(project.path); await hre.tasks @@ -205,7 +205,7 @@ describe("build system - build task - behavior on build scope", function () { describe("compiling with the --no-test flag", function () { it("compiles and generates artifacts for contracts, but not tests", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(); + const hre = await project.getHRE(solidityCompilationConfig); await hre.tasks.getTask("build").run({ noTests: true }); @@ -249,7 +249,7 @@ describe("build system - build task - behavior on build scope", function () { it("performs cleanup on contract artifacts, but not tests", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(); + const hre = await project.getHRE(solidityCompilationConfig); // Create test build info and artifact file that should be cleaned up const contractsArtifactsPath = @@ -306,7 +306,7 @@ describe("build system - build task - behavior on build scope", function () { describe("compiling with the --no-contracts flag", function () { it("compiles and generates artifacts for tests, but not contracts", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(); + const hre = await project.getHRE(solidityCompilationConfig); await hre.tasks.getTask("build").run({ noContracts: true }); @@ -350,7 +350,7 @@ describe("build system - build task - behavior on build scope", function () { it("performs cleanup on tests artifacts, but not contracts", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(); + const hre = await project.getHRE(solidityCompilationConfig); // Create test build info and artifact file that should be cleaned up const contractsArtifactsPath = @@ -407,7 +407,7 @@ describe("build system - build task - behavior on build scope", function () { it("Should throw if a test file isn't recognized", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(); + const hre = await project.getHRE(solidityCompilationConfig); const previousCwd = process.cwd(); process.chdir(project.path); @@ -436,7 +436,7 @@ describe("build system - build task - behavior on build scope", function () { it("Should throw if a contract isn't recognized", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(); + const hre = await project.getHRE(solidityCompilationConfig); const previousCwd = process.cwd(); process.chdir(project.path); @@ -457,7 +457,7 @@ describe("build system - build task - behavior on build scope", function () { it("Should throw if neither is recognized", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(); + const hre = await project.getHRE(solidityCompilationConfig); const previousCwd = process.cwd(); process.chdir(project.path); From 1b26dd5d71d0cc572ab8ab834c3986f37649a84a Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Tue, 31 Mar 2026 18:58:23 -0300 Subject: [PATCH 12/83] Add tests for the new behavior --- .../build-system/integration/build-scopes.ts | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts index 1b294dbbddb..96ab998822c 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts @@ -6,7 +6,9 @@ import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { assertRejectsWithHardhatError } from "@nomicfoundation/hardhat-test-utils"; import { exists, + getAllFilesMatching, readJsonFile, + readUtf8File, writeUtf8File, } from "@nomicfoundation/hardhat-utils/fs"; @@ -56,6 +58,13 @@ const basicProjectTemplate = { }, }; +const solidityCompilationConfig = { + solidity: { + version: "0.8.28", + splitTestsCompilation: true, + }, +}; + describe("build system - build task - behavior on build scope", function () { describe("compiling without flags", function () { describe("full compilation", function () { @@ -482,3 +491,279 @@ describe("build system - build task - behavior on build scope", function () { }); }); }); + +describe("build system - splitTestsCompilation: false", function () { + const unifiedTestsCompilationConfig = { + solidity: { + version: "0.8.28", + splitTestsCompilation: false, + }, + }; + + describe("getRootFilePaths", function () { + it("returns contract, test, and npm roots for scope 'contracts'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + const roots = await hre.solidity.getRootFilePaths({ + scope: "contracts", + }); + + // Should contain the contract file + assert.ok( + roots.some((r) => r.endsWith("Foo.sol") && !r.endsWith(".t.sol")), + "Expected contract root Foo.sol in unified roots", + ); + // Should contain the .t.sol test file + assert.ok( + roots.some((r) => r.endsWith("Foo.t.sol")), + "Expected test root Foo.t.sol in unified roots", + ); + // Should contain the test directory test file + assert.ok( + roots.some((r) => r.endsWith("OtherFooTest.sol")), + "Expected test root OtherFooTest.sol in unified roots", + ); + }); + + it("throws for scope 'tests'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await assertRejectsWithHardhatError( + hre.solidity.getRootFilePaths({ scope: "tests" }), + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + {}, + ); + }); + }); + + describe("getArtifactsDirectory", function () { + it("returns the main artifacts dir for scope 'tests'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + const contractsDir = + await hre.solidity.getArtifactsDirectory("contracts"); + const testsDir = await hre.solidity.getArtifactsDirectory("tests"); + + assert.equal(contractsDir, testsDir); + }); + }); + + describe("low-level scope:'tests' rejection", function () { + it("build() throws for scope 'tests'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await assertRejectsWithHardhatError( + hre.solidity.build([], { scope: "tests" }), + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + {}, + ); + }); + + it("getCompilationJobs() throws for scope 'tests'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await assertRejectsWithHardhatError( + hre.solidity.getCompilationJobs([], { scope: "tests" }), + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + {}, + ); + }); + + it("emitArtifacts() throws for scope 'tests'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + // We need a real compilation job to call emitArtifacts. + // Build first so we can get a compilation job. + const roots = await hre.solidity.getRootFilePaths({ + scope: "contracts", + }); + const contractRoots = roots.filter( + (r) => !r.endsWith(".t.sol") && !r.includes("/test/"), + ); + const result = await hre.solidity.getCompilationJobs(contractRoots, { + scope: "contracts", + }); + + assert.ok(result.success, "Expected compilation jobs to succeed"); + + const firstJob = [...result.compilationJobsPerFile.values()][0]; + const runResult = await hre.solidity.runCompilationJob(firstJob); + + await assertRejectsWithHardhatError( + hre.solidity.emitArtifacts(firstJob, runResult.output, { + scope: "tests", + }), + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + {}, + ); + }); + + it("cleanupArtifacts() throws for scope 'tests'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await assertRejectsWithHardhatError( + hre.solidity.cleanupArtifacts([], { scope: "tests" }), + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + {}, + ); + }); + }); + + describe("emitArtifacts - type declarations", function () { + it("skips per-source artifacts.d.ts for test roots in unified contracts-scope builds", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + // Build directly using the build-system APIs (the build task is + // not updated until Phase 4). + const roots = await hre.solidity.getRootFilePaths({ + scope: "contracts", + }); + const buildResult = await hre.solidity.build(roots, { + scope: "contracts", + }); + + assert.ok( + hre.solidity.isSuccessfulBuildResult(buildResult), + "Expected build to succeed", + ); + + const artifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + + // Contract root should have artifacts.d.ts + assert.equal( + await exists( + path.join(artifactsPath, "contracts", "Foo.sol", "artifacts.d.ts"), + ), + true, + ); + + // Test roots should NOT have artifacts.d.ts + assert.equal( + await exists( + path.join(artifactsPath, "contracts", "Foo.t.sol", "artifacts.d.ts"), + ), + false, + ); + assert.equal( + await exists( + path.join( + artifactsPath, + "test", + "OtherFooTest.sol", + "artifacts.d.ts", + ), + ), + false, + ); + }); + }); + + describe("unified cleanup", function () { + it("includes test artifacts in duplicate-name detection", async () => { + const duplicateNameTemplate = { + name: "test", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.0;\ncontract Foo {}`, + "test/Foo.sol": `// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.0;\ncontract Foo {}`, + }, + }; + + await using project = await useTestProjectTemplate(duplicateNameTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + // Build directly using the build-system APIs (the build task is + // not updated until Phase 4). + const roots = await hre.solidity.getRootFilePaths({ + scope: "contracts", + }); + const buildResult = await hre.solidity.build(roots, { + scope: "contracts", + }); + + assert.ok( + hre.solidity.isSuccessfulBuildResult(buildResult), + "Expected build to succeed", + ); + + await hre.solidity.cleanupArtifacts([...buildResult.keys()], { + scope: "contracts", + }); + + const artifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + + // The top-level artifacts.d.ts should exist and contain the duplicate + const topLevelDts = path.join(artifactsPath, "artifacts.d.ts"); + assert.equal(await exists(topLevelDts), true); + const dtsContent = await readUtf8File(topLevelDts); + assert.ok( + dtsContent.includes('"Foo"'), + "Expected top-level artifacts.d.ts to include the duplicated contract name Foo from both test and contract artifacts", + ); + }); + + it("passes mixed contract and test artifact paths to onCleanUpArtifacts", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + // Build directly using the build-system APIs (the build task is + // not updated until Phase 4). + const roots = await hre.solidity.getRootFilePaths({ + scope: "contracts", + }); + const buildResult = await hre.solidity.build(roots, { + scope: "contracts", + }); + + assert.ok( + hre.solidity.isSuccessfulBuildResult(buildResult), + "Expected build to succeed", + ); + + // This is run directly here, so this isn't testing much now, but will be + // better tested in Phase 4 + await hre.solidity.cleanupArtifacts([...buildResult.keys()], { + scope: "contracts", + }); + + const artifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + + // All artifacts should be in the main artifacts directory + const buildInfoDir = path.join(artifactsPath, "build-info"); + const artifactPaths = await getAllFilesMatching( + artifactsPath, + (p) => + p.endsWith(".json") && + p.indexOf(path.sep, artifactsPath.length + path.sep.length) !== -1, + (dir) => dir !== buildInfoDir, + ); + + // Should include both contract and test artifacts + assert.ok( + artifactPaths.some( + (p) => p.includes("Foo.sol") && !p.includes(".t.sol"), + ), + "Expected contract artifact Foo.json in unified artifacts", + ); + assert.ok( + artifactPaths.some((p) => p.includes("Foo.t.sol")), + "Expected test artifact FooTest.json in unified artifacts", + ); + assert.ok( + artifactPaths.some((p) => p.includes("OtherFooTest.sol")), + "Expected test artifact OtherFooTest.json in unified artifacts", + ); + }); + }); +}); From 770b1be87439506dc8dc8bd1d2f7765088fc5490 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Tue, 31 Mar 2026 18:59:33 -0300 Subject: [PATCH 13/83] Update the plan so that phase 4 fixes the tests that needed build to be already adapted. --- SPLIT_TESTS_COMPILATION_SPEC.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/SPLIT_TESTS_COMPILATION_SPEC.md b/SPLIT_TESTS_COMPILATION_SPEC.md index 655eb61f936..4a6fc3c21aa 100644 --- a/SPLIT_TESTS_COMPILATION_SPEC.md +++ b/SPLIT_TESTS_COMPILATION_SPEC.md @@ -575,6 +575,10 @@ Rewrite the high-level build task to implement the new unified-mode semantics. - Run existing scope and cleanup tests: - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts` - `packages/hardhat/test/internal/builtin-plugins/solidity/tasks/build-cleanup-artifacts.ts` +- Update Phase 2 tests that call build-system APIs directly to go through the build task instead: + - "skips per-source artifacts.d.ts for test roots in unified contracts-scope builds": replace direct `getRootFilePaths` + `build` calls with `hre.tasks.getTask("build").run()` + - "includes test artifacts in duplicate-name detection": replace direct `getRootFilePaths` + `build` + `cleanupArtifacts` calls with `hre.tasks.getTask("build").run()` + - "passes mixed contract and test artifact paths to onCleanUpArtifacts": replace direct `getRootFilePaths` + `build` + `cleanupArtifacts` calls with `hre.tasks.getTask("build").run()` and use an inline plugin to register an `onCleanUpArtifacts` handler that asserts on the received artifact paths - Add tests for: - unified full build compiles contracts and tests together - unified explicit-file builds compile exactly the provided files From b3c244453796d097366c9a41972ac9f23c7041ec Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 07:20:55 -0300 Subject: [PATCH 14/83] Reintroduced accidentally deleted descriptor --- packages/hardhat-errors/src/descriptors.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index c442fdf5e47..8dac7f5a7a9 100644 --- a/packages/hardhat-errors/src/descriptors.ts +++ b/packages/hardhat-errors/src/descriptors.ts @@ -1346,6 +1346,20 @@ Solidity test files must be placed in your test directory, or in your contracts Solidity test files must be placed in your test directory, or in your contracts directory and end in .t.sol.`, }, + SPLIT_TESTS_COMPILATION_DISABLED: { + number: 916, + messageTemplate: `A method of the SolidityBuildSystem was called with \`scope: "tests"\`, but \`splitTestsCompilation\` is disabled in your config. + +When \`splitTestsCompilation\` is \`false\`, contracts and tests are compiled together under \`scope: "contracts"\`, so \`scope: "tests"\` is not a valid option. + +Set \`solidity.splitTestsCompilation\` to \`true\` in your Hardhat config to enable this build scope.`, + websiteTitle: "Split tests compilation is disabled", + websiteDescription: `The Solidity build system was called with \`scope: "tests"\`, but \`splitTestsCompilation\` is disabled in your config. + +When \`splitTestsCompilation\` is \`false\`, contracts and tests are compiled together under \`scope: "contracts"\`, so \`scope: "tests"\` is not a valid option. + +Set \`solidity.splitTestsCompilation\` to \`true\` in your Hardhat config to enable this build scope.`, + }, }, ARTIFACTS: { NOT_FOUND: { From d863c0eb0d6280d2442d7a6a0b98f27765a2b842 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 07:21:33 -0300 Subject: [PATCH 15/83] Reintroduced accidentally deleted jsdoc --- .../src/types/solidity/build-system.ts | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/hardhat/src/types/solidity/build-system.ts b/packages/hardhat/src/types/solidity/build-system.ts index cbfb027aec6..d01255bb9f3 100644 --- a/packages/hardhat/src/types/solidity/build-system.ts +++ b/packages/hardhat/src/types/solidity/build-system.ts @@ -43,7 +43,13 @@ export interface BuildOptions { quiet?: boolean; /** - * Whether to compile contracts or tests. Defaults to contracts + * Whether to compile contracts or tests. Defaults to contracts. + * + * When `solidity.splitTestsCompilation` is `false` (the default), only + * `"contracts"` is accepted. + * + * When `solidity.splitTestsCompilation` is `true`, both scopes are valid + * and produce separate compilation passes. */ scope?: BuildScope; } @@ -272,6 +278,16 @@ export interface SolidityBuildSystem { * The root files are either absolute paths or * `npm:/` URIs. * + * When `solidity.splitTestsCompilation` is `false`, contracts and tests are + * compiled together and `scope: "contracts"` returns every build root: + * contract roots, test roots, and `npmFilesToBuild` roots. + * + * Calling this method with `scope: "tests"` in this mode is a logic error and + * throws a `HardhatError`. + * + * When `solidity.splitTestsCompilation` is `true`, `scope: "contracts"` + * returns only contract roots and `scope: "tests"` returns only test roots. + * * @returns An array of root file paths. */ getRootFilePaths(options?: { scope?: BuildScope }): Promise; @@ -284,6 +300,15 @@ export interface SolidityBuildSystem { /** * Builds the provided files, generating their compilation artifacts. * + * When `solidity.splitTestsCompilation` is `false`, this method rejects + * `scope: "tests"` as a logic error and throws a `HardhatError` + * + * In this mode, callers must use `scope: "contracts"` and contracts and tests + * are built together, emitting their artifacts into the same directory. + * + * When `solidity.splitTestsCompilation` is `true`, both scopes are valid + * and are built into separate artifact directories. + * * @param rootFilePaths The files to build, which can be either absolute paths * or `npm:/` URIs. * @param options The options to use when building the files. @@ -313,6 +338,9 @@ export interface SolidityBuildSystem { * can be returned for multiple files, so you should deduplicate the results * before using them. * + * When `solidity.splitTestsCompilation` is `false`, this method rejects + * `scope: "tests"` as a logic error and throws a `HardhatError`. + * * @param rootFilePaths The files to analyze, which can be either absolute * paths or `npm:/` URIs. * @param options The options to use when analyzing the files. @@ -358,6 +386,13 @@ export interface SolidityBuildSystem { /** * Emits the artifacts of the given compilation job. * + * When `solidity.splitTestsCompilation` is `false`, this method rejects + * `scope: "tests"` as a logic error and throws a `HardhatError` + * + * Artifacts for both contracts and tests are emitted under the main artifacts + * directory when built with `scope: "contracts"` and `splitTestsCompilation` + * `false`. + * * @param compilationJob The compilation job to emit the artifacts of. * @param compilerOutput The result of running the compilation job. * @returns A map from user source name to the absolute paths of the @@ -379,7 +414,25 @@ export interface SolidityBuildSystem { * This method should only be used after a complete build has succeeded, as * it relies on the build system to have generated all the necessary artifact * files. - + * + * When `solidity.splitTestsCompilation` is `false`, this method rejects + * `scope: "tests"` as a logic error and throws a `HardhatError`. Cleanup in + * this mode operates on the main artifacts directory using `scope: + * "contracts"`. + * + * What is considered a complete build changes according to + * `splitTestsCompilation`: + * - When it's `true` + * - A full "contracts" build is run when `--no-contracts` isn't used, and + * no explicit contract `files` are provided (i.e. you can still provide + * explicit test `files`). + * - A full "tests" build is run when `--no-tests` isn't used, and no + * explicit test files `files` are provided (i.e. you can still provide + * explicit contract `files`) + * - When it's `false` + * - A full build is when `files`, `--no-contracts`, nor `--no-tests` are + * used. + * * @param rootFilePaths All the root files of the project. */ cleanupArtifacts( @@ -405,7 +458,17 @@ export interface SolidityBuildSystem { ): Promise; /** - * Gets the artifacts directory for a given target (contracts/tests) + * Gets the artifacts directory for a given target (contracts/tests). + * + * When `solidity.splitTestsCompilation` is `false`, both scopes return the + * main artifacts directory, because contracts and tests share it. + * + * Unlike the other scope-aware methods, this one does not throw on that mode, + * as it's a read-only method that can be helpful for plugins. + * + * When `solidity.splitTestsCompilation` is `true`, `scope: "contracts"` + * returns the main artifacts directory and `scope: "tests"` returns a + * separate test-artifacts directory under the cache path. */ getArtifactsDirectory(scope: BuildScope): Promise; } From 9b79ed806f85282194f52d40b85e240ba5ca84ee Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 07:21:46 -0300 Subject: [PATCH 16/83] Tiny optimization to getScope() --- .../builtin-plugins/solidity/build-system/build-system.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts index 0674ef2c8cd..9c38c86636f 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts @@ -161,9 +161,11 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { return "tests"; } - for (const sourcesPath of this.#options.soliditySourcesPaths) { - if (fsPath.startsWith(sourcesPath) && fsPath.endsWith(".t.sol")) { - return "tests"; + if (fsPath.endsWith(".t.sol")) { + for (const sourcesPath of this.#options.soliditySourcesPaths) { + if (fsPath.startsWith(sourcesPath)) { + return "tests"; + } } } From ed5c4c58bc7a6a14aae0206d9bbca277a4471c2b Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 07:39:17 -0300 Subject: [PATCH 17/83] Make test portable --- .../solidity/build-system/integration/build-scopes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts index 96ab998822c..3ba47b1af16 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts @@ -584,7 +584,8 @@ describe("build system - splitTestsCompilation: false", function () { scope: "contracts", }); const contractRoots = roots.filter( - (r) => !r.endsWith(".t.sol") && !r.includes("/test/"), + (r) => + !r.endsWith(".t.sol") && !r.includes(path.sep + "test" + path.sep), ); const result = await hre.solidity.getCompilationJobs(contractRoots, { scope: "contracts", From 832063f7cbc28ed04173511242a5a89e943b503b Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 10:53:23 -0300 Subject: [PATCH 18/83] Include the artifacts directory and if a root file emitted ts types or not in the CompileCacheEntry interface --- .../solidity/build-system/build-system.ts | 57 +++ .../solidity/build-system/cache.ts | 2 + .../output-layout-cache.ts | 376 ++++++++++++++++++ 3 files changed, 435 insertions(+) create mode 100644 packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/output-layout-cache.ts diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts index 9c38c86636f..08e238d3f2a 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts @@ -378,6 +378,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { compilationResult, emitArtifactsResult, buildProfile.isolated, + options.scope, ); }), ); @@ -641,6 +642,26 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { continue; } + // Validate output layout: if the cached layout doesn't match the + // expected layout for the current config, treat it as a miss. + // Pre-existing cache entries without these fields are also treated + // as misses. + const expectedLayout = await this.#getExpectedOutputLayout( + rootFile, + options?.scope ?? "contracts", + ); + + if ( + cacheResult.artifactsDirectory === undefined || + cacheResult.emitsTypeDeclarations === undefined || + cacheResult.artifactsDirectory !== expectedLayout.artifactsDirectory || + cacheResult.emitsTypeDeclarations !== + expectedLayout.emitsTypeDeclarations + ) { + rootFilesToCompile.add(rootFile); + continue; + } + // If any of the emitted files are not present anymore, compile it const { artifactPaths, @@ -1225,11 +1246,40 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { return `${error.type}: ${error.message}`.replace(/[:\s]*$/g, "").trim(); } + async #getExpectedOutputLayout( + rootFilePath: string, + scope: BuildScope, + ): Promise<{ artifactsDirectory: string; emitsTypeDeclarations: boolean }> { + const artifactsDirectory = await this.getArtifactsDirectory(scope); + + const unified = !this.#options.solidityConfig.splitTestsCompilation; + + // In unified mode, test roots under contracts scope don't emit type + // declarations. In split mode, the scope alone determines this. + let emitsTypeDeclarations: boolean; + if (scope === "contracts") { + if (unified) { + const parsed = parseRootPath(rootFilePath); + const isTestRoot = isNpmParsedRootPath(parsed) + ? false + : (await this.getScope(parsed.fsPath)) === "tests"; + emitsTypeDeclarations = !isTestRoot; + } else { + emitsTypeDeclarations = true; + } + } else { + emitsTypeDeclarations = false; + } + + return { artifactsDirectory, emitsTypeDeclarations }; + } + async #cacheCompilationResult( indexedIndividualJobs: Map, result: CompilationResult, emitArtifactsResult: EmitArtifactsResult, isolated: boolean, + scope: BuildScope, ): Promise { for (const [userSourceName, root] of result.compilationJob.dependencyGraph .getRoots() @@ -1254,6 +1304,11 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { const jobHash = await individualJob.getBuildId(); + const expectedLayout = await this.#getExpectedOutputLayout( + rootFilePath, + scope, + ); + this.#compileCache[rootFilePath] = { jobHash, isolated, @@ -1263,6 +1318,8 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { buildInfoOutputPath: emitArtifactsResult.buildInfoOutputPath, typeFilePath, wasm: result.compiler.isSolcJs, + artifactsDirectory: expectedLayout.artifactsDirectory, + emitsTypeDeclarations: expectedLayout.emitsTypeDeclarations, }; } } diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/cache.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/cache.ts index 2f414241cd0..2fe26086506 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/cache.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/cache.ts @@ -23,6 +23,8 @@ export interface CompileCacheEntry { artifactPaths: string[]; typeFilePath?: string; wasm: boolean; + artifactsDirectory?: string; + emitsTypeDeclarations?: boolean; } const CACHE_FILE_NAME = `compile-cache.json`; diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/output-layout-cache.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/output-layout-cache.ts new file mode 100644 index 00000000000..2e4491ab8cc --- /dev/null +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/output-layout-cache.ts @@ -0,0 +1,376 @@ +import type { CompileCache } from "../../../../../../src/internal/builtin-plugins/solidity/build-system/cache.js"; + +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { readJsonFile, writeJsonFile } from "@nomicfoundation/hardhat-utils/fs"; + +import { createHardhatRuntimeEnvironment } from "../../../../../../src/internal/hre-initialization.js"; +import { FileBuildResultType } from "../../../../../../src/types/solidity/build-system.js"; +import { useTestProjectTemplate } from "../resolver/helpers.js"; + +import { getHRE } from "./helpers.js"; + +const CACHE_FILE = "compile-cache.json"; + +/** + * Creates an HRE for a given project with the specified splitTestsCompilation + * config value. + */ +async function getHREWithSplitConfig( + projectPath: string, + splitTestsCompilation: boolean, +) { + return createHardhatRuntimeEnvironment( + { + solidity: { + profiles: { + default: { version: "0.8.30", isolated: false }, + production: { version: "0.8.30", isolated: true }, + }, + splitTestsCompilation, + }, + }, + {}, + projectPath, + ); +} + +describe("Compile cache output layout", () => { + describe("unified mode", () => { + it("should cache-hit both contract and test roots on second build", async () => { + await using project = await useTestProjectTemplate({ + name: "test-project", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract Foo {}`, + "test/FooTest.sol": `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract FooTest {}`, + }, + }); + + const hre = await getHRE(project); + const contractPath = path.join(project.path, "contracts/Foo.sol"); + const testPath = path.join(project.path, "test/FooTest.sol"); + + // First build + const firstResult = await hre.solidity.build([contractPath, testPath], { + quiet: true, + }); + assert( + hre.solidity.isSuccessfulBuildResult(firstResult), + "First build should succeed", + ); + assert.equal( + firstResult.get(contractPath)?.type, + FileBuildResultType.BUILD_SUCCESS, + ); + assert.equal( + firstResult.get(testPath)?.type, + FileBuildResultType.BUILD_SUCCESS, + ); + + // Second build - both should be cache hits + const secondResult = await hre.solidity.build([contractPath, testPath], { + quiet: true, + }); + assert( + hre.solidity.isSuccessfulBuildResult(secondResult), + "Second build should succeed", + ); + assert.equal( + secondResult.get(contractPath)?.type, + FileBuildResultType.CACHE_HIT, + ); + assert.equal( + secondResult.get(testPath)?.type, + FileBuildResultType.CACHE_HIT, + ); + }); + + it("should cache-hit test roots correctly without type declarations", async () => { + await using project = await useTestProjectTemplate({ + name: "test-project", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract Foo {}`, + "test/FooTest.sol": `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract FooTest {}`, + }, + }); + + const hre = await getHRE(project); + const contractPath = path.join(project.path, "contracts/Foo.sol"); + const testPath = path.join(project.path, "test/FooTest.sol"); + + // First build + await hre.solidity.build([contractPath, testPath], { quiet: true }); + + // Verify cache entry for test root has emitsTypeDeclarations: false + const cachePath = path.join(project.path, "cache", CACHE_FILE); + const cache: CompileCache = await readJsonFile(cachePath); + const testEntry = cache[testPath]; + assert.notEqual(testEntry, undefined, "Test entry should exist in cache"); + assert.equal( + testEntry.emitsTypeDeclarations, + false, + "Test root should not emit type declarations", + ); + assert.equal( + testEntry.typeFilePath, + undefined, + "Test root should have no typeFilePath", + ); + + // Verify contract root has emitsTypeDeclarations: true + const contractEntry = cache[contractPath]; + assert.notEqual( + contractEntry, + undefined, + "Contract entry should exist in cache", + ); + assert.equal( + contractEntry.emitsTypeDeclarations, + true, + "Contract root should emit type declarations", + ); + + // Second build - test root should still be a cache hit + const secondResult = await hre.solidity.build([contractPath, testPath], { + quiet: true, + }); + assert( + hre.solidity.isSuccessfulBuildResult(secondResult), + "Second build should succeed", + ); + assert.equal( + secondResult.get(testPath)?.type, + FileBuildResultType.CACHE_HIT, + ); + }); + + it("should store artifactsDirectory in cache entries", async () => { + await using project = await useTestProjectTemplate({ + name: "test-project", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract Foo {}`, + }, + }); + + const hre = await getHRE(project); + const filePath = path.join(project.path, "contracts/Foo.sol"); + + await hre.solidity.build([filePath], { quiet: true }); + + const cachePath = path.join(project.path, "cache", CACHE_FILE); + const cache: CompileCache = await readJsonFile(cachePath); + const entry = cache[filePath]; + assert.notEqual(entry, undefined, "Entry should exist in cache"); + assert.equal( + entry.artifactsDirectory, + path.join(project.path, "artifacts"), + "Artifacts directory should be the main artifacts path", + ); + }); + }); + + describe("toggling splitTestsCompilation", () => { + it("should invalidate cache when switching from split to unified", async () => { + await using project = await useTestProjectTemplate({ + name: "test-project", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract Foo {}`, + }, + }); + + // Build with split mode + const hreSplit = await getHREWithSplitConfig(project.path, true); + const filePath = path.join(project.path, "contracts/Foo.sol"); + await hreSplit.solidity.build([filePath], { + quiet: true, + scope: "contracts", + }); + + // Verify cache hit in split mode + const splitResult2 = await hreSplit.solidity.build([filePath], { + quiet: true, + scope: "contracts", + }); + assert( + hreSplit.solidity.isSuccessfulBuildResult(splitResult2), + "Split mode second build should succeed", + ); + assert.equal( + splitResult2.get(filePath)?.type, + FileBuildResultType.CACHE_HIT, + ); + + // Switch to unified mode - for contracts in contracts scope, both modes + // have the same artifactsDir and emitsTypeDeclarations=true, so this + // case produces a cache hit. The invalidation matters for test roots + // (tested separately below). + const hreUnified = await getHREWithSplitConfig(project.path, false); + const unifiedResult = await hreUnified.solidity.build([filePath], { + quiet: true, + }); + assert( + hreUnified.solidity.isSuccessfulBuildResult(unifiedResult), + "Unified mode build should succeed", + ); + }); + + it("should invalidate test root cache when switching from split to unified", async () => { + await using project = await useTestProjectTemplate({ + name: "test-project", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract Foo {}`, + "test/FooTest.sol": `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract FooTest {}`, + }, + }); + + const testPath = path.join(project.path, "test/FooTest.sol"); + + // Build test in split mode (scope: "tests") + const hreSplit = await getHREWithSplitConfig(project.path, true); + await hreSplit.solidity.build([testPath], { + quiet: true, + scope: "tests", + }); + + // Verify cache hit in split mode + const splitResult2 = await hreSplit.solidity.build([testPath], { + quiet: true, + scope: "tests", + }); + assert( + hreSplit.solidity.isSuccessfulBuildResult(splitResult2), + "Split mode second build should succeed", + ); + assert.equal( + splitResult2.get(testPath)?.type, + FileBuildResultType.CACHE_HIT, + ); + + // Switch to unified mode and build with scope: "contracts" + // The test root should be a cache miss because: + // - artifactsDirectory changed (from cache/test-artifacts to artifacts) + // - emitsTypeDeclarations is still false + const hreUnified = await getHREWithSplitConfig(project.path, false); + const contractPath = path.join(project.path, "contracts/Foo.sol"); + const unifiedResult = await hreUnified.solidity.build( + [contractPath, testPath], + { quiet: true }, + ); + assert( + hreUnified.solidity.isSuccessfulBuildResult(unifiedResult), + "Unified mode build should succeed", + ); + assert.equal( + unifiedResult.get(testPath)?.type, + FileBuildResultType.BUILD_SUCCESS, + "Test root should be recompiled after switching to unified mode", + ); + }); + }); + + describe("pre-existing cache entries without output layout fields", () => { + it("should treat entries missing artifactsDirectory as cache misses", async () => { + await using project = await useTestProjectTemplate({ + name: "test-project", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract Foo {}`, + }, + }); + + const hre = await getHRE(project); + const filePath = path.join(project.path, "contracts/Foo.sol"); + + // First build to populate cache + await hre.solidity.build([filePath], { quiet: true }); + + // Remove the new fields from cache to simulate old format + const cachePath = path.join(project.path, "cache", CACHE_FILE); + const cache: Record> = await readJsonFile( + cachePath, + ); + for (const key of Object.keys(cache)) { + delete cache[key].artifactsDirectory; + delete cache[key].emitsTypeDeclarations; + } + await writeJsonFile(cachePath, cache); + + // Second build should be a cache miss + const result = await hre.solidity.build([filePath], { quiet: true }); + assert( + hre.solidity.isSuccessfulBuildResult(result), + "Build should succeed", + ); + assert.equal( + result.get(filePath)?.type, + FileBuildResultType.BUILD_SUCCESS, + "Should recompile when output layout fields are missing from cache", + ); + }); + + it("should treat entries missing emitsTypeDeclarations as cache misses", async () => { + await using project = await useTestProjectTemplate({ + name: "test-project", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract Foo {}`, + }, + }); + + const hre = await getHRE(project); + const filePath = path.join(project.path, "contracts/Foo.sol"); + + // First build to populate cache + await hre.solidity.build([filePath], { quiet: true }); + + // Remove only emitsTypeDeclarations to simulate partial old format + const cachePath = path.join(project.path, "cache", CACHE_FILE); + const cache: Record> = await readJsonFile( + cachePath, + ); + for (const key of Object.keys(cache)) { + delete cache[key].emitsTypeDeclarations; + } + await writeJsonFile(cachePath, cache); + + // Second build should be a cache miss + const result = await hre.solidity.build([filePath], { quiet: true }); + assert( + hre.solidity.isSuccessfulBuildResult(result), + "Build should succeed", + ); + assert.equal( + result.get(filePath)?.type, + FileBuildResultType.BUILD_SUCCESS, + "Should recompile when emitsTypeDeclarations is missing from cache", + ); + }); + }); +}); From 69a4ed0d49a699a547036d913279c5aa5e35c2a1 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 08:17:36 -0300 Subject: [PATCH 19/83] Address copilot feedback --- .../partial-compilation/output-layout-cache.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/output-layout-cache.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/output-layout-cache.ts index 2e4491ab8cc..cbaefbb34fa 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/output-layout-cache.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/output-layout-cache.ts @@ -185,7 +185,7 @@ contract Foo {}`, }); describe("toggling splitTestsCompilation", () => { - it("should invalidate cache when switching from split to unified", async () => { + it("should cache-hit contract roots when switching from split to unified (layout unchanged)", async () => { await using project = await useTestProjectTemplate({ name: "test-project", version: "1.0.0", @@ -230,6 +230,10 @@ contract Foo {}`, hreUnified.solidity.isSuccessfulBuildResult(unifiedResult), "Unified mode build should succeed", ); + assert.equal( + unifiedResult.get(filePath)?.type, + FileBuildResultType.CACHE_HIT, + ); }); it("should invalidate test root cache when switching from split to unified", async () => { @@ -309,14 +313,14 @@ contract Foo {}`, // First build to populate cache await hre.solidity.build([filePath], { quiet: true }); - // Remove the new fields from cache to simulate old format + // Remove only artifactsDirectory to simulate partial old format; the + // next test covers the missing-emitsTypeDeclarations case. const cachePath = path.join(project.path, "cache", CACHE_FILE); const cache: Record> = await readJsonFile( cachePath, ); for (const key of Object.keys(cache)) { delete cache[key].artifactsDirectory; - delete cache[key].emitsTypeDeclarations; } await writeJsonFile(cachePath, cache); @@ -329,7 +333,7 @@ contract Foo {}`, assert.equal( result.get(filePath)?.type, FileBuildResultType.BUILD_SUCCESS, - "Should recompile when output layout fields are missing from cache", + "Should recompile when artifactsDirectory is missing from cache", ); }); From 951890913a79f19a5ec5c14b408fd616d6efe4fe Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 08:17:59 -0300 Subject: [PATCH 20/83] Add missing test --- .../output-layout-cache.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/output-layout-cache.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/output-layout-cache.ts index cbaefbb34fa..48b021b0f3e 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/output-layout-cache.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/output-layout-cache.ts @@ -293,6 +293,63 @@ contract FooTest {}`, "Test root should be recompiled after switching to unified mode", ); }); + + it("should invalidate test root cache when switching from unified to split", async () => { + await using project = await useTestProjectTemplate({ + name: "test-project", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract Foo {}`, + "test/FooTest.sol": `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract FooTest {}`, + }, + }); + + const contractPath = path.join(project.path, "contracts/Foo.sol"); + const testPath = path.join(project.path, "test/FooTest.sol"); + + // Build test in unified mode (default scope: "contracts") + const hreUnified = await getHREWithSplitConfig(project.path, false); + await hreUnified.solidity.build([contractPath, testPath], { + quiet: true, + }); + + // Verify cache hit in unified mode + const unifiedResult2 = await hreUnified.solidity.build( + [contractPath, testPath], + { quiet: true }, + ); + assert( + hreUnified.solidity.isSuccessfulBuildResult(unifiedResult2), + "Unified mode second build should succeed", + ); + assert.equal( + unifiedResult2.get(testPath)?.type, + FileBuildResultType.CACHE_HIT, + ); + + // Switch to split mode and build the test with scope: "tests" + // The test root should be a cache miss because: + // - artifactsDirectory changed (from artifacts to cache/test-artifacts) + // - emitsTypeDeclarations is still false + const hreSplit = await getHREWithSplitConfig(project.path, true); + const splitResult = await hreSplit.solidity.build([testPath], { + quiet: true, + scope: "tests", + }); + assert( + hreSplit.solidity.isSuccessfulBuildResult(splitResult), + "Split mode build should succeed", + ); + assert.equal( + splitResult.get(testPath)?.type, + FileBuildResultType.BUILD_SUCCESS, + "Test root should be recompiled after switching to split mode", + ); + }); }); describe("pre-existing cache entries without output layout fields", () => { From dff3b3da6245f55489438bdc7ea3143d77d37af1 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 11:24:17 -0300 Subject: [PATCH 21/83] Document expected failures after this phase --- SPLIT_TESTS_COMPILATION_SPEC.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/SPLIT_TESTS_COMPILATION_SPEC.md b/SPLIT_TESTS_COMPILATION_SPEC.md index 4a6fc3c21aa..7c6e8f53343 100644 --- a/SPLIT_TESTS_COMPILATION_SPEC.md +++ b/SPLIT_TESTS_COMPILATION_SPEC.md @@ -596,6 +596,9 @@ Rewrite the high-level build task to implement the new unified-mode semantics. - split mode preserves the current unused-file error when explicit files fall only in a disabled scope - other split-mode regressions for current behavior - Run `pnpm test` in `packages/hardhat` +- **Known failures after Phase 4:** 18 tests fail because two callers still use `build({ files, noContracts: true })`, which is now invalid in unified mode. All 18 originate from: + - `solidity-test/task-action.ts` (6 failures) — fixed in Phase 6 + - `CoverageManagerImplementation` (12 failures) — also fixed in Phase 6, since coverage delegates to the solidity-test runner ## Phase 5: Other Built-In Task Callers From 47f513f333cf790d165280cf0109cfee38a6dda4 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 14:18:07 -0300 Subject: [PATCH 22/83] Update the spec to simplify how --no-contracts and --no-tests work with unified compilations --- SPLIT_TESTS_COMPILATION_SPEC.md | 57 ++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/SPLIT_TESTS_COMPILATION_SPEC.md b/SPLIT_TESTS_COMPILATION_SPEC.md index 7c6e8f53343..63f696a2dd5 100644 --- a/SPLIT_TESTS_COMPILATION_SPEC.md +++ b/SPLIT_TESTS_COMPILATION_SPEC.md @@ -164,7 +164,16 @@ This keeps cache hits fast: The high-level build task becomes mode-dependent. -When `splitTestsCompilation === false`: +#### Mode-independent validation + +Before branching on `splitTestsCompilation`, the build task validates that explicit files are compatible with `--no-tests` / `--no-contracts`: + +- If `--no-contracts` is set and any explicit file is a contract, throw `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` +- If `--no-tests` is set and any explicit file is a test, throw `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` + +This validation applies identically in both modes. It uses a new `HardhatError` (`INCOMPATIBLE_FILES_WITH_BUILD_FLAGS`) rather than the old `UNRECOGNIZED_FILES_NOT_COMPILED` or `FILES_WITH_SCOPE_FILTERS_NOT_SUPPORTED` errors. + +#### When `splitTestsCompilation === false` - `build` uses a single compilation pass - the pass runs with `scope: "contracts"` @@ -207,16 +216,25 @@ Behavior by input mode when `splitTestsCompilation === false`: - `onCleanUpArtifacts` does not run - Any stale artifacts or stale build-info files remain, exactly like any other partial build -5. Explicit `files` cannot be combined with `--no-tests` or `--no-contracts` +5. Explicit `files` with `--no-tests` - - If `files.length > 0` and either `--no-tests` or `--no-contracts` is used, the task throws + - Compatible contract files build normally as a partial build; test files in the file list would have been caught by the mode-independent validation above + - This is a partial build (no cleanup) + - The `--no-tests` flag also filters out test roots from the resolved set, so it is meaningful even when explicit files are provided -When `splitTestsCompilation === true`: +6. Explicit `files` with `--no-contracts` + + - Compatible test files build normally as a partial build; contract files in the file list would have been caught by the mode-independent validation above + - This is a partial build (no cleanup) + - The `--no-contracts` flag also filters out contract roots from the resolved set, so it is meaningful even when explicit files are provided + +#### When `splitTestsCompilation === true` - current behavior is preserved - The task builds contracts and tests in two separate passes - `--no-tests` and `--no-contracts` skip a scope exactly as they do today -- Otherwise, explicit `files` are routed to the matching scope with `getScope()` +- explicit `files` are routed to the matching scope with `getScope()` +- explicit `files` can be combined with `--no-tests` or `--no-contracts` when compatible (e.g., contract files with `--no-tests`); incompatible combinations are caught by the mode-independent validation above - Scope-specific cleanup remains unchanged Both modes return: @@ -552,22 +570,29 @@ Rewrite the high-level build task to implement the new unified-mode semantics. ### Changes 1. `packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts` + - Add mode-independent validation before the `splitTestsCompilation` branch: + - if `--no-contracts` and any explicit file is a contract, throw `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` + - if `--no-tests` and any explicit file is a test, throw `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` - Branch on `splitTestsCompilation` - Unified mode: - full build when no `files` and no scope-skipping flags - exact partial build when explicit `files` are provided + - explicit `files` can be combined with `--no-tests` or `--no-contracts` when the files are compatible with the flag; the flag still filters the resolved root set - synthetic partial build of all contracts for `--no-tests`: call `getRootFilePaths({ scope: "contracts" })` to get all roots, filter to contract roots using `getScope()`, and pass them as `rootFilePaths` to `build()` - synthetic partial build of all tests for `--no-contracts`: call `getRootFilePaths({ scope: "contracts" })` to get all roots, filter to test roots using `getScope()`, and pass them as `rootFilePaths` to `build()` - all low-level `solidity.build()` and `solidity.cleanupArtifacts()` calls use `scope: "contracts"`, even when the selected roots are all tests - the task must never call low-level Solidity build-system APIs with `scope: "tests"` in unified mode - - reject `files` combined with `--no-tests` or `--no-contracts` - cleanup runs only for the full unified build - Split mode: - preserve the current two-pass behavior - preserve the current explicit-file routing behavior - - preserve the current unused-file validation after scope routing + - explicit `files` can be combined with `--no-tests` or `--no-contracts` when the files are compatible with the flag; incompatible combinations are caught by the mode-independent validation - Return `{ contractRootPaths, testRootPaths }` from the roots actually built, partitioning them with `getScope()` +2. `packages/hardhat-errors/src/descriptors.ts` + - Replace `FILES_WITH_SCOPE_FILTERS_NOT_SUPPORTED` with `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` (same error number 917) + - `UNRECOGNIZED_FILES_NOT_COMPILED` (915) is no longer used in build.ts (still used by the solidity-test runner, addressed in Phase 6) + ### Validation - Run `pnpm lint` in `packages/hardhat` @@ -580,11 +605,15 @@ Rewrite the high-level build task to implement the new unified-mode semantics. - "includes test artifacts in duplicate-name detection": replace direct `getRootFilePaths` + `build` + `cleanupArtifacts` calls with `hre.tasks.getTask("build").run()` - "passes mixed contract and test artifact paths to onCleanUpArtifacts": replace direct `getRootFilePaths` + `build` + `cleanupArtifacts` calls with `hre.tasks.getTask("build").run()` and use an inline plugin to register an `onCleanUpArtifacts` handler that asserts on the received artifact paths - Add tests for: + - mode-independent: explicit contract files + `--no-contracts` throws `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` + - mode-independent: explicit test files + `--no-tests` throws `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` + - mode-independent: mixed files + `--no-contracts` throws for the contract files + - mode-independent: mixed files + `--no-tests` throws for the test files - unified full build compiles contracts and tests together - unified explicit-file builds compile exactly the provided files - unified explicit-file builds still use low-level `scope: "contracts"` - - unified explicit `files` + `--no-tests` throws - - unified explicit `files` + `--no-contracts` throws + - unified explicit contract files + `--no-tests` succeeds (partial build, contracts only) + - unified explicit test files + `--no-contracts` succeeds (partial build, tests only) - unified `--no-tests` behaves like a partial build over all contracts - unified `--no-contracts` behaves like a partial build over all tests - unified `--no-contracts` still uses low-level `scope: "contracts"` @@ -593,16 +622,14 @@ Rewrite the high-level build task to implement the new unified-mode semantics. - unified mode partitions returned `contractRootPaths` and `testRootPaths` with `getScope()` from the actual roots built - split mode preserves explicit contract-file builds with `--no-tests` - split mode preserves explicit test-file builds with `--no-contracts` - - split mode preserves the current unused-file error when explicit files fall only in a disabled scope + - split mode: explicit test files only (no flags) skips the contracts scope entirely - other split-mode regressions for current behavior - Run `pnpm test` in `packages/hardhat` -- **Known failures after Phase 4:** 18 tests fail because two callers still use `build({ files, noContracts: true })`, which is now invalid in unified mode. All 18 originate from: - - `solidity-test/task-action.ts` (6 failures) — fixed in Phase 6 - - `CoverageManagerImplementation` (12 failures) — also fixed in Phase 6, since coverage delegates to the solidity-test runner +- **Known failures after Phase 4:** 2 tests fail because the solidity-test runner calls `build({ files: testFiles, noContracts: true })` with a file that `getScope()` classifies as a contract (not in the configured test path). The mode-independent validation catches this as an incompatible combination and throws `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` (917), but the tests expect the old `UNRECOGNIZED_FILES_NOT_COMPILED` (915). Both originate from `solidity-test/task-action.ts` and are fixed in Phase 6 when the solidity-test runner is updated. ## Phase 5: Other Built-In Task Callers -Update the built-in tasks that currently call `build({ noTests: true })`. +Update the built-in tasks that currently call `build({ noTests: true })`. While `build({ noTests: true })` is technically valid after Phase 4 (it produces a partial contracts-only build), these callers need a full unified build with cleanup — not a partial build — so they must drop `noTests` in unified mode. ### Changes @@ -633,7 +660,7 @@ Update the built-in tasks that currently call `build({ noTests: true })`. ## Phase 6: Solidity Test Runner -Update the Solidity test runner for unified builds while preserving selected test execution. +Update the Solidity test runner for unified builds while preserving selected test execution. Note: the solidity-test runner currently uses `build({ files, noContracts: true })`, which is valid after Phase 4 (it produces a partial test-only build). However, in unified mode the runner should perform a full build instead, so this change is still needed for the correct full-build semantics. ### Changes From aeeab0961bc97d0daa147dea8da1f9ff8519572c Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 14:26:43 -0300 Subject: [PATCH 23/83] Update build-cleanup-artifacts tests to run full builds --- .../solidity/tasks/build-cleanup-artifacts.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/tasks/build-cleanup-artifacts.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/tasks/build-cleanup-artifacts.ts index f8a5abc9be6..c1ae1072ad2 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/tasks/build-cleanup-artifacts.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/tasks/build-cleanup-artifacts.ts @@ -89,7 +89,7 @@ contract AddedByHook {}`, // Run full build await hre.tasks.getTask("build").run({ force: true, - noTests: true, + quiet: true, }); @@ -145,7 +145,7 @@ contract Filter {}`, await hreNoFilter.tasks.getTask("build").run({ force: true, - noTests: true, + quiet: true, }); @@ -212,7 +212,7 @@ contract Filter {}`, // Run full build with filtering await hreWithFilter.tasks.getTask("build").run({ force: true, - noTests: true, + quiet: true, }); @@ -260,7 +260,7 @@ contract Third {}`, // Run full build await hre.tasks.getTask("build").run({ force: true, - noTests: true, + quiet: true, }); @@ -306,7 +306,7 @@ contract Two {}`, await hre.tasks.getTask("build").run({ force: true, - noTests: true, + quiet: true, }); @@ -326,7 +326,7 @@ contract Two {}`, // Run partial build with only One.sol await hre.tasks.getTask("build").run({ force: true, - noTests: true, + quiet: true, files: [path.join(project.path, "contracts/One.sol")], }); @@ -370,7 +370,7 @@ contract ToDelete {}`, await hre.tasks.getTask("build").run({ force: true, - noTests: true, + quiet: true, }); @@ -401,7 +401,7 @@ contract ToDelete {}`, // Rebuild await hre.tasks.getTask("build").run({ force: true, - noTests: true, + quiet: true, }); From fe0400a844314c40ea2676cb655e6c597e78a005 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 14:26:59 -0300 Subject: [PATCH 24/83] Update the build task --- .../builtin-plugins/solidity/tasks/build.ts | 423 +++++++++++++++--- 1 file changed, 350 insertions(+), 73 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts index cb437111b77..52465ad40e2 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts @@ -1,8 +1,13 @@ -import type { HardhatRuntimeEnvironment } from "../../../../types/hre.js"; -import type { BuildScope } from "../../../../types/solidity.js"; +import type { + BuildScope, + SolidityBuildSystem, +} from "../../../../types/solidity.js"; import type { NewTaskActionFunction } from "../../../../types/tasks.js"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { + assertHardhatInvariant, + HardhatError, +} from "@nomicfoundation/hardhat-errors"; import { resolveFromRoot } from "@nomicfoundation/hardhat-utils/path"; import { throwIfSolidityBuildFailed } from "../build-results.js"; @@ -17,110 +22,382 @@ interface BuildActionArguments { noContracts: boolean; } +interface BuildActionResult { + contractRootPaths: string[]; + testRootPaths: string[]; +} + const buildAction: NewTaskActionFunction = async ( args: BuildActionArguments, hre, -) => { - let contractRootPaths: string[] = []; - let testRootPaths: string[] = []; - const allUsedFiles: string[] = []; - - if (args.noContracts === false) { - const { rootPaths, usedFiles } = await buildForScope( - "contracts", - args, - hre, +): Promise => { + const buildProfile = + hre.globalOptions.buildProfile ?? args.defaultBuildProfile; + + const files = normalizedRootPaths(args.files); + + const partitionedFiles = await partitionRootPathsByScope(hre.solidity, files); + + if (args.noContracts && partitionedFiles.contractRootPaths.length > 0) { + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY.INCOMPATIBLE_FILES_WITH_BUILD_FLAGS, + { + files: partitionedFiles.contractRootPaths + .sort() + .map((f) => `- ${f}`) + .join("\n"), + }, ); + } - contractRootPaths = rootPaths; - allUsedFiles.push(...usedFiles); + if (args.noTests && partitionedFiles.testRootPaths.length > 0) { + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY.INCOMPATIBLE_FILES_WITH_BUILD_FLAGS, + { + files: partitionedFiles.testRootPaths + .sort() + .map((f) => `- ${f}`) + .join("\n"), + }, + ); } - if (args.noTests === false) { - const { rootPaths, usedFiles } = await buildForScope("tests", args, hre); + if (hre.config.solidity.splitTestsCompilation) { + const contractRootPaths = []; + const testRootPaths = []; - testRootPaths = rootPaths; - allUsedFiles.push(...usedFiles); - } + const shouldBuildContracts = + !args.noContracts && + (files.length === 0 || partitionedFiles.contractRootPaths.length > 0); - // If there's an unused file we fail - if (args.files.length !== 0) { - const files = new Set(args.files); - const usedFiles = new Set(allUsedFiles); - const unusedFiles = files.difference(usedFiles); - - if (unusedFiles.size > 0) { - const list = [...unusedFiles] - .sort() - .map((f) => `- ${f}`) - .join("\n"); - - throw new HardhatError( - HardhatError.ERRORS.CORE.SOLIDITY.UNRECOGNIZED_FILES_NOT_COMPILED, - { files: list }, + if (shouldBuildContracts) { + const contractBuildResults = await runSolidityBuild({ + buildProfile, + files: partitionedFiles.contractRootPaths, + force: args.force, + isUnifiedModeOrScope: "contracts", + noContracts: args.noContracts, + noTests: args.noTests, + quiet: args.quiet, + solidity: hre.solidity, + }); + + assertHardhatInvariant( + contractBuildResults.testRootPaths.length === 0, + "The contracts scope should build no test in split test compilation mode", ); + + contractRootPaths.push(...contractBuildResults.contractRootPaths); } + + const shouldBuildTests = + !args.noTests && + (files.length === 0 || partitionedFiles.testRootPaths.length > 0); + + if (shouldBuildTests) { + const testBuildResults = await runSolidityBuild({ + buildProfile, + files: partitionedFiles.testRootPaths, + force: args.force, + isUnifiedModeOrScope: "tests", + noContracts: args.noContracts, + noTests: args.noTests, + quiet: args.quiet, + solidity: hre.solidity, + }); + + assertHardhatInvariant( + testBuildResults.contractRootPaths.length === 0, + "The tests scope should build no contract in split test compilation mode", + ); + + testRootPaths.push(...testBuildResults.testRootPaths); + } + + return { contractRootPaths, testRootPaths }; } - return { contractRootPaths, testRootPaths }; + return runSolidityBuild({ + buildProfile, + files, + force: args.force, + isUnifiedModeOrScope: true, + noContracts: args.noContracts, + noTests: args.noTests, + quiet: args.quiet, + solidity: hre.solidity, + }); }; -async function buildForScope( - scope: BuildScope, - { force, files, quiet, defaultBuildProfile }: BuildActionArguments, - { solidity, globalOptions }: HardhatRuntimeEnvironment, -) { - const usedFiles = []; +/** + * Runs a solidity build for a scope/unified mode. + * + * Note: The files array should be pre-classified by scope if using split + * compilation. i.e. it should only include files of the scope being used. + */ +async function runSolidityBuild({ + buildProfile, + files, + force, + isUnifiedModeOrScope, + noContracts, + noTests, + quiet, + solidity, +}: { + buildProfile: string; + files: string[]; + force: boolean; + isUnifiedModeOrScope: true | BuildScope; + noContracts: boolean; + noTests: boolean; + quiet: boolean; + solidity: SolidityBuildSystem; +}): Promise<{ contractRootPaths: string[]; testRootPaths: string[] }> { + const scope = + isUnifiedModeOrScope === true ? "contracts" : isUnifiedModeOrScope; + + const { isFullBuild, contractRootPaths, testRootPaths } = + await getRootsToBuild({ + solidity, + isUnifiedModeOrScope, + files, + noTests, + noContracts, + }); + + // If there's nothing to build and this isn't a full build, we exit early. + // Full builds with no roots still need to run cleanup to remove stale + // artifacts. + if ( + !isFullBuild && + contractRootPaths.length === 0 && + testRootPaths.length === 0 + ) { + return { contractRootPaths, testRootPaths }; + } + + const results = await solidity.build( + [...contractRootPaths, ...testRootPaths], + { + force, + buildProfile, + quiet, + scope, + }, + ); + + throwIfSolidityBuildFailed(solidity, results); + + if (isFullBuild) { + // We use the result keys in case a hook added more root files + const builtRootPaths = [...results.keys()]; + await solidity.cleanupArtifacts(builtRootPaths, { + scope, + }); + } + + return { contractRootPaths, testRootPaths }; +} + +/** + * Returns the files to build, classified by testRootPaths and + * contractRootPaths, and a boolean indicating if this represents a full build + * for the scope/unified build. + * + * Note: The files array should be pre-classified by scope if using split + * compilation. i.e. it should only include files of the scope being used. + */ +async function getRootsToBuild({ + solidity, + isUnifiedModeOrScope, + files, + noTests, + noContracts, +}: { + solidity: SolidityBuildSystem; + isUnifiedModeOrScope: true | BuildScope; + files: string[]; + noTests: boolean; + noContracts: boolean; +}): Promise<{ + testRootPaths: string[]; + contractRootPaths: string[]; + isFullBuild: boolean; +}> { + if (isUnifiedModeOrScope === true) { + return getRootsToBuildInUnifiedMode({ + files, + noContracts, + noTests, + solidity, + }); + } + + return getRootsToBuildForScope({ + files, + scope: isUnifiedModeOrScope, + solidity, + }); +} - // If no specific files are passed, it means a full compilation, i.e. all source files - const isFullCompilation = files.length === 0; +/** + * Returns the root files to build in unified mode. While they are returned + * classified as contractRootPaths and testRootPaths, they are expected to be + * build together. It also returns a boolean indicating if this represents a + * full unified build. + * + * Note: The files array should be normalized already. + * @returns + */ +async function getRootsToBuildInUnifiedMode({ + files, + noContracts, + noTests, + solidity, +}: { + files: string[]; + noContracts: boolean; + noTests: boolean; + solidity: SolidityBuildSystem; +}): Promise<{ + testRootPaths: string[]; + contractRootPaths: string[]; + isFullBuild: boolean; +}> { + const isFullBuild = files.length === 0 && !noTests && !noContracts; - const rootPaths = []; + let rootFilePaths: string[]; - if (isFullCompilation) { - rootPaths.push(...(await solidity.getRootFilePaths({ scope }))); + if (isFullBuild) { + // In this mode, "contracts" also returns the tests + rootFilePaths = await solidity.getRootFilePaths({ + scope: "contracts", + }); } else { - for (const file of files) { - if (isNpmRootPath(file)) { - rootPaths.push(file); + const allRoots = + files.length > 0 + ? files + : await solidity.getRootFilePaths({ + scope: "contracts", + }); + + rootFilePaths = []; + for (const root of allRoots) { + if (isNpmRootPath(root)) { + // npm files are considered contract files, so we skip them if + // --no-contracts + if (!noContracts) { + rootFilePaths.push(root); + } + + continue; } - const rootPath = resolveFromRoot(process.cwd(), file); + const scope = await solidity.getScope(root); - if ((await solidity.getScope(rootPath)) !== scope) { + if (noTests && scope === "tests") { continue; } - usedFiles.push(file); - rootPaths.push(rootPath); - } + if (noContracts && scope === "contracts") { + continue; + } - // If a file list has been passed but none match this scope, we don't run the build - if (rootPaths.length === 0) { - return { rootPaths, usedFiles }; + rootFilePaths.push(root); } } - const buildProfile = globalOptions.buildProfile ?? defaultBuildProfile; + const partitionedRootPaths = await partitionRootPathsByScope( + solidity, + rootFilePaths, + ); - const results = await solidity.build(rootPaths, { - force, - buildProfile, - quiet, - scope, - }); + return { + isFullBuild, + ...partitionedRootPaths, + }; +} - throwIfSolidityBuildFailed(solidity, results); +/** + * Returns the root files to build for a certain scope, and a boolean indicating + * if it's a full build for that scope. + * + * Note: The files array should be pre-classified by scope if using split + * compilation. i.e. it should only include files of the scope being used. + * + * Note: One of the returned arrays is always empty, depending on the scope + * being used. + */ +async function getRootsToBuildForScope({ + files, + scope, + solidity, +}: { + files: string[]; + scope: BuildScope; + solidity: SolidityBuildSystem; +}): Promise<{ + isFullBuild: boolean; + contractRootPaths: string[]; + testRootPaths: string[]; +}> { + const isFullBuild = files.length === 0; - // If we recompiled the entire project we cleanup the artifacts - if (isFullCompilation) { - // Use the root files from the build results, which may include - // additional files added by plugins hooking into solidity#build - const builtRootPaths = [...results.keys()]; - await solidity.cleanupArtifacts(builtRootPaths, { scope }); + const rootPaths = isFullBuild + ? await solidity.getRootFilePaths({ scope }) + : files; // This is safe because the files have already been partitioned by scope + + if (scope === "contracts") { + return { isFullBuild, contractRootPaths: rootPaths, testRootPaths: [] }; } - return { rootPaths, usedFiles }; + return { isFullBuild, contractRootPaths: [], testRootPaths: rootPaths }; +} + +/** + * Partitions root paths by scope, as returned by `solidity.getScope(rootPath)`. + */ +async function partitionRootPathsByScope( + solidity: SolidityBuildSystem, + rootPaths: string[], +): Promise<{ contractRootPaths: string[]; testRootPaths: string[] }> { + const contractRootPaths: string[] = []; + const testRootPaths: string[] = []; + + for (const rootPath of rootPaths) { + if (isNpmRootPath(rootPath)) { + contractRootPaths.push(rootPath); + continue; + } + + const scope = await solidity.getScope(rootPath); + if (scope === "tests") { + testRootPaths.push(rootPath); + } else { + contractRootPaths.push(rootPath); + } + } + + return { contractRootPaths, testRootPaths }; +} + +/** + * Normalizes the received root paths. + * + * If a file is an npm root path or absolute file path, it's returned as is. + * If it's a relative path it's resolved from the CWD. + */ +function normalizedRootPaths(files: string[]): string[] { + const normalizedPaths = files.map((f) => { + if (isNpmRootPath(f)) { + return f; + } + + return resolveFromRoot(process.cwd(), f); + }); + + return normalizedPaths; } export default buildAction; From 4c56c9681c81a6b238709ece66a1b693a4bbb349 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 14:50:58 -0300 Subject: [PATCH 25/83] Adds SolidityBuildSystem tests for splitTestCompilation:false behavior --- .../integration/unified-build-system.ts | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/unified-build-system.ts diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/unified-build-system.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/unified-build-system.ts new file mode 100644 index 00000000000..faf1642bcbc --- /dev/null +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/unified-build-system.ts @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { assertRejectsWithHardhatError } from "@nomicfoundation/hardhat-test-utils"; +import { exists } from "@nomicfoundation/hardhat-utils/fs"; + +import { useTestProjectTemplate } from "../resolver/helpers.js"; + +const basicProjectTemplate = { + name: "test", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +contract Foo { + uint256 x; +}`, + "contracts/Foo.t.sol": `// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "./Foo.sol"; + +contract FooTest { + Foo foo; + + constructor() { + foo = new Foo(); + } + + function test_Assertion() public view { + assert(address(foo) != address(0)); + } +}`, + "test/OtherFooTest.sol": `// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "../contracts/Foo.sol"; + +contract OtherFooTest { + Foo foo; + + constructor() { + foo = new Foo(); + } + + function test_Assertion() public view { + assert(address(foo) != address(0)); + } +}`, + }, +}; + +const unifiedTestsCompilationConfig = { + solidity: { + version: "0.8.28", + splitTestsCompilation: false, + }, +}; + +describe("build system - splitTestsCompilation: false - build system API", function () { + describe("getRootFilePaths", function () { + it("returns contract, test, and npm roots for scope 'contracts'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + const roots = await hre.solidity.getRootFilePaths({ + scope: "contracts", + }); + + // Should contain the contract file + assert.ok( + roots.some((r) => r.endsWith("Foo.sol") && !r.endsWith(".t.sol")), + "Expected contract root Foo.sol in unified roots", + ); + // Should contain the .t.sol test file + assert.ok( + roots.some((r) => r.endsWith("Foo.t.sol")), + "Expected test root Foo.t.sol in unified roots", + ); + // Should contain the test directory test file + assert.ok( + roots.some((r) => r.endsWith("OtherFooTest.sol")), + "Expected test root OtherFooTest.sol in unified roots", + ); + }); + + it("throws for scope 'tests'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await assertRejectsWithHardhatError( + hre.solidity.getRootFilePaths({ scope: "tests" }), + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + {}, + ); + }); + }); + + describe("getArtifactsDirectory", function () { + it("returns the main artifacts dir for scope 'tests'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + const contractsDir = + await hre.solidity.getArtifactsDirectory("contracts"); + const testsDir = await hre.solidity.getArtifactsDirectory("tests"); + + assert.equal(contractsDir, testsDir); + }); + }); + + describe("low-level scope:'tests' rejection", function () { + it("build() throws for scope 'tests'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await assertRejectsWithHardhatError( + hre.solidity.build([], { scope: "tests" }), + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + {}, + ); + }); + + it("getCompilationJobs() throws for scope 'tests'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await assertRejectsWithHardhatError( + hre.solidity.getCompilationJobs([], { scope: "tests" }), + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + {}, + ); + }); + + it("emitArtifacts() throws for scope 'tests'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + // We need a real compilation job to call emitArtifacts. + // Build first so we can get a compilation job. + const roots = await hre.solidity.getRootFilePaths({ + scope: "contracts", + }); + const contractRoots = roots.filter( + (r) => !r.endsWith(".t.sol") && !r.includes("/test/"), + ); + const result = await hre.solidity.getCompilationJobs(contractRoots, { + scope: "contracts", + }); + + assert.ok(result.success, "Expected compilation jobs to succeed"); + + const firstJob = [...result.compilationJobsPerFile.values()][0]; + const runResult = await hre.solidity.runCompilationJob(firstJob); + + await assertRejectsWithHardhatError( + hre.solidity.emitArtifacts(firstJob, runResult.output, { + scope: "tests", + }), + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + {}, + ); + }); + + it("cleanupArtifacts() throws for scope 'tests'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await assertRejectsWithHardhatError( + hre.solidity.cleanupArtifacts([], { scope: "tests" }), + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + {}, + ); + }); + }); + + describe("emitArtifacts - type declarations", function () { + it("skips per-source artifacts.d.ts for test roots in unified contracts-scope builds", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await hre.tasks.getTask("build").run(); + + const artifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + + // Contract root should have artifacts.d.ts + assert.equal( + await exists( + path.join(artifactsPath, "contracts", "Foo.sol", "artifacts.d.ts"), + ), + true, + ); + + // Test roots should NOT have artifacts.d.ts + assert.equal( + await exists( + path.join(artifactsPath, "contracts", "Foo.t.sol", "artifacts.d.ts"), + ), + false, + ); + assert.equal( + await exists( + path.join( + artifactsPath, + "test", + "OtherFooTest.sol", + "artifacts.d.ts", + ), + ), + false, + ); + }); + }); +}); From 42170c7212fb8d154032d3247f09aa8aaeaea88c Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 14:51:21 -0300 Subject: [PATCH 26/83] Add cleanup artifacts tests for unified builds --- .../solidity/tasks/build-cleanup-artifacts.ts | 147 +++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/tasks/build-cleanup-artifacts.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/tasks/build-cleanup-artifacts.ts index c1ae1072ad2..ff32f6eabcc 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/tasks/build-cleanup-artifacts.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/tasks/build-cleanup-artifacts.ts @@ -13,7 +13,11 @@ import assert from "node:assert/strict"; import path from "node:path"; import { describe, it } from "node:test"; -import { exists, remove } from "@nomicfoundation/hardhat-utils/fs"; +import { + exists, + readUtf8File, + remove, +} from "@nomicfoundation/hardhat-utils/fs"; import { createHardhatRuntimeEnvironment } from "../../../../../src/hre.js"; import { useTestProjectTemplate } from "../build-system/resolver/helpers.js"; @@ -418,3 +422,144 @@ contract ToDelete {}`, }); }); }); + +const unifiedCleanupProjectTemplate = { + name: "test", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +contract Foo { + uint256 x; +}`, + "contracts/Foo.t.sol": `// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "./Foo.sol"; + +contract FooTest { + Foo foo; + + constructor() { + foo = new Foo(); + } + + function test_Assertion() public view { + assert(address(foo) != address(0)); + } +}`, + "test/OtherFooTest.sol": `// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "../contracts/Foo.sol"; + +contract OtherFooTest { + Foo foo; + + constructor() { + foo = new Foo(); + } + + function test_Assertion() public view { + assert(address(foo) != address(0)); + } +}`, + }, +}; + +const unifiedTestsCompilationConfig = { + solidity: { + version: "0.8.28", + splitTestsCompilation: false, + }, +}; + +describe("build task - unified mode cleanup", () => { + it("includes test artifacts in duplicate-name detection", async () => { + const duplicateNameTemplate = { + name: "test", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.0;\ncontract Foo {}`, + "test/Foo.sol": `// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.0;\ncontract Foo {}`, + }, + }; + + await using project = await useTestProjectTemplate(duplicateNameTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await hre.tasks.getTask("build").run(); + + const artifactsPath = await hre.solidity.getArtifactsDirectory("contracts"); + + // The top-level artifacts.d.ts should exist and contain the duplicate + const topLevelDts = path.join(artifactsPath, "artifacts.d.ts"); + assert.equal(await exists(topLevelDts), true); + const dtsContent = await readUtf8File(topLevelDts); + assert.ok( + dtsContent.includes('"Foo"'), + "Expected top-level artifacts.d.ts to include the duplicated contract name Foo from both test and contract artifacts", + ); + }); + + it("passes mixed contract and test artifact paths to onCleanUpArtifacts", async () => { + await using project = await useTestProjectTemplate( + unifiedCleanupProjectTemplate, + ); + + const receivedArtifactPaths: string[] = []; + + const hookCapturingPlugin: HardhatPlugin = { + id: "test-capture-cleanup-hook", + hookHandlers: { + solidity: async () => ({ + default: async () => { + const handlers: Partial = { + onCleanUpArtifacts: async ( + _context: HookContext, + artifactPaths: string[], + next: ( + nextContext: HookContext, + nextArtifactPaths: string[], + ) => Promise, + ) => { + receivedArtifactPaths.push(...artifactPaths); + return next(_context, artifactPaths); + }, + }; + + return handlers; + }, + }), + }, + }; + + const hre = await createHardhatRuntimeEnvironment( + { + plugins: [hookCapturingPlugin], + ...unifiedTestsCompilationConfig, + }, + {}, + project.path, + ); + + await hre.tasks.getTask("build").run(); + + // Should include both contract and test artifacts + assert.ok( + receivedArtifactPaths.some( + (p) => p.includes("Foo.sol") && !p.includes(".t.sol"), + ), + "Expected contract artifact path in onCleanUpArtifacts", + ); + assert.ok( + receivedArtifactPaths.some((p) => p.includes("Foo.t.sol")), + "Expected test artifact path (Foo.t.sol) in onCleanUpArtifacts", + ); + assert.ok( + receivedArtifactPaths.some((p) => p.includes("OtherFooTest.sol")), + "Expected test artifact path (OtherFooTest.sol) in onCleanUpArtifacts", + ); + }); +}); From 6a6bd2edaa35712090965b1f44306ea0c7a03a61 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 14:51:53 -0300 Subject: [PATCH 27/83] Add tests of the build task when using unified builds and update the split build ones --- .../build-system/integration/build-scopes.ts | 618 +++++++++++------- 1 file changed, 371 insertions(+), 247 deletions(-) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts index 3ba47b1af16..320effc74b0 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts @@ -6,9 +6,7 @@ import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { assertRejectsWithHardhatError } from "@nomicfoundation/hardhat-test-utils"; import { exists, - getAllFilesMatching, readJsonFile, - readUtf8File, writeUtf8File, } from "@nomicfoundation/hardhat-utils/fs"; @@ -411,88 +409,169 @@ describe("build system - build task - behavior on build scope", function () { assert.equal(await exists(testBuildInfoPath), false); assert.equal(await exists(testArtifactPath), false); }); + }); - describe("When user provided files' scopes can't be recognized", async () => { - it("Should throw if a test file isn't recognized", async () => { - await using project = - await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(solidityCompilationConfig); + describe("explicit files with compatible scope flags", function () { + it("builds contract files with --no-tests", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(solidityCompilationConfig); + process.chdir(project.path); - const previousCwd = process.cwd(); - process.chdir(project.path); + await hre.tasks + .getTask("build") + .run({ files: ["contracts/Foo.sol"], noTests: true }); - try { - await assertRejectsWithHardhatError( - hre.tasks - .getTask("build") - .run({ noTests: true, files: ["contracts/Foo.t.sol"] }), - HardhatError.ERRORS.CORE.SOLIDITY.UNRECOGNIZED_FILES_NOT_COMPILED, - { files: "- contracts/Foo.t.sol" }, - ); - - await assertRejectsWithHardhatError( - hre.tasks - .getTask("build") - .run({ noTests: true, files: ["test/OtherFooTest.sol"] }), - HardhatError.ERRORS.CORE.SOLIDITY.UNRECOGNIZED_FILES_NOT_COMPILED, - { files: "- test/OtherFooTest.sol" }, - ); - } catch { - process.chdir(previousCwd); - } - }); + const contractsArtifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + await readJsonFile( + path.join(contractsArtifactsPath, "contracts", "Foo.sol", "Foo.json"), + ); + }); - it("Should throw if a contract isn't recognized", async () => { - await using project = - await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(solidityCompilationConfig); + it("builds test files with --no-contracts", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(solidityCompilationConfig); + process.chdir(project.path); - const previousCwd = process.cwd(); - process.chdir(project.path); + await hre.tasks + .getTask("build") + .run({ files: ["contracts/Foo.t.sol"], noContracts: true }); - try { - await assertRejectsWithHardhatError( - hre.tasks - .getTask("build") - .run({ noContracts: true, files: ["contracts/Foo.sol"] }), - HardhatError.ERRORS.CORE.SOLIDITY.UNRECOGNIZED_FILES_NOT_COMPILED, - { files: "- contracts/Foo.sol" }, - ); - } catch { - process.chdir(previousCwd); - } - }); + const testsArtifactsPath = + await hre.solidity.getArtifactsDirectory("tests"); + await readJsonFile( + path.join(testsArtifactsPath, "contracts", "Foo.t.sol", "FooTest.json"), + ); + }); + }); - it("Should throw if neither is recognized", async () => { - await using project = - await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(solidityCompilationConfig); + describe("explicit files in one scope without flags", function () { + it("skips the contracts scope when only test files are passed", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(solidityCompilationConfig); + process.chdir(project.path); - const previousCwd = process.cwd(); - process.chdir(project.path); + await hre.tasks + .getTask("build") + .run({ files: ["test/OtherFooTest.sol"] }); - try { - await assertRejectsWithHardhatError( - hre.tasks.getTask("build").run({ - noContracts: true, - noTests: true, - files: ["contracts/Foo.sol", "contracts/Foo.t.sol"], - }), - HardhatError.ERRORS.CORE.SOLIDITY.UNRECOGNIZED_FILES_NOT_COMPILED, - { - files: `- contracts/Foo.sol -- contracts/Foo.t.sol`, - }, - ); - } catch { - process.chdir(previousCwd); - } - }); + const testsArtifactsPath = + await hre.solidity.getArtifactsDirectory("tests"); + + // Test artifact should exist + await readJsonFile( + path.join( + testsArtifactsPath, + "test", + "OtherFooTest.sol", + "OtherFooTest.json", + ), + ); + + // Contract artifacts should NOT exist (contracts scope was skipped) + const contractsArtifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + assert.equal( + await exists( + path.join(contractsArtifactsPath, "contracts", "Foo.sol", "Foo.json"), + ), + false, + "Contract artifact should not exist when only test files were passed", + ); }); }); }); -describe("build system - splitTestsCompilation: false", function () { +describe("build system - mode-independent file+flag validation", function () { + // These tests use the default config (splitTestsCompilation: false) + // because the validation applies identically in both modes. + it("throws when test files are passed with --no-tests", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(); + process.chdir(project.path); + + await assertRejectsWithHardhatError( + hre.tasks + .getTask("build") + .run({ noTests: true, files: ["test/OtherFooTest.sol"] }), + HardhatError.ERRORS.CORE.SOLIDITY.INCOMPATIBLE_FILES_WITH_BUILD_FLAGS, + { + files: `- ${path.resolve(project.path, "test/OtherFooTest.sol")}`, + }, + ); + }); + + it("throws when contract files are passed with --no-contracts", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(); + process.chdir(project.path); + + await assertRejectsWithHardhatError( + hre.tasks + .getTask("build") + .run({ noContracts: true, files: ["contracts/Foo.sol"] }), + HardhatError.ERRORS.CORE.SOLIDITY.INCOMPATIBLE_FILES_WITH_BUILD_FLAGS, + { + files: `- ${path.resolve(project.path, "contracts/Foo.sol")}`, + }, + ); + }); + + it("throws for the test file when mixed files are passed with --no-tests", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(); + process.chdir(project.path); + + await assertRejectsWithHardhatError( + hre.tasks.getTask("build").run({ + noTests: true, + files: ["contracts/Foo.sol", "test/OtherFooTest.sol"], + }), + HardhatError.ERRORS.CORE.SOLIDITY.INCOMPATIBLE_FILES_WITH_BUILD_FLAGS, + { + files: `- ${path.resolve(project.path, "test/OtherFooTest.sol")}`, + }, + ); + }); + + it("throws for the contract file when mixed files are passed with --no-contracts", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(); + process.chdir(project.path); + + await assertRejectsWithHardhatError( + hre.tasks.getTask("build").run({ + noContracts: true, + files: ["contracts/Foo.sol", "test/OtherFooTest.sol"], + }), + HardhatError.ERRORS.CORE.SOLIDITY.INCOMPATIBLE_FILES_WITH_BUILD_FLAGS, + { + files: `- ${path.resolve(project.path, "contracts/Foo.sol")}`, + }, + ); + }); + + it("throws for the first conflict when both flags are set", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(); + process.chdir(project.path); + + // noContracts is checked first, so only the contract file appears in the error + await assertRejectsWithHardhatError( + hre.tasks.getTask("build").run({ + noContracts: true, + noTests: true, + files: ["contracts/Foo.sol", "contracts/Foo.t.sol"], + }), + HardhatError.ERRORS.CORE.SOLIDITY.INCOMPATIBLE_FILES_WITH_BUILD_FLAGS, + { + files: `- ${path.resolve(project.path, "contracts/Foo.sol")}`, + }, + ); + }); +}); + +describe("build system - splitTestsCompilation: false - build task", function () { const unifiedTestsCompilationConfig = { solidity: { version: "0.8.28", @@ -500,271 +579,316 @@ describe("build system - splitTestsCompilation: false", function () { }, }; - describe("getRootFilePaths", function () { - it("returns contract, test, and npm roots for scope 'contracts'", async () => { + describe("full build", function () { + it("compiles contracts and tests together", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); const hre = await project.getHRE(unifiedTestsCompilationConfig); - const roots = await hre.solidity.getRootFilePaths({ - scope: "contracts", - }); + await hre.tasks.getTask("build").run(); - // Should contain the contract file - assert.ok( - roots.some((r) => r.endsWith("Foo.sol") && !r.endsWith(".t.sol")), - "Expected contract root Foo.sol in unified roots", + const artifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + + // Contract artifact + await readJsonFile( + path.join(artifactsPath, "contracts", "Foo.sol", "Foo.json"), ); - // Should contain the .t.sol test file - assert.ok( - roots.some((r) => r.endsWith("Foo.t.sol")), - "Expected test root Foo.t.sol in unified roots", + // Test artifacts in main artifacts dir + await readJsonFile( + path.join(artifactsPath, "contracts", "Foo.t.sol", "FooTest.json"), ); - // Should contain the test directory test file - assert.ok( - roots.some((r) => r.endsWith("OtherFooTest.sol")), - "Expected test root OtherFooTest.sol in unified roots", + await readJsonFile( + path.join( + artifactsPath, + "test", + "OtherFooTest.sol", + "OtherFooTest.json", + ), ); }); - it("throws for scope 'tests'", async () => { + it("runs cleanup on the main artifacts directory", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); const hre = await project.getHRE(unifiedTestsCompilationConfig); - await assertRejectsWithHardhatError( - hre.solidity.getRootFilePaths({ scope: "tests" }), - HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, - {}, + const artifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + + // Create a stale artifact + const staleArtifactPath = path.join( + artifactsPath, + "contracts", + "Stale.sol", + "Stale.json", ); + await writeUtf8File(staleArtifactPath, ""); + assert.equal(await exists(staleArtifactPath), true); + + await hre.tasks.getTask("build").run(); + + // Stale artifact should be cleaned up + assert.equal(await exists(staleArtifactPath), false); }); - }); - describe("getArtifactsDirectory", function () { - it("returns the main artifacts dir for scope 'tests'", async () => { + it("partitions returned contractRootPaths and testRootPaths with getScope()", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); const hre = await project.getHRE(unifiedTestsCompilationConfig); - const contractsDir = - await hre.solidity.getArtifactsDirectory("contracts"); - const testsDir = await hre.solidity.getArtifactsDirectory("tests"); + const result: { + contractRootPaths: string[]; + testRootPaths: string[]; + } = await hre.tasks.getTask("build").run(); - assert.equal(contractsDir, testsDir); + assert.ok( + result.contractRootPaths.some( + (r) => r.endsWith("Foo.sol") && !r.endsWith(".t.sol"), + ), + "Expected Foo.sol in contractRootPaths", + ); + assert.ok( + result.testRootPaths.some((r) => r.endsWith("Foo.t.sol")), + "Expected Foo.t.sol in testRootPaths", + ); + assert.ok( + result.testRootPaths.some((r) => r.endsWith("OtherFooTest.sol")), + "Expected OtherFooTest.sol in testRootPaths", + ); }); }); - describe("low-level scope:'tests' rejection", function () { - it("build() throws for scope 'tests'", async () => { + describe("explicit files", function () { + it("compiles exactly the provided files", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); const hre = await project.getHRE(unifiedTestsCompilationConfig); + process.chdir(project.path); + + await hre.tasks.getTask("build").run({ files: ["contracts/Foo.sol"] }); - await assertRejectsWithHardhatError( - hre.solidity.build([], { scope: "tests" }), - HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, - {}, + const artifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + + // Only Foo.sol should have been compiled + await readJsonFile( + path.join(artifactsPath, "contracts", "Foo.sol", "Foo.json"), ); }); - it("getCompilationJobs() throws for scope 'tests'", async () => { + it("uses the main artifacts dir for explicit files", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); const hre = await project.getHRE(unifiedTestsCompilationConfig); + process.chdir(project.path); + + // Compile a test file explicitly — it should still go through + // scope: "contracts" at the low level + await hre.tasks + .getTask("build") + .run({ files: ["test/OtherFooTest.sol"] }); + + const artifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); - await assertRejectsWithHardhatError( - hre.solidity.getCompilationJobs([], { scope: "tests" }), - HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, - {}, + // The test artifact should be in the main artifacts directory + await readJsonFile( + path.join( + artifactsPath, + "test", + "OtherFooTest.sol", + "OtherFooTest.json", + ), ); }); - it("emitArtifacts() throws for scope 'tests'", async () => { + it("does not run cleanup for explicit-file builds", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); const hre = await project.getHRE(unifiedTestsCompilationConfig); - // We need a real compilation job to call emitArtifacts. - // Build first so we can get a compilation job. - const roots = await hre.solidity.getRootFilePaths({ - scope: "contracts", - }); - const contractRoots = roots.filter( - (r) => - !r.endsWith(".t.sol") && !r.includes(path.sep + "test" + path.sep), + // First do a full build + await hre.tasks.getTask("build").run(); + + const artifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + + // Create a stale artifact + const staleArtifactPath = path.join( + artifactsPath, + "contracts", + "Stale.sol", + "Stale.json", ); - const result = await hre.solidity.getCompilationJobs(contractRoots, { - scope: "contracts", - }); + await writeUtf8File(staleArtifactPath, ""); - assert.ok(result.success, "Expected compilation jobs to succeed"); + process.chdir(project.path); - const firstJob = [...result.compilationJobsPerFile.values()][0]; - const runResult = await hre.solidity.runCompilationJob(firstJob); + // Partial build with explicit files + await hre.tasks.getTask("build").run({ files: ["contracts/Foo.sol"] }); - await assertRejectsWithHardhatError( - hre.solidity.emitArtifacts(firstJob, runResult.output, { - scope: "tests", - }), - HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, - {}, - ); + // Stale artifact should NOT be cleaned up + assert.equal(await exists(staleArtifactPath), true); }); - it("cleanupArtifacts() throws for scope 'tests'", async () => { + it("builds contract files with --no-tests", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); const hre = await project.getHRE(unifiedTestsCompilationConfig); + process.chdir(project.path); + + await hre.tasks + .getTask("build") + .run({ files: ["contracts/Foo.sol"], noTests: true }); - await assertRejectsWithHardhatError( - hre.solidity.cleanupArtifacts([], { scope: "tests" }), - HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, - {}, + const artifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + + // Contract artifact should exist + await readJsonFile( + path.join(artifactsPath, "contracts", "Foo.sol", "Foo.json"), ); }); - }); - describe("emitArtifacts - type declarations", function () { - it("skips per-source artifacts.d.ts for test roots in unified contracts-scope builds", async () => { + it("builds test files with --no-contracts", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); const hre = await project.getHRE(unifiedTestsCompilationConfig); + process.chdir(project.path); - // Build directly using the build-system APIs (the build task is - // not updated until Phase 4). - const roots = await hre.solidity.getRootFilePaths({ - scope: "contracts", - }); - const buildResult = await hre.solidity.build(roots, { - scope: "contracts", - }); + await hre.tasks + .getTask("build") + .run({ files: ["contracts/Foo.t.sol"], noContracts: true }); - assert.ok( - hre.solidity.isSuccessfulBuildResult(buildResult), - "Expected build to succeed", + const artifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + + // Test artifact should be in the main artifacts directory + await readJsonFile( + path.join(artifactsPath, "contracts", "Foo.t.sol", "FooTest.json"), ); + }); + }); + + describe("--no-tests", function () { + it("behaves like a partial build over all contracts", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await hre.tasks.getTask("build").run({ noTests: true }); const artifactsPath = await hre.solidity.getArtifactsDirectory("contracts"); - // Contract root should have artifacts.d.ts - assert.equal( - await exists( - path.join(artifactsPath, "contracts", "Foo.sol", "artifacts.d.ts"), - ), - true, + // Contract artifact should exist + await readJsonFile( + path.join(artifactsPath, "contracts", "Foo.sol", "Foo.json"), ); - // Test roots should NOT have artifacts.d.ts - assert.equal( - await exists( - path.join(artifactsPath, "contracts", "Foo.t.sol", "artifacts.d.ts"), - ), - false, + // Test artifacts should also exist because they're dependencies, + // but the test roots themselves weren't included as roots + const noTestsResult: { + contractRootPaths: string[]; + testRootPaths: string[]; + } = await hre.tasks.getTask("build").run({ noTests: true }); + assert.ok( + noTestsResult.contractRootPaths.length > 0, + "Expected contractRootPaths to contain entries", ); assert.equal( - await exists( - path.join( - artifactsPath, - "test", - "OtherFooTest.sol", - "artifacts.d.ts", - ), - ), - false, + noTestsResult.testRootPaths.length, + 0, + "Expected testRootPaths to be empty for --no-tests", ); }); - }); - describe("unified cleanup", function () { - it("includes test artifacts in duplicate-name detection", async () => { - const duplicateNameTemplate = { - name: "test", - version: "1.0.0", - files: { - "contracts/Foo.sol": `// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.0;\ncontract Foo {}`, - "test/Foo.sol": `// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.0;\ncontract Foo {}`, - }, - }; - - await using project = await useTestProjectTemplate(duplicateNameTemplate); + it("does not run cleanup", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); const hre = await project.getHRE(unifiedTestsCompilationConfig); - // Build directly using the build-system APIs (the build task is - // not updated until Phase 4). - const roots = await hre.solidity.getRootFilePaths({ - scope: "contracts", - }); - const buildResult = await hre.solidity.build(roots, { - scope: "contracts", - }); - - assert.ok( - hre.solidity.isSuccessfulBuildResult(buildResult), - "Expected build to succeed", - ); - - await hre.solidity.cleanupArtifacts([...buildResult.keys()], { - scope: "contracts", - }); + // First do a full build + await hre.tasks.getTask("build").run(); const artifactsPath = await hre.solidity.getArtifactsDirectory("contracts"); - // The top-level artifacts.d.ts should exist and contain the duplicate - const topLevelDts = path.join(artifactsPath, "artifacts.d.ts"); - assert.equal(await exists(topLevelDts), true); - const dtsContent = await readUtf8File(topLevelDts); - assert.ok( - dtsContent.includes('"Foo"'), - "Expected top-level artifacts.d.ts to include the duplicated contract name Foo from both test and contract artifacts", + // Create a stale artifact + const staleArtifactPath = path.join( + artifactsPath, + "contracts", + "Stale.sol", + "Stale.json", ); + await writeUtf8File(staleArtifactPath, ""); + + await hre.tasks.getTask("build").run({ noTests: true }); + + // Stale artifact should NOT be cleaned up (partial build) + assert.equal(await exists(staleArtifactPath), true); }); + }); - it("passes mixed contract and test artifact paths to onCleanUpArtifacts", async () => { + describe("--no-contracts", function () { + it("behaves like a partial build over all tests", async () => { await using project = await useTestProjectTemplate(basicProjectTemplate); const hre = await project.getHRE(unifiedTestsCompilationConfig); - // Build directly using the build-system APIs (the build task is - // not updated until Phase 4). - const roots = await hre.solidity.getRootFilePaths({ - scope: "contracts", - }); - const buildResult = await hre.solidity.build(roots, { - scope: "contracts", - }); + const noContractsResult: { + contractRootPaths: string[]; + testRootPaths: string[]; + } = await hre.tasks.getTask("build").run({ noContracts: true }); + assert.equal( + noContractsResult.contractRootPaths.length, + 0, + "Expected contractRootPaths to be empty for --no-contracts", + ); assert.ok( - hre.solidity.isSuccessfulBuildResult(buildResult), - "Expected build to succeed", + noContractsResult.testRootPaths.length > 0, + "Expected testRootPaths to contain entries", ); + }); - // This is run directly here, so this isn't testing much now, but will be - // better tested in Phase 4 - await hre.solidity.cleanupArtifacts([...buildResult.keys()], { - scope: "contracts", - }); + it("still uses low-level scope 'contracts'", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + // --no-contracts builds only test roots but uses scope: "contracts" + await hre.tasks.getTask("build").run({ noContracts: true }); const artifactsPath = await hre.solidity.getArtifactsDirectory("contracts"); - // All artifacts should be in the main artifacts directory - const buildInfoDir = path.join(artifactsPath, "build-info"); - const artifactPaths = await getAllFilesMatching( - artifactsPath, - (p) => - p.endsWith(".json") && - p.indexOf(path.sep, artifactsPath.length + path.sep.length) !== -1, - (dir) => dir !== buildInfoDir, + // Test artifacts should be in the main artifacts directory + await readJsonFile( + path.join(artifactsPath, "contracts", "Foo.t.sol", "FooTest.json"), ); - - // Should include both contract and test artifacts - assert.ok( - artifactPaths.some( - (p) => p.includes("Foo.sol") && !p.includes(".t.sol"), + await readJsonFile( + path.join( + artifactsPath, + "test", + "OtherFooTest.sol", + "OtherFooTest.json", ), - "Expected contract artifact Foo.json in unified artifacts", ); - assert.ok( - artifactPaths.some((p) => p.includes("Foo.t.sol")), - "Expected test artifact FooTest.json in unified artifacts", - ); - assert.ok( - artifactPaths.some((p) => p.includes("OtherFooTest.sol")), - "Expected test artifact OtherFooTest.json in unified artifacts", + }); + + it("does not run cleanup", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + // First do a full build + await hre.tasks.getTask("build").run(); + + const artifactsPath = + await hre.solidity.getArtifactsDirectory("contracts"); + + // Create a stale artifact + const staleArtifactPath = path.join( + artifactsPath, + "contracts", + "Stale.sol", + "Stale.json", ); + await writeUtf8File(staleArtifactPath, ""); + + await hre.tasks.getTask("build").run({ noContracts: true }); + + // Stale artifact should NOT be cleaned up (partial build) + assert.equal(await exists(staleArtifactPath), true); }); }); }); From 7ef6c6a9b8d278e26da30a68a490ea1fe65b0340 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 08:38:37 -0300 Subject: [PATCH 28/83] Make test portable --- .../solidity/build-system/integration/unified-build-system.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/unified-build-system.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/unified-build-system.ts index faf1642bcbc..f87d8f87bdb 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/unified-build-system.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/unified-build-system.ts @@ -145,7 +145,8 @@ describe("build system - splitTestsCompilation: false - build system API", funct scope: "contracts", }); const contractRoots = roots.filter( - (r) => !r.endsWith(".t.sol") && !r.includes("/test/"), + (r) => + !r.endsWith(".t.sol") && !r.includes(path.sep + "test" + path.sep), ); const result = await hre.solidity.getCompilationJobs(contractRoots, { scope: "contracts", From 3868caf003fd7c592fdfc9371b7b2fe7e835d1e5 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 08:39:03 -0300 Subject: [PATCH 29/83] Add missing error descriptor --- packages/hardhat-errors/src/descriptors.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index 8dac7f5a7a9..9e0690c753c 100644 --- a/packages/hardhat-errors/src/descriptors.ts +++ b/packages/hardhat-errors/src/descriptors.ts @@ -1360,6 +1360,18 @@ When \`splitTestsCompilation\` is \`false\`, contracts and tests are compiled to Set \`solidity.splitTestsCompilation\` to \`true\` in your Hardhat config to enable this build scope.`, }, + INCOMPATIBLE_FILES_WITH_BUILD_FLAGS: { + number: 917, + messageTemplate: `Some of the files you are trying to build are incompatible with the \`--no-contracts\` or \`--no-tests\` flag you provided: + +{files} + +Try re-running without these files, or without the flag.`, + websiteTitle: "Incompatible files with build flags", + websiteDescription: `You are trying to build a list of files while using \`--no-contracts\` or \`--no-tests\`, but some of those files are incompatible with the flag you provided. + +For example, you may be trying to build a test file with \`--no-tests\`, which isn't a valid operation.`, + }, }, ARTIFACTS: { NOT_FOUND: { From dee44f509f2f286e9ff8ad532b8a9fd3f262e193 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 09:03:46 -0300 Subject: [PATCH 30/83] Fix return type of build task --- .../builtin-plugins/solidity/tasks/build.ts | 17 ++- .../build-system/integration/build-scopes.ts | 118 ++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts index 52465ad40e2..15b0bc85c12 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts @@ -88,6 +88,7 @@ const buildAction: NewTaskActionFunction = async ( ); contractRootPaths.push(...contractBuildResults.contractRootPaths); + testRootPaths.push(...contractBuildResults.testRootPaths); } const shouldBuildTests = @@ -111,6 +112,7 @@ const buildAction: NewTaskActionFunction = async ( "The tests scope should build no contract in split test compilation mode", ); + contractRootPaths.push(...testBuildResults.contractRootPaths); testRootPaths.push(...testBuildResults.testRootPaths); } @@ -189,15 +191,24 @@ async function runSolidityBuild({ throwIfSolidityBuildFailed(solidity, results); + // We use the result keys in case a hook added or removed root files + const builtRootPaths = [...results.keys()]; + if (isFullBuild) { - // We use the result keys in case a hook added more root files - const builtRootPaths = [...results.keys()]; await solidity.cleanupArtifacts(builtRootPaths, { scope, }); } - return { contractRootPaths, testRootPaths }; + const preBuildRoots = new Set([...contractRootPaths, ...testRootPaths]); + if ( + builtRootPaths.length === preBuildRoots.size && + builtRootPaths.every((p) => preBuildRoots.has(p)) + ) { + return { contractRootPaths, testRootPaths }; + } + + return partitionRootPathsByScope(solidity, builtRootPaths); } /** diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts index 320effc74b0..d1f863506bb 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts @@ -1,3 +1,15 @@ +import type { + HookContext, + SolidityHooks, +} from "../../../../../../src/types/hooks.js"; +import type { HardhatPlugin } from "../../../../../../src/types/plugins.js"; +import type { + BuildOptions, + BuildScope, + CompilationJobCreationError, + FileBuildResult, +} from "../../../../../../src/types/solidity/build-system.js"; + import assert from "node:assert/strict"; import path from "node:path"; import { describe, it } from "node:test"; @@ -12,6 +24,48 @@ import { import { useTestProjectTemplate } from "../resolver/helpers.js"; +/** + * Creates a plugin that installs a `solidity.build` hook adding `extraFile` + * to the root file paths. If `onlyForScope` is provided, the file is only + * added when the build is invoked for that scope. + */ +function makeBuildHookAddingPlugin( + extraFile: string, + onlyForScope?: BuildScope, +): HardhatPlugin { + return { + id: "test-build-hook-adding-plugin", + hookHandlers: { + solidity: async () => ({ + default: async () => { + const handlers: Partial = { + build: async ( + context: HookContext, + rootFilePaths: string[], + options: BuildOptions | undefined, + next: ( + nextContext: HookContext, + nextRootFilePaths: string[], + nextOptions: BuildOptions | undefined, + ) => Promise< + CompilationJobCreationError | Map + >, + ) => { + const shouldAdd = + onlyForScope === undefined || options?.scope === onlyForScope; + const nextRoots = shouldAdd + ? [...rootFilePaths, extraFile] + : rootFilePaths; + return next(context, nextRoots, options); + }, + }; + return handlers; + }, + }), + }, + }; +} + const basicProjectTemplate = { name: "test", version: "1.0.0", @@ -156,6 +210,38 @@ describe("build system - build task - behavior on build scope", function () { assert.equal(await exists(contractArtifactPath), false); assert.equal(await exists(testArtifactPath), false); }); + + it("includes a hook-added contract root in the returned contractRootPaths", async () => { + await using project = await useTestProjectTemplate({ + ...basicProjectTemplate, + name: "test-split-hook-adds-contract", + files: { + ...basicProjectTemplate.files, + "extra/AddedByHook.sol": `// SPDX-License-Identifier: UNLICENSED + pragma solidity ^0.8.0; + contract AddedByHook {}`, + }, + }); + const extraFile = path.join(project.path, "extra/AddedByHook.sol"); + const hre = await project.getHRE({ + ...solidityCompilationConfig, + plugins: [makeBuildHookAddingPlugin(extraFile, "contracts")], + }); + + const result: { + contractRootPaths: string[]; + testRootPaths: string[]; + } = await hre.tasks.getTask("build").run(); + + assert.ok( + result.contractRootPaths.some((r) => r === extraFile), + "Expected hook-added AddedByHook.sol in contractRootPaths", + ); + assert.ok( + !result.testRootPaths.some((r) => r === extraFile), + "Did not expect hook-added contract in testRootPaths", + ); + }); }); describe("specifying files", function () { @@ -654,6 +740,38 @@ describe("build system - splitTestsCompilation: false - build task", function () "Expected OtherFooTest.sol in testRootPaths", ); }); + + it("includes a hook-added contract root in the returned contractRootPaths", async () => { + await using project = await useTestProjectTemplate({ + ...basicProjectTemplate, + name: "test-unified-hook-adds-contract", + files: { + ...basicProjectTemplate.files, + "extra/AddedByHook.sol": `// SPDX-License-Identifier: UNLICENSED + pragma solidity ^0.8.0; + contract AddedByHook {}`, + }, + }); + const extraFile = path.join(project.path, "extra/AddedByHook.sol"); + const hre = await project.getHRE({ + ...unifiedTestsCompilationConfig, + plugins: [makeBuildHookAddingPlugin(extraFile)], + }); + + const result: { + contractRootPaths: string[]; + testRootPaths: string[]; + } = await hre.tasks.getTask("build").run(); + + assert.ok( + result.contractRootPaths.some((r) => r === extraFile), + "Expected hook-added AddedByHook.sol in contractRootPaths", + ); + assert.ok( + !result.testRootPaths.some((r) => r === extraFile), + "Did not expect hook-added contract in testRootPaths", + ); + }); }); describe("explicit files", function () { From 3e8e721f07267ee47a1c59b69c130091d9f2edcd Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 15:05:29 -0300 Subject: [PATCH 31/83] Update bulitin tasks: run, test, and console --- .../builtin-plugins/console/task-action.ts | 3 +- .../builtin-plugins/run/task-action.ts | 3 +- .../builtin-plugins/test/task-action.ts | 5 +- .../builtin-plugins/console/task-action.ts | 66 +++++++++++++++++++ .../builtin-plugins/run/task-action.ts | 57 ++++++++++++++++ .../builtin-plugins/test/task-action.ts | 63 ++++++++++++++++++ 6 files changed, 192 insertions(+), 5 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/console/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/console/task-action.ts index f401d072d76..07d07cfab68 100644 --- a/packages/hardhat/src/internal/builtin-plugins/console/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/console/task-action.ts @@ -42,7 +42,8 @@ const consoleAction: NewTaskActionFunction = async ( } if (!noCompile) { - await hre.tasks.getTask("build").run({ noTests: true, quiet: true }); + const noTests = hre.config.solidity.splitTestsCompilation; + await hre.tasks.getTask("build").run({ noTests, quiet: true }); } return await new Promise(async (resolve) => { diff --git a/packages/hardhat/src/internal/builtin-plugins/run/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/run/task-action.ts index 31d706c43d8..0aede41949f 100644 --- a/packages/hardhat/src/internal/builtin-plugins/run/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/run/task-action.ts @@ -25,7 +25,8 @@ const runScriptWithHardhat: NewTaskActionFunction = async ( } if (!noCompile) { - await hre.tasks.getTask("build").run({ quiet: true, noTests: true }); + const noTests = hre.config.solidity.splitTestsCompilation; + await hre.tasks.getTask("build").run({ quiet: true, noTests }); console.log(); } diff --git a/packages/hardhat/src/internal/builtin-plugins/test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/test/task-action.ts index 87892452adc..7f198fea6bc 100644 --- a/packages/hardhat/src/internal/builtin-plugins/test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/test/task-action.ts @@ -64,9 +64,8 @@ const runAllTests: NewTaskActionFunction = async ( const thisTask = hre.tasks.getTask("test"); if (!noCompile) { - await hre.tasks.getTask("build").run({ - noTests: true, - }); + const noTests = hre.config.solidity.splitTestsCompilation; + await hre.tasks.getTask("build").run({ noTests }); } if (hre.globalOptions.coverage === true) { diff --git a/packages/hardhat/test/internal/builtin-plugins/console/task-action.ts b/packages/hardhat/test/internal/builtin-plugins/console/task-action.ts index 8933e081529..63589bfec6a 100644 --- a/packages/hardhat/test/internal/builtin-plugins/console/task-action.ts +++ b/packages/hardhat/test/internal/builtin-plugins/console/task-action.ts @@ -14,6 +14,7 @@ import { ensureError } from "@nomicfoundation/hardhat-utils/error"; import { exists, remove } from "@nomicfoundation/hardhat-utils/fs"; import debug from "debug"; +import { overrideTask } from "../../../../src/config.js"; import consoleAction from "../../../../src/internal/builtin-plugins/console/task-action.js"; import { createHardhatRuntimeEnvironment } from "../../../../src/internal/hre-initialization.js"; @@ -152,6 +153,71 @@ describe("console/task-action", function () { }); }); + describe("build invocation", function () { + useFixtureProject("run-js-script"); + + function buildArgCaptor() { + const buildArgs: any[] = []; + const buildOverride = overrideTask("build") + .setAction(async () => { + return { + default: (args: any) => { + buildArgs.push(args); + return { contractRootPaths: [], testRootPaths: [] }; + }, + }; + }) + .build(); + return { buildArgs, buildOverride }; + } + + it("should call build without noTests when splitTestsCompilation is false", async function () { + const { buildArgs, buildOverride } = buildArgCaptor(); + const testHre = await createHardhatRuntimeEnvironment({ + tasks: [buildOverride], + }); + + await consoleAction( + { + commands: [".exit"], + history: "", + noCompile: false, + options, + }, + testHre, + ); + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, false); + assert.equal(buildArgs[0].quiet, true); + }); + + it("should call build with noTests when splitTestsCompilation is true", async function () { + const { buildArgs, buildOverride } = buildArgCaptor(); + const testHre = await createHardhatRuntimeEnvironment({ + solidity: { + version: "0.8.28", + splitTestsCompilation: true, + }, + tasks: [buildOverride], + }); + + await consoleAction( + { + commands: [".exit"], + history: "", + noCompile: false, + options, + }, + testHre, + ); + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, true); + assert.equal(buildArgs[0].quiet, true); + }); + }); + describe("history", function () { let cacheDir: string; let history: string; diff --git a/packages/hardhat/test/internal/builtin-plugins/run/task-action.ts b/packages/hardhat/test/internal/builtin-plugins/run/task-action.ts index 47a04b4dd77..9561131c2bc 100644 --- a/packages/hardhat/test/internal/builtin-plugins/run/task-action.ts +++ b/packages/hardhat/test/internal/builtin-plugins/run/task-action.ts @@ -1,5 +1,6 @@ import type { HardhatRuntimeEnvironment } from "../../../../src/types/hre.js"; +import assert from "node:assert/strict"; import { before, describe, it } from "node:test"; import { HardhatError } from "@nomicfoundation/hardhat-errors"; @@ -9,6 +10,7 @@ import { useFixtureProject, } from "@nomicfoundation/hardhat-test-utils"; +import { overrideTask } from "../../../../src/config.js"; import runScriptWithHardhat from "../../../../src/internal/builtin-plugins/run/task-action.js"; import { createHardhatRuntimeEnvironment } from "../../../../src/internal/hre-initialization.js"; @@ -95,4 +97,59 @@ describe("run/task-action", function () { }); }); }); + + describe("build invocation", function () { + useFixtureProject("run-js-script"); + + function buildArgCaptor() { + const buildArgs: any[] = []; + const buildOverride = overrideTask("build") + .setAction(async () => { + return { + default: (args: any) => { + buildArgs.push(args); + return { contractRootPaths: [], testRootPaths: [] }; + }, + }; + }) + .build(); + return { buildArgs, buildOverride }; + } + + it("should call build without noTests when splitTestsCompilation is false", async function () { + const { buildArgs, buildOverride } = buildArgCaptor(); + const testHre = await createHardhatRuntimeEnvironment({ + tasks: [buildOverride], + }); + + await runScriptWithHardhat( + { script: "./scripts/success.js", noCompile: false }, + testHre, + ); + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, false); + assert.equal(buildArgs[0].quiet, true); + }); + + it("should call build with noTests when splitTestsCompilation is true", async function () { + const { buildArgs, buildOverride } = buildArgCaptor(); + const testHre = await createHardhatRuntimeEnvironment({ + solidity: { + version: "0.8.28", + splitTestsCompilation: true, + }, + tasks: [buildOverride], + }); + + await runScriptWithHardhat( + { script: "./scripts/success.js", noCompile: false }, + testHre, + ); + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, true); + assert.equal(buildArgs[0].quiet, true); + }); + }); }); diff --git a/packages/hardhat/test/internal/builtin-plugins/test/task-action.ts b/packages/hardhat/test/internal/builtin-plugins/test/task-action.ts index 41ab777763d..b2b619dc493 100644 --- a/packages/hardhat/test/internal/builtin-plugins/test/task-action.ts +++ b/packages/hardhat/test/internal/builtin-plugins/test/task-action.ts @@ -1,3 +1,4 @@ +import type { HardhatRuntimeEnvironment } from "../../../../src/types/hre.js"; import type { HardhatPlugin } from "../../../../src/types/plugins.js"; import assert from "node:assert/strict"; @@ -293,6 +294,68 @@ describe("test/task-action", function () { }); }); + describe("build invocation", function () { + function buildArgCaptor() { + const buildArgs: any[] = []; + const buildOverride = overrideTask("build") + .setAction(async () => { + return { + default: (args: any) => { + buildArgs.push(args); + return { contractRootPaths: [], testRootPaths: [] }; + }, + }; + }) + .build(); + return { buildArgs, buildOverride }; + } + + async function createTestHre( + buildOverride: ReturnType["buildOverride"], + splitTestsCompilation: boolean, + ): Promise { + return createHardhatRuntimeEnvironment({ + ...(splitTestsCompilation + ? { + solidity: { + version: "0.8.28", + splitTestsCompilation: true, + }, + } + : {}), + tasks: [ + solidityNoOp, + buildOverride, + mockRunner("runner-a", () => + successfulResult({ + summary: { passed: 1, failed: 0, skipped: 0, todo: 0 }, + }), + ), + ], + }); + } + + it("should call build without noTests when splitTestsCompilation is false", async () => { + const { buildArgs, buildOverride } = buildArgCaptor(); + const hre = await createTestHre(buildOverride, false); + + await hre.tasks.getTask("test").run({ noCompile: false }); + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, false); + }); + + it("should call build with noTests when splitTestsCompilation is true", async () => { + const { buildArgs, buildOverride } = buildArgCaptor(); + const hre = await createTestHre(buildOverride, true); + + await hre.tasks.getTask("test").run({ noCompile: false }); + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, true); + }); + }); + describe("gas stats reporting only includes data from subtasks that ran", function () { it("should not include stale data from a skipped runner in the gas stats report", async (t) => { const consoleMock = t.mock.method(console, "log", () => {}); From a405dc4fed53e3d4e42ea1c39ce6892807f8ad10 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 17:38:35 -0300 Subject: [PATCH 32/83] Fix existing race condition --- .../builtin-plugins/solidity-test/runner.ts | 124 ++++++++++-------- 1 file changed, 66 insertions(+), 58 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/runner.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/runner.ts index e5f41302735..5c38ce7e56e 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/runner.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/runner.ts @@ -31,7 +31,7 @@ import { formatArtifactId } from "./formatters.js"; * Despite the changes, the signature of the function should still be considered * a draft that may change in the future. * - * TODO: Once the signature is finalized, give feedback to the EDR team. + * Important TODO: Transform this into an AsyncGenerator */ export function run( chainType: ChainType, @@ -41,67 +41,75 @@ export function run( tracingConfig: TracingConfigWithBuffers, sourceNameToUserSourceName: Map, ): TestsStream { - const stream = new ReadableStream({ - async start(controller) { - if (testSuiteIds.length === 0) { - controller.close(); - return; - } - let runCompleted = false; + const stream = new Readable({ + objectMode: true, + read() {}, + }); - const remainingSuites = new Set( - testSuiteIds.map((id) => - formatArtifactId(id, sourceNameToUserSourceName), - ), - ); + if (testSuiteIds.length === 0) { + stream.push(null); + return stream; + } - // TODO: Add support for predeploys once EDR supports them. - try { - const edrContext = await getGlobalEdrContext(); - const solidityTestResult = await edrContext.runSolidityTests( - hardhatChainTypeToEdrChainType(chainType), - artifacts, - testSuiteIds, - testRunnerConfig, - tracingConfig, - (suiteResult) => { - controller.enqueue({ - type: "suite:done", - data: suiteResult, - }); - remainingSuites.delete( - formatArtifactId(suiteResult.id, sourceNameToUserSourceName), - ); - if (remainingSuites.size === 0) { - if (runCompleted) { - controller.close(); - } - } - }, - ); - controller.enqueue({ - type: "run:done", - data: solidityTestResult, - }); - runCompleted = true; + let runCompleted = false; + + const remainingSuites = new Set( + testSuiteIds.map((id) => formatArtifactId(id, sourceNameToUserSourceName)), + ); - if (remainingSuites.size === 0) { - controller.close(); - } - } catch (error) { - ensureError(error); + // Start the async work immediately. The read() callback is a no-op + // because we push data proactively from the EDR suite-completion + // callback. Using a native Readable (instead of a web ReadableStream + // wrapped with Readable.from) avoids a race where Node.js stream + // cleanup cancels the web reader while the async start callback still + // has pending work — push() on a destroyed Readable is a safe no-op. + // TODO: Add support for predeploys once EDR supports them. + void (async () => { + try { + const edrContext = await getGlobalEdrContext(); + const solidityTestResult = await edrContext.runSolidityTests( + hardhatChainTypeToEdrChainType(chainType), + artifacts, + testSuiteIds, + testRunnerConfig, + tracingConfig, + (suiteResult) => { + stream.push({ + type: "suite:done", + data: suiteResult, + } satisfies TestEvent); + remainingSuites.delete( + formatArtifactId(suiteResult.id, sourceNameToUserSourceName), + ); + if (remainingSuites.size === 0) { + if (runCompleted) { + stream.push(null); + } + } + }, + ); + stream.push({ + type: "run:done", + data: solidityTestResult, + } satisfies TestEvent); + runCompleted = true; - controller.error( - new HardhatError( - HardhatError.ERRORS.CORE.SOLIDITY_TESTS.UNHANDLED_EDR_ERROR_SOLIDITY_TESTS, - { - error: error.message, - }, - ), - ); + if (remainingSuites.size === 0) { + stream.push(null); } - }, - }); + } catch (error) { + ensureError(error); + + stream.destroy( + new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.UNHANDLED_EDR_ERROR_SOLIDITY_TESTS, + { + error: error.message, + }, + ), + ); + } + })(); - return Readable.from(stream); + return stream; } From c3d1d3151688ce6d5986e35aed331a6299f2a442 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 17:41:58 -0300 Subject: [PATCH 33/83] Update the SPEC to reflect some DX improvements --- SPLIT_TESTS_COMPILATION_SPEC.md | 41 +++++++++++++++++---------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/SPLIT_TESTS_COMPILATION_SPEC.md b/SPLIT_TESTS_COMPILATION_SPEC.md index 63f696a2dd5..e5ec1ebd875 100644 --- a/SPLIT_TESTS_COMPILATION_SPEC.md +++ b/SPLIT_TESTS_COMPILATION_SPEC.md @@ -264,25 +264,21 @@ When `splitTestsCompilation === true`: ### Solidity Test Runner (`hardhat test solidity`) +Before branching on `splitTestsCompilation`, the runner validates that all provided `testFiles` are classified as tests by `getScope()`, throwing `SELECTED_FILES_ARENT_SOLIDITY_TESTS` if any are not. This validation runs in both modes. + When `splitTestsCompilation === false`: - `noCompile === true` skips compilation entirely -- `noCompile !== true` performs one full unified build -- `testFiles` only controls which tests are executed -- partial Solidity test runs may still compile all Solidity tests as a temporary limitation -- the runner must compute the selected test roots independently from the build return value +- `noCompile !== true` calls `build({ files: testFiles })` once, without `noTests` or `noContracts` — a full build when `testFiles` is empty, a partial build of the specified files otherwise +- `testFiles` controls both which files are compiled and which tests are executed +- the runner uses `testRootPaths` from the build return value to determine which tests to run - when `noCompile === true`, selected test roots must still be validated against the compiled artifacts available on disk -- if a selected Solidity test file exists but has not been compiled, the task throws a `HardhatError` +- if a selected Solidity test file exists but has not been compiled, the task throws `SELECTED_TEST_FILES_NOT_COMPILED` - only the selected test roots are used for: - deciding which suites to execute - deprecated-test warnings - artifacts and build info are read from a single directory: `getArtifactsDirectory("contracts")` -Important distinction in unified mode: - -- compiled test roots: all test roots produced by the unified build -- executed test roots: the tests requested by the user, or all test roots when no specific `testFiles` are provided - When `splitTestsCompilation === true`, current behavior is preserved: - the first build (contracts) is guarded by `noCompile` @@ -591,7 +587,7 @@ Rewrite the high-level build task to implement the new unified-mode semantics. 2. `packages/hardhat-errors/src/descriptors.ts` - Replace `FILES_WITH_SCOPE_FILTERS_NOT_SUPPORTED` with `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` (same error number 917) - - `UNRECOGNIZED_FILES_NOT_COMPILED` (915) is no longer used in build.ts (still used by the solidity-test runner, addressed in Phase 6) + - `UNRECOGNIZED_FILES_NOT_COMPILED` (915) is no longer used in build.ts. After Phase 6, it is no longer used anywhere — the solidity-test runner replaces it with `SELECTED_FILES_ARENT_SOLIDITY_TESTS` (815) ### Validation @@ -625,7 +621,7 @@ Rewrite the high-level build task to implement the new unified-mode semantics. - split mode: explicit test files only (no flags) skips the contracts scope entirely - other split-mode regressions for current behavior - Run `pnpm test` in `packages/hardhat` -- **Known failures after Phase 4:** 2 tests fail because the solidity-test runner calls `build({ files: testFiles, noContracts: true })` with a file that `getScope()` classifies as a contract (not in the configured test path). The mode-independent validation catches this as an incompatible combination and throws `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` (917), but the tests expect the old `UNRECOGNIZED_FILES_NOT_COMPILED` (915). Both originate from `solidity-test/task-action.ts` and are fixed in Phase 6 when the solidity-test runner is updated. +- **Known failures after Phase 4:** 2 tests fail because the solidity-test runner calls `build({ files: testFiles, noContracts: true })` with a file that `getScope()` classifies as a contract (not in the configured test path). The mode-independent validation catches this as an incompatible combination and throws `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` (917), but the tests expect the old `UNRECOGNIZED_FILES_NOT_COMPILED` (915). Both originate from `solidity-test/task-action.ts`. Phase 6 resolves this by adding an early `getScope()`-based validation in the solidity-test runner that throws `SELECTED_FILES_ARENT_SOLIDITY_TESTS` (815) before the build call, so neither 917 nor 915 fires for this case. ## Phase 5: Other Built-In Task Callers @@ -660,35 +656,40 @@ Update the built-in tasks that currently call `build({ noTests: true })`. While ## Phase 6: Solidity Test Runner -Update the Solidity test runner for unified builds while preserving selected test execution. Note: the solidity-test runner currently uses `build({ files, noContracts: true })`, which is valid after Phase 4 (it produces a partial test-only build). However, in unified mode the runner should perform a full build instead, so this change is still needed for the correct full-build semantics. +Update the Solidity test runner for unified builds while preserving selected test execution. Note: the solidity-test runner currently uses `build({ files, noContracts: true })`, which is valid after Phase 4 (it produces a partial test-only build). In unified mode the runner drops `noContracts` and passes `files: testFiles` to build, performing selective compilation without scope flags. ### Changes 1. `packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts` + - Before branching on `splitTestsCompilation`, validate that all provided `testFiles` are classified as tests by `getScope()`, throwing `SELECTED_FILES_ARENT_SOLIDITY_TESTS` if not - Branch on `hre.config.solidity.splitTestsCompilation` - Unified mode: - - if `noCompile !== true`, call `build()` once without `noTests` or `noContracts` - - compute selected test roots independently from the build return value + - if `noCompile !== true`, call `build({ files: testFiles })` once, without `noTests` or `noContracts` + - use `testRootPaths` from the build return value to determine which tests to run - when `noCompile === true`, validate that every selected Solidity test root has compiled artifacts available - - throw a `HardhatError` if a selected Solidity test file exists but was not compiled + - throw `SELECTED_TEST_FILES_NOT_COMPILED` if a selected Solidity test file exists but was not compiled - use selected test roots for suite execution and deprecated-test warnings - read artifacts and build info from the main artifacts directory only - - accept the temporary limitation that selected runs may still compile all Solidity tests - Split mode: - preserve the current two-build behavior +2. `packages/hardhat-errors/src/descriptors.ts` + - Add `SELECTED_TEST_FILES_NOT_COMPILED` (814) — thrown when `noCompile` is set and selected test files have not been compiled + - Add `SELECTED_FILES_ARENT_SOLIDITY_TESTS` (815) — thrown when non-test files are passed as test files + ### Validation - Run `pnpm lint` in `packages/hardhat` - Run `pnpm build` in `packages/hardhat` - Run existing Solidity test runner tests: `packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts` - Add tests for: - - unified mode performs one build + - early validation throws `SELECTED_FILES_ARENT_SOLIDITY_TESTS` for non-test files in both modes + - unified mode performs one build via `build({ files: testFiles })` - unified mode reads artifacts from a single directory - unified mode executes only the selected test files - - a non-selected failing test may be compiled but is not executed + - unified mode compiles only the selected test files (selective compilation) - deprecated-test warnings are emitted only for selected tests - - unified `noCompile === true` throws a `HardhatError` when a selected Solidity test file exists but has not been compiled + - unified `noCompile === true` throws `SELECTED_TEST_FILES_NOT_COMPILED` when a selected Solidity test file exists but has not been compiled - `noCompile === true` works in both modes - split-mode behavior remains unchanged - Run `pnpm test` in `packages/hardhat` From 6db5c9c81794780337a8007319e2c91d6c7c8fe8 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 17:44:37 -0300 Subject: [PATCH 34/83] Update test solidity task --- packages/hardhat-errors/src/descriptors.ts | 20 +++ .../solidity-test/task-action.ts | 145 +++++++++++++++--- 2 files changed, 144 insertions(+), 21 deletions(-) diff --git a/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index 9e0690c753c..84ec216bb5a 100644 --- a/packages/hardhat-errors/src/descriptors.ts +++ b/packages/hardhat-errors/src/descriptors.ts @@ -1192,6 +1192,26 @@ Remaining test suites: {suites}`, websiteDescription: "An inline config key was used that does not apply to the type of test function it was attached to. Fuzz test functions (test*) only accept fuzz.* keys and top-level keys, while invariant test functions (invariant*) only accept invariant.* keys and top-level keys.", }, + SELECTED_TEST_FILES_NOT_COMPILED: { + number: 814, + messageTemplate: `The following Solidity test files have not been compiled: + +{files} + +Run \`hardhat build\` to compile your project before running tests with \`--no-compile\`.`, + websiteTitle: "Selected Solidity test files not compiled", + websiteDescription: `You ran Solidity tests with \`--no-compile\`, but some of the selected test files have not been compiled yet. Run \`hardhat build\` first, or remove the \`--no-compile\` flag.`, + }, + SELECTED_FILES_ARE_NOT_SOLIDITY_TESTS: { + number: 815, + messageTemplate: `Trying to run these files as Solidity tests, but they aren't: + +{files} + +Double check the files that you are providing to the \`test solidity\` task`, + websiteTitle: "Invalid Solidity test files", + websiteDescription: `You ran the \`test solidity\` task files that aren't clasified as Solidity tests..`, + }, }, SOLIDITY: { PROJECT_ROOT_RESOLUTION_ERROR: { diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index b8bb7c8b2a2..6dfaaa18cbc 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -3,6 +3,7 @@ import type { EdrArtifactWithMetadata, } from "./edr-artifacts.js"; import type { TestEvent } from "./types.js"; +import type { SolidityBuildSystem } from "../../../types/solidity.js"; import type { NewTaskActionFunction } from "../../../types/tasks.js"; import type { TestRunResult } from "../../../types/test.js"; import type { Result } from "../../../types/utils.js"; @@ -15,6 +16,7 @@ import type { import { finished } from "node:stream/promises"; import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { exists } from "@nomicfoundation/hardhat-utils/fs"; import { resolveFromRoot } from "@nomicfoundation/hardhat-utils/path"; import { createNonClosingWriter } from "@nomicfoundation/hardhat-utils/stream"; @@ -59,6 +61,15 @@ const runSolidityTests: NewTaskActionFunction = async ( process.env.HH_TEST = "true"; const verbosity = hre.globalOptions.verbosity; + const resolvedTestFilesArgument = testFiles.map((f) => + resolveFromRoot(hre.config.paths.root, f), + ); + + await validateThatProvidedFilesAreTests( + hre.solidity, + testFiles, + resolvedTestFilesArgument, + ); // Sets the NODE_ENV environment variable to "test" so the code can detect that tests are running // This is done by other JS/TS test frameworks like vitest @@ -75,27 +86,62 @@ const runSolidityTests: NewTaskActionFunction = async ( ); } - // Run the build task for contract files if needed - if (noCompile !== true) { - await hre.tasks.getTask("build").run({ - noTests: true, - }); - } - - // Run the build task for test files - const { testRootPaths }: { testRootPaths: string[] } = await hre.tasks - .getTask("build") - .run({ - files: testFiles, - noContracts: true, - }); - console.log(); - - // EDR needs all artifacts (contracts + tests) + let testRootPathsToRun: string[]; const edrArtifactsWithMetadata: EdrArtifactWithMetadata[] = []; const allBuildInfosAndOutputs: BuildInfoAndOutput[] = []; - for (const scope of ["contracts", "tests"] as const) { - const artifactsDir = await hre.solidity.getArtifactsDirectory(scope); + + if (hre.config.solidity.splitTestsCompilation) { + if (noCompile !== true) { + await hre.tasks.getTask("build").run({ + noTests: true, + }); + } + + ({ testRootPaths: testRootPathsToRun } = await hre.tasks + .getTask("build") + .run({ + files: testFiles, + noContracts: true, + })); + console.log(); + + for (const scope of ["contracts", "tests"] as const) { + const artifactsDir = await hre.solidity.getArtifactsDirectory(scope); + const artifactManager = new ArtifactManagerImplementation(artifactsDir); + edrArtifactsWithMetadata.push( + ...(await buildEdrArtifactsWithMetadata(artifactManager)), + ); + allBuildInfosAndOutputs.push( + ...(await getBuildInfosAndOutputs(artifactManager)), + ); + } + } else { + if (noCompile !== true) { + ({ testRootPaths: testRootPathsToRun } = await hre.tasks + .getTask("build") + .run({ + files: testFiles, + })); + } else { + if (resolvedTestFilesArgument.length > 0) { + testRootPathsToRun = resolvedTestFilesArgument; + } else { + testRootPathsToRun = []; + const allRoots = await hre.solidity.getRootFilePaths({ + scope: "contracts", + }); + + for (const root of allRoots) { + if ((await hre.solidity.getScope(root)) === "tests") { + testRootPathsToRun.push(root); + } + } + } + } + console.log(); + + // Load artifacts from a single directory + const artifactsDir = await hre.solidity.getArtifactsDirectory("contracts"); const artifactManager = new ArtifactManagerImplementation(artifactsDir); edrArtifactsWithMetadata.push( ...(await buildEdrArtifactsWithMetadata(artifactManager)), @@ -103,6 +149,31 @@ const runSolidityTests: NewTaskActionFunction = async ( allBuildInfosAndOutputs.push( ...(await getBuildInfosAndOutputs(artifactManager)), ); + + // When noCompile, validate selected test roots have compiled artifacts + if (noCompile === true) { + const compiledSources = new Set( + edrArtifactsWithMetadata.map(({ userSourceName }) => + resolveFromRoot(hre.config.paths.root, userSourceName), + ), + ); + + const notCompiledFiles: string[] = []; + for (const root of testRootPathsToRun) { + if (!compiledSources.has(root) && (await exists(root))) { + notCompiledFiles.push(root); + } + } + + if (notCompiledFiles.length > 0) { + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.SELECTED_TEST_FILES_NOT_COMPILED, + { + files: notCompiledFiles.map((f) => `- ${f}`).join("\n"), + }, + ); + } + } } const sourceNameToUserSourceName = new Map( @@ -114,7 +185,7 @@ const runSolidityTests: NewTaskActionFunction = async ( edrArtifactsWithMetadata.forEach(({ userSourceName, edrArtifact }) => { if ( - testRootPaths.includes( + testRootPathsToRun.includes( resolveFromRoot(hre.config.paths.root, userSourceName), ) && isTestSuiteArtifact(edrArtifact) @@ -125,7 +196,7 @@ const runSolidityTests: NewTaskActionFunction = async ( const testSuiteArtifacts = edrArtifactsWithMetadata .filter(({ userSourceName }) => - testRootPaths.includes( + testRootPathsToRun.includes( resolveFromRoot(hre.config.paths.root, userSourceName), ), ) @@ -310,4 +381,36 @@ const runSolidityTests: NewTaskActionFunction = async ( : successfulResult(result); }; +/** + * Validates that the test files provided by the user, resolved in this case, + * are actually test files. + * + * @param solidity The solidity build system + * @param testFiles The test files, as provided by the user + * @param resolvedTestFilesArgument The resolved testFiles + */ +async function validateThatProvidedFilesAreTests( + solidity: SolidityBuildSystem, + testFiles: string[], + resolvedTestFilesArgument: string[], +) { + const nonTests = []; + for (let i = 0; i < resolvedTestFilesArgument.length; i++) { + const rootPath = resolvedTestFilesArgument[i]; + const scope = await solidity.getScope(rootPath); + if (scope !== "tests") { + nonTests.push(testFiles[i]); + } + } + + if (nonTests.length > 0) { + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.SELECTED_FILES_ARE_NOT_SOLIDITY_TESTS, + { + files: nonTests.map((f) => `- ${f}`).join("\n"), + }, + ); + } +} + export default runSolidityTests; From e7eef1773d559c86804a2139acd3d6717b058004 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 17:45:05 -0300 Subject: [PATCH 35/83] Update the tests --- .../deprecated/DeprecatedTestFail.t.sol | 8 + .../contracts/deprecated/NormalTest.t.sol | 10 + .../solidity-test/task-action.ts | 247 ++++++++++++++---- 3 files changed, 210 insertions(+), 55 deletions(-) create mode 100644 packages/hardhat/test/fixture-projects/solidity-test/test/contracts/deprecated/DeprecatedTestFail.t.sol create mode 100644 packages/hardhat/test/fixture-projects/solidity-test/test/contracts/deprecated/NormalTest.t.sol diff --git a/packages/hardhat/test/fixture-projects/solidity-test/test/contracts/deprecated/DeprecatedTestFail.t.sol b/packages/hardhat/test/fixture-projects/solidity-test/test/contracts/deprecated/DeprecatedTestFail.t.sol new file mode 100644 index 00000000000..92fc84a776d --- /dev/null +++ b/packages/hardhat/test/fixture-projects/solidity-test/test/contracts/deprecated/DeprecatedTestFail.t.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract DeprecatedTestFailTest { + function testFailDeprecated() public pure { + // Intentionally uses the deprecated testFail* prefix + } +} diff --git a/packages/hardhat/test/fixture-projects/solidity-test/test/contracts/deprecated/NormalTest.t.sol b/packages/hardhat/test/fixture-projects/solidity-test/test/contracts/deprecated/NormalTest.t.sol new file mode 100644 index 00000000000..e73be899b5d --- /dev/null +++ b/packages/hardhat/test/fixture-projects/solidity-test/test/contracts/deprecated/NormalTest.t.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../../../contracts/Counter.sol"; + +contract NormalTest { + function testNormalPassing() public pure { + // A normal passing test + } +} diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts index e10e681d070..550bc036dae 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts @@ -23,6 +23,13 @@ import hardhatConfig from "../../../fixture-projects/solidity-test/hardhat.confi * If it fails, unintended files were executed. */ +// Covers all test subdirectories so a single build produces artifacts +// for every test file in the fixture project. +const hardhatConfigAllTestDirs = { + ...hardhatConfig, + paths: { tests: { solidity: "test/contracts" } }, +}; + const hardhatConfigAllTests = { ...hardhatConfig, paths: { tests: { solidity: "test/contracts/all" } }, @@ -54,9 +61,14 @@ describe("solidity-test/task-action", function () { useFixtureProject("solidity-test"); before(async function () { - hre = await createHardhatRuntimeEnvironment(hardhatConfigAllTests); + // Build with a config that covers all test subdirectories so that + // noCompile: true tests find pre-compiled artifacts on disk. + const buildHre = await createHardhatRuntimeEnvironment( + hardhatConfigAllTestDirs, + ); + await buildHre.tasks.getTask(["build"]).run({}); - await hre.tasks.getTask(["build"]).run({}); + hre = await createHardhatRuntimeEnvironment(hardhatConfigAllTests); }); describe("when the solidity task test runner is specified", () => { @@ -83,7 +95,8 @@ describe("solidity-test/task-action", function () { noCompile: true, testFiles: ["./test/not-in-test-path.t.sol"], }), - HardhatError.ERRORS.CORE.SOLIDITY.UNRECOGNIZED_FILES_NOT_COMPILED, + HardhatError.ERRORS.CORE.SOLIDITY_TESTS + .SELECTED_FILES_ARE_NOT_SOLIDITY_TESTS, { files: "- ./test/not-in-test-path.t.sol" }, ); }); @@ -112,7 +125,8 @@ describe("solidity-test/task-action", function () { noCompile: true, testFiles: ["./test/not-in-test-path.t.sol"], }), - HardhatError.ERRORS.CORE.SOLIDITY.UNRECOGNIZED_FILES_NOT_COMPILED, + HardhatError.ERRORS.CORE.SOLIDITY_TESTS + .SELECTED_FILES_ARE_NOT_SOLIDITY_TESTS, { files: "- ./test/not-in-test-path.t.sol" }, ); }); @@ -243,14 +257,17 @@ describe("solidity-test/task-action", function () { describe("building contracts and tests", () => { /** * Returns an HRE that accumulates the args to `build` in the array it - * returns + * returns. */ - async function getHreWithOverriddenBuild(): Promise< - [hre: HardhatRuntimeEnvironment, buildArgs: any[]] - > { + async function getHreWithOverriddenBuild( + splitTestsCompilation: boolean, + ): Promise<[hre: HardhatRuntimeEnvironment, buildArgs: any[]]> { const buildArgs: any[] = []; const overriddenHre = await createHardhatRuntimeEnvironment({ ...hardhatConfigAllTests, + ...(splitTestsCompilation + ? { solidity: { version: "0.8.28", splitTestsCompilation: true } } + : {}), tasks: [ overrideTask("build") .setAction(async () => { @@ -269,85 +286,205 @@ describe("solidity-test/task-action", function () { return [overriddenHre, buildArgs]; } - describe("When noCompile is provided", () => { - it("Should compile the test files, but not the contracts", async () => { - const [overriddenHre, buildArgs] = await getHreWithOverriddenBuild(); + describe("when splitTestsCompilation is true", () => { + describe("When noCompile is provided", () => { + it("Should compile the test files, but not the contracts", async () => { + const [overriddenHre, buildArgs] = + await getHreWithOverriddenBuild(true); - await overriddenHre.tasks.getTask(["test", "solidity"]).run({ - noCompile: true, + await overriddenHre.tasks.getTask(["test", "solidity"]).run({ + noCompile: true, + }); + + // We only call build once + assert.equal(buildArgs.length, 1); + + const lastArgs = buildArgs[0]; + assert.equal(lastArgs.noContracts, true); + assert.equal(lastArgs.noTests, false); + assert.deepEqual(lastArgs.files, []); }); - // We only call build once - assert.equal(buildArgs.length, 1); + it("Should compile only the provided test files, and not the contracts", async () => { + const [overriddenHre, buildArgs] = + await getHreWithOverriddenBuild(true); + + const testFiles = ["test/contracts/all/Counter-1.t.sol"]; + await overriddenHre.tasks.getTask(["test", "solidity"]).run({ + noCompile: true, + testFiles, + }); + + // We only call build once + assert.equal(buildArgs.length, 1); - const lastArgs = buildArgs[0]; - assert.equal(lastArgs.noContracts, true); - assert.equal(lastArgs.noTests, false); - assert.deepEqual(lastArgs.files, []); + const lastArgs = buildArgs[0]; + assert.equal(lastArgs.noContracts, true); + assert.equal(lastArgs.noTests, false); + assert.deepEqual(lastArgs.files, testFiles); + }); }); - it("Should compile only the provided test files, and not the contracts", async () => { - const [overriddenHre, buildArgs] = await getHreWithOverriddenBuild(); + describe("When noCompile is not provided", () => { + it("Should compile the contracts and then the test files", async () => { + const [overriddenHre, buildArgs] = + await getHreWithOverriddenBuild(true); - const testFiles = ["test/contracts/all/Counter-1.t.sol"]; - await overriddenHre.tasks.getTask(["test", "solidity"]).run({ - noCompile: true, - testFiles, + await overriddenHre.tasks.getTask(["test", "solidity"]).run({}); + + assert.equal(buildArgs.length, 2); + + const firstArgs = buildArgs[0]; + assert.equal(firstArgs.noContracts, false); + assert.equal(firstArgs.noTests, true); + assert.deepEqual(firstArgs.files, []); + + const lastArgs = buildArgs[1]; + assert.equal(lastArgs.noContracts, true); + assert.equal(lastArgs.noTests, false); + assert.deepEqual(lastArgs.files, []); }); - // We only call build once - assert.equal(buildArgs.length, 1); + it("Should compile the contracts and then the provided test files", async () => { + const [overriddenHre, buildArgs] = + await getHreWithOverriddenBuild(true); - const lastArgs = buildArgs[0]; - assert.equal(lastArgs.noContracts, true); - assert.equal(lastArgs.noTests, false); - assert.deepEqual(lastArgs.files, testFiles); + const testFiles = ["test/contracts/all/Counter-1.t.sol"]; + await overriddenHre.tasks + .getTask(["test", "solidity"]) + .run({ testFiles }); + + assert.equal(buildArgs.length, 2); + + const firstArgs = buildArgs[0]; + assert.equal(firstArgs.noContracts, false); + assert.equal(firstArgs.noTests, true); + assert.deepEqual(firstArgs.files, []); + + const lastArgs = buildArgs[1]; + assert.equal(lastArgs.noContracts, true); + assert.equal(lastArgs.noTests, false); + assert.deepEqual(lastArgs.files, testFiles); + }); }); }); - describe("When noCompile is not provided", () => { - it("Should compile the contracts and then the test files", async () => { - const [overriddenHre, buildArgs] = await getHreWithOverriddenBuild(); + describe("when splitTestsCompilation is false", () => { + it("should perform one build when noCompile is not provided", async () => { + const [overriddenHre, buildArgs] = + await getHreWithOverriddenBuild(false); await overriddenHre.tasks.getTask(["test", "solidity"]).run({}); - assert.equal(buildArgs.length, 2); - - const firstArgs = buildArgs[0]; - assert.equal(firstArgs.noContracts, false); - assert.equal(firstArgs.noTests, true); - assert.deepEqual(firstArgs.files, []); + assert.equal(buildArgs.length, 1); - const lastArgs = buildArgs[1]; - assert.equal(lastArgs.noContracts, true); - assert.equal(lastArgs.noTests, false); - assert.deepEqual(lastArgs.files, []); + const args = buildArgs[0]; + assert.equal(args.noTests, false); + assert.equal(args.noContracts, false); }); - it("Should compile the contracts and then the provided test files", async () => { - const [overriddenHre, buildArgs] = await getHreWithOverriddenBuild(); + it("should perform one build with selected test files", async () => { + const [overriddenHre, buildArgs] = + await getHreWithOverriddenBuild(false); const testFiles = ["test/contracts/all/Counter-1.t.sol"]; await overriddenHre.tasks .getTask(["test", "solidity"]) .run({ testFiles }); - assert.equal(buildArgs.length, 2); + assert.equal(buildArgs.length, 1); - const firstArgs = buildArgs[0]; - assert.equal(firstArgs.noContracts, false); - assert.equal(firstArgs.noTests, true); - assert.deepEqual(firstArgs.files, []); + const args = buildArgs[0]; + assert.equal(args.noTests, false); + assert.equal(args.noContracts, false); + }); - const lastArgs = buildArgs[1]; - assert.equal(lastArgs.noContracts, true); - assert.equal(lastArgs.noTests, false); - assert.deepEqual(lastArgs.files, testFiles); + it("should not call build when noCompile is provided", async () => { + const [overriddenHre, buildArgs] = + await getHreWithOverriddenBuild(false); + + await overriddenHre.tasks.getTask(["test", "solidity"]).run({ + noCompile: true, + }); + + assert.equal(buildArgs.length, 0); }); }); }); }); + describe("when splitTestsCompilation is false", () => { + it("should execute only the selected test files", async () => { + hre = await createHardhatRuntimeEnvironment(hardhatConfigPartialTests); + + const result = await hre.tasks.getTask(["test", "solidity"]).run({ + testFiles: ["./test/contracts/partial/Counter-1.sol"], + }); + assert.equal(result.success, true); + }); + + it("should read artifacts from a single directory", async () => { + hre = await createHardhatRuntimeEnvironment(hardhatConfigAllTests); + + const result = await hre.tasks.getTask(["test", "solidity"]).run({}); + assert.equal(result.success, true); + }); + + it("should only emit deprecated-test warnings for selected tests", async () => { + const deprecatedConfig = { + ...hardhatConfig, + paths: { tests: { solidity: "test/contracts/deprecated" } }, + }; + const deprecatedHre = + await createHardhatRuntimeEnvironment(deprecatedConfig); + + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }; + try { + await deprecatedHre.tasks.getTask(["test", "solidity"]).run({ + testFiles: ["./test/contracts/deprecated/NormalTest.t.sol"], + }); + } finally { + console.warn = originalWarn; + } + + assert.equal( + warnings.filter((w) => w.includes("testFail")).length, + 0, + "No testFail deprecation warning should be emitted for non-selected tests", + ); + }); + + it("should throw when a selected test file exists but has not been compiled", async () => { + const unbuiltConfig = { + ...hardhatConfig, + paths: { tests: { solidity: "test" } }, + }; + const unbuiltHre = await createHardhatRuntimeEnvironment(unbuiltConfig); + + try { + await unbuiltHre.tasks.getTask(["test", "solidity"]).run({ + noCompile: true, + testFiles: ["./test/not-in-test-path.t.sol"], + }); + assert.fail("Expected HardhatError to be thrown"); + } catch (error) { + assert.ok( + HardhatError.isHardhatError(error), + "Expected a HardhatError", + ); + assert.equal( + error.number, + HardhatError.ERRORS.CORE.SOLIDITY_TESTS + .SELECTED_TEST_FILES_NOT_COMPILED.number, + ); + } + }); + }); + it("should support EIP-7212 precompile at address 0x100", async () => { hre = await createHardhatRuntimeEnvironment(hardhatConfigHardforkTests); From cdcb4976dcff8ab2511ca37acb7aba59bac47870 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 17:48:30 -0300 Subject: [PATCH 36/83] Remove duplicated code --- .../solidity-test/task-action.ts | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index 6dfaaa18cbc..fd182973986 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -87,8 +87,8 @@ const runSolidityTests: NewTaskActionFunction = async ( } let testRootPathsToRun: string[]; - const edrArtifactsWithMetadata: EdrArtifactWithMetadata[] = []; - const allBuildInfosAndOutputs: BuildInfoAndOutput[] = []; + let edrArtifactsWithMetadata: EdrArtifactWithMetadata[]; + let allBuildInfosAndOutputs: BuildInfoAndOutput[]; if (hre.config.solidity.splitTestsCompilation) { if (noCompile !== true) { @@ -105,16 +105,8 @@ const runSolidityTests: NewTaskActionFunction = async ( })); console.log(); - for (const scope of ["contracts", "tests"] as const) { - const artifactsDir = await hre.solidity.getArtifactsDirectory(scope); - const artifactManager = new ArtifactManagerImplementation(artifactsDir); - edrArtifactsWithMetadata.push( - ...(await buildEdrArtifactsWithMetadata(artifactManager)), - ); - allBuildInfosAndOutputs.push( - ...(await getBuildInfosAndOutputs(artifactManager)), - ); - } + ({ edrArtifactsWithMetadata, allBuildInfosAndOutputs } = + await loadArtifacts(hre.solidity, ["contracts", "tests"])); } else { if (noCompile !== true) { ({ testRootPaths: testRootPathsToRun } = await hre.tasks @@ -140,15 +132,8 @@ const runSolidityTests: NewTaskActionFunction = async ( } console.log(); - // Load artifacts from a single directory - const artifactsDir = await hre.solidity.getArtifactsDirectory("contracts"); - const artifactManager = new ArtifactManagerImplementation(artifactsDir); - edrArtifactsWithMetadata.push( - ...(await buildEdrArtifactsWithMetadata(artifactManager)), - ); - allBuildInfosAndOutputs.push( - ...(await getBuildInfosAndOutputs(artifactManager)), - ); + ({ edrArtifactsWithMetadata, allBuildInfosAndOutputs } = + await loadArtifacts(hre.solidity, ["contracts"])); // When noCompile, validate selected test roots have compiled artifacts if (noCompile === true) { @@ -413,4 +398,26 @@ async function validateThatProvidedFilesAreTests( } } +async function loadArtifacts( + solidity: SolidityBuildSystem, + scopes: Array<"contracts" | "tests">, +): Promise<{ + edrArtifactsWithMetadata: EdrArtifactWithMetadata[]; + allBuildInfosAndOutputs: BuildInfoAndOutput[]; +}> { + const edrArtifactsWithMetadata: EdrArtifactWithMetadata[] = []; + const allBuildInfosAndOutputs: BuildInfoAndOutput[] = []; + for (const scope of scopes) { + const artifactsDir = await solidity.getArtifactsDirectory(scope); + const artifactManager = new ArtifactManagerImplementation(artifactsDir); + edrArtifactsWithMetadata.push( + ...(await buildEdrArtifactsWithMetadata(artifactManager)), + ); + allBuildInfosAndOutputs.push( + ...(await getBuildInfosAndOutputs(artifactManager)), + ); + } + return { edrArtifactsWithMetadata, allBuildInfosAndOutputs }; +} + export default runSolidityTests; From 71148175b1dc18640ebad20e1a42525f2df3021a Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 17:49:36 -0300 Subject: [PATCH 37/83] Simplify and optimize code a bit --- .../solidity-test/task-action.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index fd182973986..533354b9f4c 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -168,25 +168,19 @@ const runSolidityTests: NewTaskActionFunction = async ( ]), ); - edrArtifactsWithMetadata.forEach(({ userSourceName, edrArtifact }) => { - if ( - testRootPathsToRun.includes( - resolveFromRoot(hre.config.paths.root, userSourceName), - ) && - isTestSuiteArtifact(edrArtifact) - ) { - warnDeprecatedTestFail(edrArtifact, sourceNameToUserSourceName); - } - }); - + const testRootPathsSet = new Set(testRootPathsToRun); const testSuiteArtifacts = edrArtifactsWithMetadata .filter(({ userSourceName }) => - testRootPathsToRun.includes( + testRootPathsSet.has( resolveFromRoot(hre.config.paths.root, userSourceName), ), ) .filter(({ edrArtifact }) => isTestSuiteArtifact(edrArtifact)); + for (const { edrArtifact } of testSuiteArtifacts) { + warnDeprecatedTestFail(edrArtifact, sourceNameToUserSourceName); + } + const testSuiteIds = testSuiteArtifacts.map( ({ edrArtifact }) => edrArtifact.id, ); From 768b800252844d830369a347c100587abf336350 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 17:57:38 -0300 Subject: [PATCH 38/83] Fix typos and cspell config --- SPLIT_TESTS_COMPILATION_SPEC.md | 2 ++ packages/hardhat-errors/src/descriptors.ts | 2 +- .../internal/builtin-plugins/solidity-test/task-action.ts | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/SPLIT_TESTS_COMPILATION_SPEC.md b/SPLIT_TESTS_COMPILATION_SPEC.md index e5ec1ebd875..19516485b67 100644 --- a/SPLIT_TESTS_COMPILATION_SPEC.md +++ b/SPLIT_TESTS_COMPILATION_SPEC.md @@ -566,6 +566,7 @@ Rewrite the high-level build task to implement the new unified-mode semantics. ### Changes 1. `packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts` + - Add mode-independent validation before the `splitTestsCompilation` branch: - if `--no-contracts` and any explicit file is a contract, throw `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` - if `--no-tests` and any explicit file is a test, throw `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` @@ -661,6 +662,7 @@ Update the Solidity test runner for unified builds while preserving selected tes ### Changes 1. `packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts` + - Before branching on `splitTestsCompilation`, validate that all provided `testFiles` are classified as tests by `getScope()`, throwing `SELECTED_FILES_ARENT_SOLIDITY_TESTS` if not - Branch on `hre.config.solidity.splitTestsCompilation` - Unified mode: diff --git a/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index 84ec216bb5a..7978e69f380 100644 --- a/packages/hardhat-errors/src/descriptors.ts +++ b/packages/hardhat-errors/src/descriptors.ts @@ -1210,7 +1210,7 @@ Run \`hardhat build\` to compile your project before running tests with \`--no-c Double check the files that you are providing to the \`test solidity\` task`, websiteTitle: "Invalid Solidity test files", - websiteDescription: `You ran the \`test solidity\` task files that aren't clasified as Solidity tests..`, + websiteDescription: `You ran the \`test solidity\` task with files that aren't classified as Solidity tests.`, }, }, SOLIDITY: { diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts index 550bc036dae..1ee00b5e457 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts @@ -459,14 +459,14 @@ describe("solidity-test/task-action", function () { }); it("should throw when a selected test file exists but has not been compiled", async () => { - const unbuiltConfig = { + const notBuildConfig = { ...hardhatConfig, paths: { tests: { solidity: "test" } }, }; - const unbuiltHre = await createHardhatRuntimeEnvironment(unbuiltConfig); + const notBuiltHre = await createHardhatRuntimeEnvironment(notBuildConfig); try { - await unbuiltHre.tasks.getTask(["test", "solidity"]).run({ + await notBuiltHre.tasks.getTask(["test", "solidity"]).run({ noCompile: true, testFiles: ["./test/not-in-test-path.t.sol"], }); From 04602723b3f28b07fc95db09689d95b5f5fe4ea3 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 18:00:02 -0300 Subject: [PATCH 39/83] Minor simplification --- .../src/internal/builtin-plugins/solidity/tasks/build.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts index 15b0bc85c12..80227e00ce6 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts @@ -400,15 +400,13 @@ async function partitionRootPathsByScope( * If it's a relative path it's resolved from the CWD. */ function normalizedRootPaths(files: string[]): string[] { - const normalizedPaths = files.map((f) => { + return files.map((f) => { if (isNpmRootPath(f)) { return f; } return resolveFromRoot(process.cwd(), f); }); - - return normalizedPaths; } export default buildAction; From a1fa452bc611d905361fe02851809f4bdcad2ce0 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 09:11:53 -0300 Subject: [PATCH 40/83] Disable spellcheck in the spec file --- SPLIT_TESTS_COMPILATION_SPEC.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SPLIT_TESTS_COMPILATION_SPEC.md b/SPLIT_TESTS_COMPILATION_SPEC.md index 19516485b67..00e6f787cc3 100644 --- a/SPLIT_TESTS_COMPILATION_SPEC.md +++ b/SPLIT_TESTS_COMPILATION_SPEC.md @@ -1,3 +1,5 @@ +/_ cSpell:disable _/ + # Spec: `splitTestsCompilation` Config Field ## Overview From 8fee5842dbb13c800cc8aa818e8d23056e6ab2c9 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 09:13:04 -0300 Subject: [PATCH 41/83] Improve type-safety --- .../src/internal/builtin-plugins/solidity-test/task-action.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index 533354b9f4c..899260bcc8f 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -373,7 +373,7 @@ async function validateThatProvidedFilesAreTests( testFiles: string[], resolvedTestFilesArgument: string[], ) { - const nonTests = []; + const nonTests: string[] = []; for (let i = 0; i < resolvedTestFilesArgument.length; i++) { const rootPath = resolvedTestFilesArgument[i]; const scope = await solidity.getScope(rootPath); From bda6aa625ef2014c922ad02f8c00bbbeeb78674e Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 09:22:30 -0300 Subject: [PATCH 42/83] spellcheck --- SPLIT_TESTS_COMPILATION_SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPLIT_TESTS_COMPILATION_SPEC.md b/SPLIT_TESTS_COMPILATION_SPEC.md index 00e6f787cc3..a7ad3daa40b 100644 --- a/SPLIT_TESTS_COMPILATION_SPEC.md +++ b/SPLIT_TESTS_COMPILATION_SPEC.md @@ -1,4 +1,4 @@ -/_ cSpell:disable _/ + # Spec: `splitTestsCompilation` Config Field From 835dd6e72937924dc364405c701e866304859985 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 09:24:56 -0300 Subject: [PATCH 43/83] Get scopes in parallel --- .../builtin-plugins/solidity-test/task-action.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index 899260bcc8f..3165104e948 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -373,14 +373,11 @@ async function validateThatProvidedFilesAreTests( testFiles: string[], resolvedTestFilesArgument: string[], ) { - const nonTests: string[] = []; - for (let i = 0; i < resolvedTestFilesArgument.length; i++) { - const rootPath = resolvedTestFilesArgument[i]; - const scope = await solidity.getScope(rootPath); - if (scope !== "tests") { - nonTests.push(testFiles[i]); - } - } + const scopes = await Promise.all( + resolvedTestFilesArgument.map((rootPath) => solidity.getScope(rootPath)), + ); + + const nonTests: string[] = testFiles.filter((_, i) => scopes[i] !== "tests"); if (nonTests.length > 0) { throw new HardhatError( From 43f99fb942a65cb8b0e1712324318442520705c8 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 09:28:22 -0300 Subject: [PATCH 44/83] Fix typo --- packages/hardhat-errors/src/descriptors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index 7978e69f380..db03fa3296b 100644 --- a/packages/hardhat-errors/src/descriptors.ts +++ b/packages/hardhat-errors/src/descriptors.ts @@ -1208,7 +1208,7 @@ Run \`hardhat build\` to compile your project before running tests with \`--no-c {files} -Double check the files that you are providing to the \`test solidity\` task`, +Double-check the files that you are providing to the \`test solidity\` task`, websiteTitle: "Invalid Solidity test files", websiteDescription: `You ran the \`test solidity\` task with files that aren't classified as Solidity tests.`, }, From a63341e9ba9d6e215f5613e6ac39ce17295afad0 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 09:28:31 -0300 Subject: [PATCH 45/83] Fix test files resolution --- .../src/internal/builtin-plugins/solidity-test/task-action.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index 3165104e948..4ff8f5f5b40 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -62,7 +62,7 @@ const runSolidityTests: NewTaskActionFunction = async ( const verbosity = hre.globalOptions.verbosity; const resolvedTestFilesArgument = testFiles.map((f) => - resolveFromRoot(hre.config.paths.root, f), + resolveFromRoot(process.cwd(), f), ); await validateThatProvidedFilesAreTests( From 3dd4360e78b14d52523b2e1e7163e501bcb2e2d1 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 09:45:25 -0300 Subject: [PATCH 46/83] Validate that the provided test files exist --- packages/hardhat-errors/src/descriptors.ts | 10 ++++++++++ .../solidity-test/task-action.ts | 17 ++++++++++++++++- .../solidity-test/task-action.ts | 13 +++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index db03fa3296b..bdb54ea1b46 100644 --- a/packages/hardhat-errors/src/descriptors.ts +++ b/packages/hardhat-errors/src/descriptors.ts @@ -1212,6 +1212,16 @@ Double-check the files that you are providing to the \`test solidity\` task`, websiteTitle: "Invalid Solidity test files", websiteDescription: `You ran the \`test solidity\` task with files that aren't classified as Solidity tests.`, }, + SELECTED_TEST_FILES_DO_NOT_EXIST: { + number: 816, + messageTemplate: `The following Solidity test files do not exist: + +{files} + +Double-check the paths you are providing to the \`test solidity\` task.`, + websiteTitle: "Selected Solidity test files do not exist", + websiteDescription: `You ran the \`test solidity\` task with files that do not exist on disk.`, + }, }, SOLIDITY: { PROJECT_ROOT_RESOLUTION_ERROR: { diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index 4ff8f5f5b40..856084d606b 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -145,7 +145,7 @@ const runSolidityTests: NewTaskActionFunction = async ( const notCompiledFiles: string[] = []; for (const root of testRootPathsToRun) { - if (!compiledSources.has(root) && (await exists(root))) { + if (!compiledSources.has(root)) { notCompiledFiles.push(root); } } @@ -373,6 +373,21 @@ async function validateThatProvidedFilesAreTests( testFiles: string[], resolvedTestFilesArgument: string[], ) { + const existsResults = await Promise.all( + resolvedTestFilesArgument.map((rootPath) => exists(rootPath)), + ); + + const missing: string[] = testFiles.filter((_, i) => !existsResults[i]); + + if (missing.length > 0) { + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.SELECTED_TEST_FILES_DO_NOT_EXIST, + { + files: missing.map((f) => `- ${f}`).join("\n"), + }, + ); + } + const scopes = await Promise.all( resolvedTestFilesArgument.map((rootPath) => solidity.getScope(rootPath)), ); diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts index 1ee00b5e457..5caeff90bf0 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts @@ -483,6 +483,19 @@ describe("solidity-test/task-action", function () { ); } }); + + it("should throw when a selected test file does not exist on disk", async () => { + hre = await createHardhatRuntimeEnvironment(hardhatConfigPartialTests); + await assertRejectsWithHardhatError( + hre.tasks.getTask(["test", "solidity"]).run({ + noCompile: true, + testFiles: ["./test/contracts/partial/DoesNotExist.t.sol"], + }), + HardhatError.ERRORS.CORE.SOLIDITY_TESTS + .SELECTED_TEST_FILES_DO_NOT_EXIST, + { files: "- ./test/contracts/partial/DoesNotExist.t.sol" }, + ); + }); }); it("should support EIP-7212 precompile at address 0x100", async () => { From 3322ffd12249b2c3aeacc6231f923da12fc73c81 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 18:19:29 -0300 Subject: [PATCH 47/83] Add integration tests for the ArtifactManager in unified build mode --- .../build-system/integration/artifacts.ts | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/artifacts.ts diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/artifacts.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/artifacts.ts new file mode 100644 index 00000000000..8ba937b00cf --- /dev/null +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/artifacts.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { assertRejectsWithHardhatError } from "@nomicfoundation/hardhat-test-utils"; +import { exists } from "@nomicfoundation/hardhat-utils/fs"; + +import { useTestProjectTemplate } from "../resolver/helpers.js"; + +const basicProjectTemplate = { + name: "test", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: UNLICENSED \n pragma solidity ^0.8.0; contract Foo {}`, + "contracts/Foo.t.sol": ` + // SPDX-License-Identifier: UNLICENSED + pragma solidity ^0.8.0; + + import {Foo} from "./Foo.sol"; + + contract FooTest { + Foo foo; + + function setUp() public { + foo = new Foo(); + } + + function test_Assertion() public view { + require(1 == 1, "test assertion"); + } + } + `, + "test/OtherFooTest.sol": ` + // SPDX-License-Identifier: UNLICENSED + pragma solidity ^0.8.0; + + import {Foo} from "../contracts/Foo.sol"; + + contract OtherFooTest { + Foo foo; + + function setUp() public { + foo = new Foo(); + } + + function test_Assertion() public view { + require(1 == 1, "test assertion"); + } + } + `, + }, +}; + +const unifiedTestsCompilationConfig = { + solidity: { + version: "0.8.28", + splitTestsCompilation: false, + }, +}; + +describe("artifact API in unified mode", function () { + it("getAllArtifactPaths includes test artifacts", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await hre.tasks.getTask("build").run(); + + const allPaths = await hre.artifacts.getAllArtifactPaths(); + const pathsArray = Array.from(allPaths); + + assert.ok( + pathsArray.some((p) => p.includes("Foo.sol") && !p.includes(".t.sol")), + "Expected contract artifact path in getAllArtifactPaths", + ); + assert.ok( + pathsArray.some((p) => p.includes("Foo.t.sol")), + "Expected test artifact path (Foo.t.sol) in getAllArtifactPaths", + ); + assert.ok( + pathsArray.some((p) => p.includes("OtherFooTest.sol")), + "Expected test artifact path (OtherFooTest.sol) in getAllArtifactPaths", + ); + }); + + it("getAllFullyQualifiedNames includes test artifacts", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await hre.tasks.getTask("build").run(); + + const allNames = await hre.artifacts.getAllFullyQualifiedNames(); + + assert.ok( + allNames.has("contracts/Foo.sol:Foo"), + "Expected contract FQN in getAllFullyQualifiedNames", + ); + assert.ok( + allNames.has("contracts/Foo.t.sol:FooTest"), + "Expected test FQN (FooTest) in getAllFullyQualifiedNames", + ); + assert.ok( + allNames.has("test/OtherFooTest.sol:OtherFooTest"), + "Expected test FQN (OtherFooTest) in getAllFullyQualifiedNames", + ); + }); + + it("bare-name lookup becomes ambiguous when a test and contract share a name", async () => { + const duplicateNameTemplate = { + name: "test", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.0;\ncontract Foo {}`, + "test/Foo.sol": `// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.0;\ncontract Foo {}`, + }, + }; + + await using project = await useTestProjectTemplate(duplicateNameTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await hre.tasks.getTask("build").run(); + await hre.artifacts.clearCache(); + + // Bare-name lookup should throw because both contract and test + // produce artifacts named "Foo" + await assertRejectsWithHardhatError( + hre.artifacts.readArtifact("Foo"), + HardhatError.ERRORS.CORE.ARTIFACTS.MULTIPLE_FOUND, + { + contractName: "Foo", + candidates: "contracts/Foo.sol:Foo\ntest/Foo.sol:Foo", + }, + ); + }); + + it("fully qualified name lookup still works when a test and contract share a name", async () => { + const duplicateNameTemplate = { + name: "test", + version: "1.0.0", + files: { + "contracts/Foo.sol": `// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.0;\ncontract Foo {}`, + "test/Foo.sol": `// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.0;\ncontract Foo {}`, + }, + }; + + await using project = await useTestProjectTemplate(duplicateNameTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await hre.tasks.getTask("build").run(); + await hre.artifacts.clearCache(); + + const contractArtifact = await hre.artifacts.readArtifact( + "contracts/Foo.sol:Foo", + ); + assert.equal(contractArtifact.contractName, "Foo"); + + const testArtifact = await hre.artifacts.readArtifact("test/Foo.sol:Foo"); + assert.equal(testArtifact.contractName, "Foo"); + }); + + it("test roots do not get per-source artifacts.d.ts", async () => { + await using project = await useTestProjectTemplate(basicProjectTemplate); + const hre = await project.getHRE(unifiedTestsCompilationConfig); + + await hre.tasks.getTask("build").run(); + + const artifactsPath = await hre.solidity.getArtifactsDirectory("contracts"); + + // Contract root should have artifacts.d.ts + assert.equal( + await exists( + path.join(artifactsPath, "contracts", "Foo.sol", "artifacts.d.ts"), + ), + true, + ); + + // Test roots should NOT have artifacts.d.ts + assert.equal( + await exists( + path.join(artifactsPath, "contracts", "Foo.t.sol", "artifacts.d.ts"), + ), + false, + ); + assert.equal( + await exists( + path.join(artifactsPath, "test", "OtherFooTest.sol", "artifacts.d.ts"), + ), + false, + ); + }); +}); From 3ee06b257e0544cb9ba4b3cf62370270a5c8d7d9 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 18:19:47 -0300 Subject: [PATCH 48/83] Update the typechain plugin to filter out tests in unified mode --- .../src/internal/hook-handlers/solidity.ts | 35 ++++++- .../unified-mode/contracts/A.sol | 8 ++ .../unified-mode/contracts/A.t.sol | 16 ++++ .../unified-mode/hardhat.config.ts | 14 +++ .../unified-mode/package.json | 5 + .../unified-mode/test/BTest.sol | 8 ++ packages/hardhat-typechain/test/index.ts | 93 ++++++++++++++++++- 7 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 packages/hardhat-typechain/test/fixture-projects/unified-mode/contracts/A.sol create mode 100644 packages/hardhat-typechain/test/fixture-projects/unified-mode/contracts/A.t.sol create mode 100644 packages/hardhat-typechain/test/fixture-projects/unified-mode/hardhat.config.ts create mode 100644 packages/hardhat-typechain/test/fixture-projects/unified-mode/package.json create mode 100644 packages/hardhat-typechain/test/fixture-projects/unified-mode/test/BTest.sol diff --git a/packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts b/packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts index 86da136dcd9..e492f549731 100644 --- a/packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts +++ b/packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts @@ -5,6 +5,8 @@ import type { FileBuildResult, } from "hardhat/types/solidity"; +import path from "node:path"; + import { generateTypes } from "../generate-types.js"; export default async (): Promise> => { @@ -37,11 +39,42 @@ export default async (): Promise> => { // Get all artifact paths and generate types const allArtifactPaths = await context.artifacts.getAllArtifactPaths(); + let artifactPaths = Array.from(allArtifactPaths); + + // When splitTestsCompilation is disabled, contract and test artifacts + // live in the same directory. Filter out test artifacts so TypeChain + // only generates types for contracts. + if (!context.config.solidity.splitTestsCompilation) { + const artifactsRoot = context.config.paths.artifacts; + const projectRoot = context.config.paths.root; + + const filtered: string[] = []; + for (const artifactPath of artifactPaths) { + // Derive the source file path from the artifact path. + // TODO: Reconstructing the path shouldn't be necessary + const relativeFromArtifacts = path.relative( + artifactsRoot, + artifactPath, + ); + + const parts = relativeFromArtifacts.split(path.sep); + const sourceRelative = parts.slice(0, -1).join(path.sep); + const sourcePath = path.resolve(projectRoot, sourceRelative); + + const scope = await context.solidity.getScope(sourcePath); + if (scope === "contracts") { + filtered.push(artifactPath); + } + } + + artifactPaths = filtered; + } + await generateTypes( context.config.paths.root, context.config.typechain, context.globalOptions.noTypechain, - Array.from(allArtifactPaths), + artifactPaths, ); return result; diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode/contracts/A.sol b/packages/hardhat-typechain/test/fixture-projects/unified-mode/contracts/A.sol new file mode 100644 index 00000000000..7b6c923235f --- /dev/null +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode/contracts/A.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract A { + function getMessage() external pure returns (string memory) { + return "Hello from A contract!"; + } +} diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode/contracts/A.t.sol b/packages/hardhat-typechain/test/fixture-projects/unified-mode/contracts/A.t.sol new file mode 100644 index 00000000000..dcbac4fed0f --- /dev/null +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode/contracts/A.t.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {A} from "./A.sol"; + +contract ATest { + A a; + + function setUp() public { + a = new A(); + } + + function test_Assertion() public view { + require(1 == 1, "test assertion"); + } +} diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode/hardhat.config.ts b/packages/hardhat-typechain/test/fixture-projects/unified-mode/hardhat.config.ts new file mode 100644 index 00000000000..162f9153559 --- /dev/null +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode/hardhat.config.ts @@ -0,0 +1,14 @@ +import type { HardhatUserConfig } from "hardhat/config"; + +// eslint-disable-next-line import/no-relative-packages -- allow in fixture projects +import hardhatTypechain from "../../../src/index.js"; + +const config: HardhatUserConfig = { + solidity: { + version: "0.8.28", + splitTestsCompilation: false, + }, + plugins: [hardhatTypechain], +}; + +export default config; diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode/package.json b/packages/hardhat-typechain/test/fixture-projects/unified-mode/package.json new file mode 100644 index 00000000000..7b687e5d0dc --- /dev/null +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode/package.json @@ -0,0 +1,5 @@ +{ + "name": "hardhat-project", + "private": "true", + "type": "module" +} diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode/test/BTest.sol b/packages/hardhat-typechain/test/fixture-projects/unified-mode/test/BTest.sol new file mode 100644 index 00000000000..3f5a4367051 --- /dev/null +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode/test/BTest.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract BTest { + function test_Assertion() public view { + require(1 == 1, "test assertion"); + } +} diff --git a/packages/hardhat-typechain/test/index.ts b/packages/hardhat-typechain/test/index.ts index e1983075a45..11898efd21e 100644 --- a/packages/hardhat-typechain/test/index.ts +++ b/packages/hardhat-typechain/test/index.ts @@ -355,7 +355,14 @@ describe("hardhat-typechain", () => { `./fixture-projects/${projectFolder}/hardhat.config.js` ); - const hre = await createHardhatRuntimeEnvironment(hardhatConfig.default); + // scope: "tests" requires splitTestsCompilation: true + const hre = await createHardhatRuntimeEnvironment({ + ...hardhatConfig.default, + solidity: { + ...hardhatConfig.default.solidity, + splitTestsCompilation: true, + }, + }); await hre.tasks.getTask("clean").run(); @@ -371,6 +378,90 @@ describe("hardhat-typechain", () => { }); }); + describe("types are not generated for test artifacts when splitTestsCompilation is false", () => { + const projectFolder = "unified-mode"; + + useFixtureProject(projectFolder); + + before(async () => { + await remove(`${process.cwd()}/types`); + + const hardhatConfig = await import( + `./fixture-projects/${projectFolder}/hardhat.config.js` + ); + + const hre = await createHardhatRuntimeEnvironment(hardhatConfig.default); + + await hre.tasks.getTask("clean").run(); + await hre.tasks.getTask("build").run(); + }); + + it("should generate types for contract artifacts", async () => { + assert.equal(await exists(`${process.cwd()}/types`), true); + + // Contract A should have types generated + assert.equal( + await exists( + path.join(process.cwd(), "types", "ethers-contracts", "A.ts"), + ), + true, + ); + }); + + it("should not generate types for .t.sol test artifacts", async () => { + // ATest from contracts/A.t.sol should NOT have types + assert.equal( + await exists( + path.join(process.cwd(), "types", "ethers-contracts", "ATest.ts"), + ), + false, + ); + }); + + it("should not generate types for test directory artifacts", async () => { + // BTest from test/BTest.sol should NOT have types + assert.equal( + await exists( + path.join(process.cwd(), "types", "ethers-contracts", "BTest.ts"), + ), + false, + ); + }); + + it("should classify artifacts using getScope, not artifact-path heuristics", async () => { + // The A.t.sol test file imports A.sol, so A.sol's contract artifact + // appears under contracts/A.sol/ (a contract path). The filter must + // use getScope() on the source file, not a path-based heuristic. + // A.sol is a contract, so its type should be generated. + assert.equal( + await exists( + path.join( + process.cwd(), + "types", + "ethers-contracts", + "factories", + "A__factory.ts", + ), + ), + true, + ); + + // ATest is a test (from .t.sol), so its factory should NOT exist + assert.equal( + await exists( + path.join( + process.cwd(), + "types", + "ethers-contracts", + "factories", + "ATest__factory.ts", + ), + ), + false, + ); + }); + }); + describe("clean hook removes the types folder", () => { describe("with default outDir", () => { const projectFolder = "generate-types"; From 3997e74e44bdcd33ee4acffdedcc0fb8baa3c7f5 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 18:32:05 -0300 Subject: [PATCH 49/83] Add a test of an npm root --- .../unified-mode-npm/contracts/A.sol | 8 +++ .../unified-mode-npm/hardhat.config.ts | 1 + .../node_modules/@fake/lib/Token.sol | 8 +++ .../node_modules/@fake/lib/package.json | 4 ++ .../unified-mode-npm/package.json | 1 + packages/hardhat-typechain/test/index.ts | 51 +++++++++++++++++++ 6 files changed, 73 insertions(+) create mode 100644 packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/contracts/A.sol create mode 100644 packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/hardhat.config.ts create mode 100644 packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/@fake/lib/Token.sol create mode 100644 packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/@fake/lib/package.json create mode 100644 packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/package.json diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/contracts/A.sol b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/contracts/A.sol new file mode 100644 index 00000000000..7b6c923235f --- /dev/null +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/contracts/A.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract A { + function getMessage() external pure returns (string memory) { + return "Hello from A contract!"; + } +} diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/hardhat.config.ts b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/hardhat.config.ts new file mode 100644 index 00000000000..ff8b4c56321 --- /dev/null +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/hardhat.config.ts @@ -0,0 +1 @@ +export default {}; diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/@fake/lib/Token.sol b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/@fake/lib/Token.sol new file mode 100644 index 00000000000..34037a91695 --- /dev/null +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/@fake/lib/Token.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract Token { + function name() external pure returns (string memory) { + return "FakeToken"; + } +} diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/@fake/lib/package.json b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/@fake/lib/package.json new file mode 100644 index 00000000000..24b444ac72a --- /dev/null +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/@fake/lib/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fake/lib", + "version": "1.0.0" +} \ No newline at end of file diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/package.json b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/hardhat-typechain/test/index.ts b/packages/hardhat-typechain/test/index.ts index 11898efd21e..8656467b9cb 100644 --- a/packages/hardhat-typechain/test/index.ts +++ b/packages/hardhat-typechain/test/index.ts @@ -462,6 +462,57 @@ describe("hardhat-typechain", () => { }); }); + describe("npm-dependency artifacts are classified as contracts in unified mode", () => { + useFixtureProject("unified-mode-npm"); + + before(async () => { + await remove(`${process.cwd()}/types`); + + const hre = await createHardhatRuntimeEnvironment({ + solidity: { + version: "0.8.28", + splitTestsCompilation: false, + npmFilesToBuild: ["@fake/lib/Token.sol"], + }, + plugins: [hardhatTypechain], + }); + + await hre.tasks.getTask("clean").run(); + await hre.tasks.getTask("build").run(); + }); + + it("should generate types for the npm-dependency artifact", async () => { + assert.equal( + await exists( + path.join( + process.cwd(), + "types", + "ethers-contracts", + "@fake", + "lib", + "Token.ts", + ), + ), + true, + ); + }); + + it("should generate types for the local contract artifact", async () => { + assert.equal( + await exists( + path.join( + process.cwd(), + "types", + "ethers-contracts", + "contracts", + "A.ts", + ), + ), + true, + ); + }); + }); + describe("clean hook removes the types folder", () => { describe("with default outDir", () => { const projectFolder = "generate-types"; From 5c3dedd941897a139b8c58a41b06b25a03af1e29 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 13:04:43 -0300 Subject: [PATCH 50/83] Remove duplicated test --- .../build-system/integration/artifacts.ts | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/artifacts.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/artifacts.ts index 8ba937b00cf..d89a7d80f26 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/artifacts.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/artifacts.ts @@ -157,35 +157,4 @@ describe("artifact API in unified mode", function () { const testArtifact = await hre.artifacts.readArtifact("test/Foo.sol:Foo"); assert.equal(testArtifact.contractName, "Foo"); }); - - it("test roots do not get per-source artifacts.d.ts", async () => { - await using project = await useTestProjectTemplate(basicProjectTemplate); - const hre = await project.getHRE(unifiedTestsCompilationConfig); - - await hre.tasks.getTask("build").run(); - - const artifactsPath = await hre.solidity.getArtifactsDirectory("contracts"); - - // Contract root should have artifacts.d.ts - assert.equal( - await exists( - path.join(artifactsPath, "contracts", "Foo.sol", "artifacts.d.ts"), - ), - true, - ); - - // Test roots should NOT have artifacts.d.ts - assert.equal( - await exists( - path.join(artifactsPath, "contracts", "Foo.t.sol", "artifacts.d.ts"), - ), - false, - ); - assert.equal( - await exists( - path.join(artifactsPath, "test", "OtherFooTest.sol", "artifacts.d.ts"), - ), - false, - ); - }); }); From a130e61b852afa39ea37898220211242679bf802 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 13:05:25 -0300 Subject: [PATCH 51/83] Sort FQNs when multiple candidates are found for a bare contract name --- .../internal/builtin-plugins/artifacts/artifact-manager.ts | 2 +- .../solidity/build-system/integration/artifacts.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/artifacts/artifact-manager.ts b/packages/hardhat/src/internal/builtin-plugins/artifacts/artifact-manager.ts index c13f5d46475..f27bfca4a53 100644 --- a/packages/hardhat/src/internal/builtin-plugins/artifacts/artifact-manager.ts +++ b/packages/hardhat/src/internal/builtin-plugins/artifacts/artifact-manager.ts @@ -191,7 +191,7 @@ export class ArtifactManagerImplementation implements ArtifactManager { HardhatError.ERRORS.CORE.ARTIFACTS.MULTIPLE_FOUND, { contractName: contractNameOrFullyQualifiedName, - candidates: Array.from(fqns).join(EOL), + candidates: Array.from(fqns).sort().join(EOL), }, ); } diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/artifacts.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/artifacts.ts index d89a7d80f26..dc62a609fd3 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/artifacts.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/artifacts.ts @@ -1,10 +1,9 @@ import assert from "node:assert/strict"; -import path from "node:path"; +import { EOL } from "node:os"; import { describe, it } from "node:test"; import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { assertRejectsWithHardhatError } from "@nomicfoundation/hardhat-test-utils"; -import { exists } from "@nomicfoundation/hardhat-utils/fs"; import { useTestProjectTemplate } from "../resolver/helpers.js"; @@ -128,7 +127,7 @@ describe("artifact API in unified mode", function () { HardhatError.ERRORS.CORE.ARTIFACTS.MULTIPLE_FOUND, { contractName: "Foo", - candidates: "contracts/Foo.sol:Foo\ntest/Foo.sol:Foo", + candidates: `contracts/Foo.sol:Foo${EOL}test/Foo.sol:Foo`, }, ); }); From a78068cc3fbc25de79fa7567c2c078f67dbf0964 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 13:25:14 -0300 Subject: [PATCH 52/83] Fix three path comparision bugs in the build system --- .../solidity/build-system/build-system.ts | 6 +- .../solidity/build-system/build-system.ts | 102 ++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts index 08e238d3f2a..c04662110e4 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts @@ -155,7 +155,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { public async getScope(fsPath: string): Promise { if ( - fsPath.startsWith(this.#options.solidityTestsPath) && + fsPath.startsWith(this.#options.solidityTestsPath + path.sep) && fsPath.endsWith(".sol") ) { return "tests"; @@ -163,7 +163,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { if (fsPath.endsWith(".t.sol")) { for (const sourcesPath of this.#options.soliditySourcesPaths) { - if (fsPath.startsWith(sourcesPath)) { + if (fsPath.startsWith(sourcesPath + path.sep)) { return "tests"; } } @@ -1122,7 +1122,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { // Get all the reachable build info files const buildInfoFiles = await getAllFilesMatching(buildInfosDir, (f) => - f.startsWith(buildInfosDir), + f.startsWith(buildInfosDir + path.sep), ); for (const buildInfoFile of buildInfoFiles) { diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/build-system.ts b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/build-system.ts index a007af44c35..a611b7fe9ca 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/build-system.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity/build-system/build-system.ts @@ -410,3 +410,105 @@ describe( }); }, ); + +describe("SolidityBuildSystemImplementation.getScope", () => { + const projectRoot = path.join(path.sep, "project"); + const solidityTestsPath = path.join(projectRoot, "tests"); + const soliditySourcesPaths = [path.join(projectRoot, "contracts")]; + + function makeBuildSystem(): SolidityBuildSystemImplementation { + const hooks = new HookManagerImplementation(projectRoot, []); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- hooks context is irrelevant for getScope + hooks.setContext({} as HookContext); + const solidityConfig: SolidityConfig = { + profiles: { + default: { + compilers: [], + overrides: {}, + isolated: false, + preferWasm: false, + }, + }, + npmFilesToBuild: [], + registeredCompilerTypes: ["solc"], + splitTestsCompilation: false, + }; + return new SolidityBuildSystemImplementation(hooks, { + solidityConfig, + projectRoot, + soliditySourcesPaths, + artifactsPath: path.join(projectRoot, "artifacts"), + cachePath: path.join(projectRoot, "cache"), + solidityTestsPath, + }); + } + + const solidity = makeBuildSystem(); + + it("returns 'tests' for a .sol file directly inside solidityTestsPath", async () => { + assert.equal( + await solidity.getScope(path.join(solidityTestsPath, "Foo.sol")), + "tests", + ); + }); + + it("returns 'tests' for a .sol file nested inside solidityTestsPath", async () => { + assert.equal( + await solidity.getScope(path.join(solidityTestsPath, "sub", "Foo.sol")), + "tests", + ); + }); + + it("returns 'contracts' for a file in a directory whose name is a prefix of solidityTestsPath", async () => { + assert.equal( + await solidity.getScope(path.join(projectRoot, "tests-extra", "Foo.sol")), + "contracts", + ); + }); + + it("returns 'contracts' for a plain .sol file inside a sources path", async () => { + assert.equal( + await solidity.getScope(path.join(soliditySourcesPaths[0], "Foo.sol")), + "contracts", + ); + }); + + it("returns 'tests' for a .t.sol file inside a sources path", async () => { + assert.equal( + await solidity.getScope(path.join(soliditySourcesPaths[0], "Foo.t.sol")), + "tests", + ); + }); + + it("returns 'tests' for a .t.sol file nested inside a sources path", async () => { + assert.equal( + await solidity.getScope( + path.join(soliditySourcesPaths[0], "sub", "Foo.t.sol"), + ), + "tests", + ); + }); + + it("returns 'contracts' for a .t.sol file in a directory whose name is a prefix of a sources path", async () => { + assert.equal( + await solidity.getScope( + path.join(projectRoot, "contracts-extra", "Foo.t.sol"), + ), + "contracts", + ); + }); + + it("returns 'contracts' for a .t.sol file outside any sources path", async () => { + assert.equal( + await solidity.getScope(path.join(projectRoot, "elsewhere", "Foo.t.sol")), + "contracts", + ); + }); + + it("returns 'contracts' for a .sol file outside any sources or tests path", async () => { + assert.equal( + await solidity.getScope(path.join(projectRoot, "elsewhere", "Foo.sol")), + "contracts", + ); + }); +}); From 0eff216ad18a2173c79ce2977bd57fdbdc7ca92e Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 13:25:41 -0300 Subject: [PATCH 53/83] Update how the typechain hook get the right contract paths in unified mode --- .../src/internal/hook-handlers/solidity.ts | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts b/packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts index e492f549731..1530beb2e17 100644 --- a/packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts +++ b/packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts @@ -1,6 +1,7 @@ import type { HookContext, SolidityHooks } from "hardhat/types/hooks"; import type { BuildOptions, + BuildScope, CompilationJobCreationError, FileBuildResult, } from "hardhat/types/solidity"; @@ -36,38 +37,18 @@ export default async (): Promise> => { // Clear cache to ensure fresh data after compilation await context.artifacts.clearCache(); - // Get all artifact paths and generate types - const allArtifactPaths = await context.artifacts.getAllArtifactPaths(); - - let artifactPaths = Array.from(allArtifactPaths); - - // When splitTestsCompilation is disabled, contract and test artifacts - // live in the same directory. Filter out test artifacts so TypeChain - // only generates types for contracts. - if (!context.config.solidity.splitTestsCompilation) { - const artifactsRoot = context.config.paths.artifacts; - const projectRoot = context.config.paths.root; - - const filtered: string[] = []; - for (const artifactPath of artifactPaths) { - // Derive the source file path from the artifact path. - // TODO: Reconstructing the path shouldn't be necessary - const relativeFromArtifacts = path.relative( - artifactsRoot, - artifactPath, - ); - - const parts = relativeFromArtifacts.split(path.sep); - const sourceRelative = parts.slice(0, -1).join(path.sep); - const sourcePath = path.resolve(projectRoot, sourceRelative); - - const scope = await context.solidity.getScope(sourcePath); - if (scope === "contracts") { - filtered.push(artifactPath); - } - } - - artifactPaths = filtered; + let artifactPaths: string[]; + + if (context.config.solidity.splitTestsCompilation) { + artifactPaths = Array.from( + await context.artifacts.getAllArtifactPaths(), + ); + } else { + // Contracts and tests share the artifacts folder. + // Filter out test artifacts using each artifact's sourceName (derived + // from its fully qualified name), which is the project-relative or npm + // source identifier. + artifactPaths = await getContractArtifactPaths(context); } await generateTypes( @@ -83,3 +64,34 @@ export default async (): Promise> => { return handlers; }; + +async function getContractArtifactPaths( + context: HookContext, +): Promise { + const fqns = await context.artifacts.getAllFullyQualifiedNames(); + const projectRoot = context.config.paths.root; + + const scopeBySource = new Map(); + const contractFqns: string[] = []; + + for (const fqn of fqns) { + const sourceName = fqn.slice(0, fqn.lastIndexOf(":")); + + let scope = scopeBySource.get(sourceName); + if (scope === undefined) { + const fsPath = path.resolve(projectRoot, sourceName); + + // npm files will be classified as "contracts" because that's the default + scope = await context.solidity.getScope(fsPath); + scopeBySource.set(sourceName, scope); + } + + if (scope === "contracts") { + contractFqns.push(fqn); + } + } + + return Promise.all( + contractFqns.map((fqn) => context.artifacts.getArtifactPath(fqn)), + ); +} From 972369b31aa6ea11b99d229dba0b2feaa7d984f2 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 13:25:51 -0300 Subject: [PATCH 54/83] Update the tests --- packages/hardhat-typechain/.prettierignore | 2 +- .../node_modules/test-lib/Helper.sol | 8 ++++++++ .../node_modules/test-lib/package.json | 4 ++++ .../fixture-projects/unified-mode/package.json | 2 +- packages/hardhat-typechain/test/index.ts | 17 ++++++++++++++++- 5 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/test-lib/Helper.sol create mode 100644 packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/test-lib/package.json diff --git a/packages/hardhat-typechain/.prettierignore b/packages/hardhat-typechain/.prettierignore index f4ff47bd3eb..cf41b63d6de 100644 --- a/packages/hardhat-typechain/.prettierignore +++ b/packages/hardhat-typechain/.prettierignore @@ -4,5 +4,5 @@ CHANGELOG.md /test/fixture-projects/**/artifacts /test/fixture-projects/**/cache +/test/fixture-projects/**/types /test/fixture-projects/custom-out-dir/custom-types -test/fixture-projects/generate-types/types diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/test-lib/Helper.sol b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/test-lib/Helper.sol new file mode 100644 index 00000000000..f43850dd846 --- /dev/null +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/test-lib/Helper.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract Helper { + function ping() external pure returns (string memory) { + return "pong"; + } +} diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/test-lib/package.json b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/test-lib/package.json new file mode 100644 index 00000000000..aace75bf6b3 --- /dev/null +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode-npm/node_modules/test-lib/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-lib", + "version": "1.0.0" +} diff --git a/packages/hardhat-typechain/test/fixture-projects/unified-mode/package.json b/packages/hardhat-typechain/test/fixture-projects/unified-mode/package.json index 7b687e5d0dc..d9a203d79eb 100644 --- a/packages/hardhat-typechain/test/fixture-projects/unified-mode/package.json +++ b/packages/hardhat-typechain/test/fixture-projects/unified-mode/package.json @@ -1,5 +1,5 @@ { "name": "hardhat-project", - "private": "true", + "private": true, "type": "module" } diff --git a/packages/hardhat-typechain/test/index.ts b/packages/hardhat-typechain/test/index.ts index 8656467b9cb..b31c43bbd0d 100644 --- a/packages/hardhat-typechain/test/index.ts +++ b/packages/hardhat-typechain/test/index.ts @@ -472,7 +472,7 @@ describe("hardhat-typechain", () => { solidity: { version: "0.8.28", splitTestsCompilation: false, - npmFilesToBuild: ["@fake/lib/Token.sol"], + npmFilesToBuild: ["@fake/lib/Token.sol", "test-lib/Helper.sol"], }, plugins: [hardhatTypechain], }); @@ -497,6 +497,21 @@ describe("hardhat-typechain", () => { ); }); + it("should generate types for an unscoped npm package whose name starts with the tests dir name", async () => { + assert.equal( + await exists( + path.join( + process.cwd(), + "types", + "ethers-contracts", + "test-lib", + "Helper.ts", + ), + ), + true, + ); + }); + it("should generate types for the local contract artifact", async () => { assert.equal( await exists( From b80aa4a4698e2353b9087eb013cc3c4b7d59f7b7 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 16:37:51 -0300 Subject: [PATCH 55/83] Improve comment --- .../src/internal/hook-handlers/solidity.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts b/packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts index 1530beb2e17..9c99fd7cffd 100644 --- a/packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts +++ b/packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts @@ -81,7 +81,20 @@ async function getContractArtifactPaths( if (scope === undefined) { const fsPath = path.resolve(projectRoot, sourceName); - // npm files will be classified as "contracts" because that's the default + // npm files will be classified as "contracts" because their sourceName is + // not an existing file, and "contracts" is the default. + // + // If the package name clashed with + // ```ts + // path.relative( + // context.config.paths.root, + // context.config.paths.tests.solidity + // ) + // ``` + // + // They could be misclassified as test files. This is highly improbable, + // so we don't check it. You could read the artifact and see if the + // inputSourceName starts with `npm/` to rule this out. scope = await context.solidity.getScope(fsPath); scopeBySource.set(sourceName, scope); } From c43aaf55d145ca93b3eb245bcb34eedf9167038e Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 18:37:37 -0300 Subject: [PATCH 56/83] Update hardhat-mocha --- packages/hardhat-mocha/src/task-action.ts | 5 +- packages/hardhat-mocha/test/index.ts | 78 +++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/hardhat-mocha/src/task-action.ts b/packages/hardhat-mocha/src/task-action.ts index 80469b6f406..eb46fe5b3e6 100644 --- a/packages/hardhat-mocha/src/task-action.ts +++ b/packages/hardhat-mocha/src/task-action.ts @@ -82,9 +82,8 @@ const testWithHardhat: NewTaskActionFunction = async ( perf.startPhase("Build"); if (!noCompile) { - await hre.tasks.getTask("build").run({ - noTests: true, - }); + const noTests = hre.config.solidity.splitTestsCompilation; + await hre.tasks.getTask("build").run({ noTests }); console.log(); } diff --git a/packages/hardhat-mocha/test/index.ts b/packages/hardhat-mocha/test/index.ts index 549147c6638..c8430ce05d3 100644 --- a/packages/hardhat-mocha/test/index.ts +++ b/packages/hardhat-mocha/test/index.ts @@ -6,6 +6,9 @@ import { assertRejectsWithHardhatError, useFixtureProject, } from "@nomicfoundation/hardhat-test-utils"; +import { overrideTask } from "hardhat/config"; + +import HardhatMochaPlugin from "../src/index.js"; describe("Hardhat Mocha plugin", () => { describe("Success", () => { @@ -51,4 +54,79 @@ describe("Hardhat Mocha plugin", () => { ); }); }); + + describe("build invocation", () => { + useFixtureProject("test-project"); + + function buildArgCaptor() { + const buildArgs: any[] = []; + const buildOverride = overrideTask("build") + .setAction(async () => { + return { + default: (args: any) => { + buildArgs.push(args); + return { contractRootPaths: [], testRootPaths: [] }; + }, + }; + }) + .build(); + return { buildArgs, buildOverride }; + } + + it("should call build without noTests when splitTestsCompilation is false", async () => { + const { createHardhatRuntimeEnvironment } = await import("hardhat/hre"); + + const { buildArgs, buildOverride } = buildArgCaptor(); + const hre = await createHardhatRuntimeEnvironment({ + plugins: [HardhatMochaPlugin], + tasks: [buildOverride], + }); + + // The task may throw because of the ESM re-run guard, but we only + // care about the build invocation args captured before that point. + try { + await hre.tasks.getTask(["test", "mocha"]).run({}); + } catch {} + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, false); + }); + + it("should call build with noTests when splitTestsCompilation is true", async () => { + const { createHardhatRuntimeEnvironment } = await import("hardhat/hre"); + + const { buildArgs, buildOverride } = buildArgCaptor(); + const hre = await createHardhatRuntimeEnvironment({ + solidity: { + version: "0.8.28", + splitTestsCompilation: true, + }, + plugins: [HardhatMochaPlugin], + tasks: [buildOverride], + }); + + try { + await hre.tasks.getTask(["test", "mocha"]).run({}); + } catch {} + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, true); + }); + + it("should skip compilation when noCompile is true regardless of splitTestsCompilation", async () => { + const { createHardhatRuntimeEnvironment } = await import("hardhat/hre"); + + const { buildArgs, buildOverride } = buildArgCaptor(); + const hre = await createHardhatRuntimeEnvironment({ + plugins: [HardhatMochaPlugin], + tasks: [buildOverride], + }); + + try { + await hre.tasks.getTask(["test", "mocha"]).run({ noCompile: true }); + } catch {} + + assert.equal(buildArgs.length, 0); + }); + }); }); From d78de32e0ae6f932415a218ec04fa19d45a04ca1 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 13:36:58 -0300 Subject: [PATCH 57/83] Cleanup the tests --- packages/hardhat-mocha/test/index.ts | 32 ++++++++++++++++++---------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/hardhat-mocha/test/index.ts b/packages/hardhat-mocha/test/index.ts index c8430ce05d3..bf85836d73c 100644 --- a/packages/hardhat-mocha/test/index.ts +++ b/packages/hardhat-mocha/test/index.ts @@ -1,3 +1,5 @@ +import type { HardhatRuntimeEnvironment, TaskArguments } from "hardhat/types"; + import assert from "node:assert/strict"; import { describe, it } from "node:test"; @@ -6,6 +8,7 @@ import { assertRejectsWithHardhatError, useFixtureProject, } from "@nomicfoundation/hardhat-test-utils"; +import { ensureError } from "@nomicfoundation/hardhat-utils/error"; import { overrideTask } from "hardhat/config"; import HardhatMochaPlugin from "../src/index.js"; @@ -73,6 +76,21 @@ describe("Hardhat Mocha plugin", () => { return { buildArgs, buildOverride }; } + async function runMochaIgnoringEsmReRunErrors( + hre: HardhatRuntimeEnvironment, + args: TaskArguments = {}, + ) { + try { + await hre.tasks.getTask(["test", "mocha"]).run(args); + } catch (error) { + ensureError(error); + assert.match( + error.message, + /ESM and you've programmatically run your tests twice/i, + ); + } + } + it("should call build without noTests when splitTestsCompilation is false", async () => { const { createHardhatRuntimeEnvironment } = await import("hardhat/hre"); @@ -82,11 +100,7 @@ describe("Hardhat Mocha plugin", () => { tasks: [buildOverride], }); - // The task may throw because of the ESM re-run guard, but we only - // care about the build invocation args captured before that point. - try { - await hre.tasks.getTask(["test", "mocha"]).run({}); - } catch {} + await runMochaIgnoringEsmReRunErrors(hre); assert.equal(buildArgs.length, 1); assert.equal(buildArgs[0].noTests, false); @@ -105,9 +119,7 @@ describe("Hardhat Mocha plugin", () => { tasks: [buildOverride], }); - try { - await hre.tasks.getTask(["test", "mocha"]).run({}); - } catch {} + await runMochaIgnoringEsmReRunErrors(hre); assert.equal(buildArgs.length, 1); assert.equal(buildArgs[0].noTests, true); @@ -122,9 +134,7 @@ describe("Hardhat Mocha plugin", () => { tasks: [buildOverride], }); - try { - await hre.tasks.getTask(["test", "mocha"]).run({ noCompile: true }); - } catch {} + await runMochaIgnoringEsmReRunErrors(hre, { noCompile: true }); assert.equal(buildArgs.length, 0); }); From a551ab3f07f137609168d2a14490bd58c502d68d Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 16:18:19 -0300 Subject: [PATCH 58/83] Simplify tests --- packages/hardhat-mocha/test/index.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/hardhat-mocha/test/index.ts b/packages/hardhat-mocha/test/index.ts index bf85836d73c..e51a828136b 100644 --- a/packages/hardhat-mocha/test/index.ts +++ b/packages/hardhat-mocha/test/index.ts @@ -18,8 +18,6 @@ describe("Hardhat Mocha plugin", () => { useFixtureProject("test-project"); it("should work", async () => { - const { createHardhatRuntimeEnvironment } = await import("hardhat/hre"); - const hardhatConfig = await import( "./fixture-projects/test-project/hardhat.config.js" ); @@ -41,8 +39,6 @@ describe("Hardhat Mocha plugin", () => { useFixtureProject("invalid-mocha-config"); it("should fail", async () => { - const { createHardhatRuntimeEnvironment } = await import("hardhat/hre"); - const errors = "\t* Config error in config.test.mocha.delay: Expected boolean, received number"; @@ -92,8 +88,6 @@ describe("Hardhat Mocha plugin", () => { } it("should call build without noTests when splitTestsCompilation is false", async () => { - const { createHardhatRuntimeEnvironment } = await import("hardhat/hre"); - const { buildArgs, buildOverride } = buildArgCaptor(); const hre = await createHardhatRuntimeEnvironment({ plugins: [HardhatMochaPlugin], @@ -107,8 +101,6 @@ describe("Hardhat Mocha plugin", () => { }); it("should call build with noTests when splitTestsCompilation is true", async () => { - const { createHardhatRuntimeEnvironment } = await import("hardhat/hre"); - const { buildArgs, buildOverride } = buildArgCaptor(); const hre = await createHardhatRuntimeEnvironment({ solidity: { From c0f974105c3941331f8baea1a30e115467db3293 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 16:18:31 -0300 Subject: [PATCH 59/83] Test noCompile with and without splitTestsCompilation --- packages/hardhat-mocha/test/index.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/hardhat-mocha/test/index.ts b/packages/hardhat-mocha/test/index.ts index e51a828136b..579117d7b0f 100644 --- a/packages/hardhat-mocha/test/index.ts +++ b/packages/hardhat-mocha/test/index.ts @@ -10,6 +10,7 @@ import { } from "@nomicfoundation/hardhat-test-utils"; import { ensureError } from "@nomicfoundation/hardhat-utils/error"; import { overrideTask } from "hardhat/config"; +import { createHardhatRuntimeEnvironment } from "hardhat/hre"; import HardhatMochaPlugin from "../src/index.js"; @@ -117,9 +118,23 @@ describe("Hardhat Mocha plugin", () => { assert.equal(buildArgs[0].noTests, true); }); - it("should skip compilation when noCompile is true regardless of splitTestsCompilation", async () => { - const { createHardhatRuntimeEnvironment } = await import("hardhat/hre"); + it("should skip compilation when noCompile is true with splitTestsCompilation", async () => { + const { buildArgs, buildOverride } = buildArgCaptor(); + const hre = await createHardhatRuntimeEnvironment({ + solidity: { + version: "0.8.28", + splitTestsCompilation: true, + }, + plugins: [HardhatMochaPlugin], + tasks: [buildOverride], + }); + + await runMochaIgnoringEsmReRunErrors(hre, { noCompile: true }); + + assert.equal(buildArgs.length, 0); + }); + it("should skip compilation when noCompile is true without splitTestsCompilation", async () => { const { buildArgs, buildOverride } = buildArgCaptor(); const hre = await createHardhatRuntimeEnvironment({ plugins: [HardhatMochaPlugin], From f14d9755764b6cd78ae98d22d155527c5471e78c Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 18:40:33 -0300 Subject: [PATCH 60/83] Update hardhat-node-test-runner --- .../src/task-action.ts | 5 +- .../hardhat-node-test-runner/test/index.ts | 70 +++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/hardhat-node-test-runner/src/task-action.ts b/packages/hardhat-node-test-runner/src/task-action.ts index 238b085e0e1..b922d68872e 100644 --- a/packages/hardhat-node-test-runner/src/task-action.ts +++ b/packages/hardhat-node-test-runner/src/task-action.ts @@ -69,9 +69,8 @@ const testWithHardhat: NewTaskActionFunction = async ( setGlobalOptionsAsEnvVariables(hre.globalOptions); if (!noCompile) { - await hre.tasks.getTask("build").run({ - noTests: true, - }); + const noTests = hre.config.solidity.splitTestsCompilation; + await hre.tasks.getTask("build").run({ noTests }); console.log(); } diff --git a/packages/hardhat-node-test-runner/test/index.ts b/packages/hardhat-node-test-runner/test/index.ts index e9c0ad0cbfc..97edaf011d6 100644 --- a/packages/hardhat-node-test-runner/test/index.ts +++ b/packages/hardhat-node-test-runner/test/index.ts @@ -2,8 +2,11 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { useFixtureProject } from "@nomicfoundation/hardhat-test-utils"; +import { overrideTask } from "hardhat/config"; import { createHardhatRuntimeEnvironment } from "hardhat/hre"; +import HardhatNodeTestRunnerPlugin from "../src/index.js"; + describe("Hardhat Node plugin", () => { useFixtureProject("test-project"); @@ -44,4 +47,71 @@ describe("Hardhat Node plugin", () => { process.env.NODE_ENV = nodeEnv; } }); + + describe("build invocation", () => { + function buildArgCaptor() { + const buildArgs: any[] = []; + const buildOverride = overrideTask("build") + .setAction(async () => { + return { + default: (args: any) => { + buildArgs.push(args); + return { contractRootPaths: [], testRootPaths: [] }; + }, + }; + }) + .build(); + return { buildArgs, buildOverride }; + } + + it("should call build without noTests when splitTestsCompilation is false", async () => { + const { buildArgs, buildOverride } = buildArgCaptor(); + const hre = await createHardhatRuntimeEnvironment({ + plugins: [HardhatNodeTestRunnerPlugin], + tasks: [buildOverride], + }); + + // The task may throw because of the ESM re-run guard, but we only + // care about the build invocation args captured before that point. + try { + await hre.tasks.getTask(["test", "nodejs"]).run({}); + } catch {} + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, false); + }); + + it("should call build with noTests when splitTestsCompilation is true", async () => { + const { buildArgs, buildOverride } = buildArgCaptor(); + const hre = await createHardhatRuntimeEnvironment({ + solidity: { + version: "0.8.28", + splitTestsCompilation: true, + }, + plugins: [HardhatNodeTestRunnerPlugin], + tasks: [buildOverride], + }); + + try { + await hre.tasks.getTask(["test", "nodejs"]).run({}); + } catch {} + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, true); + }); + + it("should skip compilation when noCompile is true regardless of splitTestsCompilation", async () => { + const { buildArgs, buildOverride } = buildArgCaptor(); + const hre = await createHardhatRuntimeEnvironment({ + plugins: [HardhatNodeTestRunnerPlugin], + tasks: [buildOverride], + }); + + try { + await hre.tasks.getTask(["test", "nodejs"]).run({ noCompile: true }); + } catch {} + + assert.equal(buildArgs.length, 0); + }); + }); }); From 6695e099d1fc52d2255c87a7ede8ef20baf1a2cf Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 16:24:01 -0300 Subject: [PATCH 61/83] Improve tests --- .../hardhat-node-test-runner/test/index.ts | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/hardhat-node-test-runner/test/index.ts b/packages/hardhat-node-test-runner/test/index.ts index 97edaf011d6..a654ea88386 100644 --- a/packages/hardhat-node-test-runner/test/index.ts +++ b/packages/hardhat-node-test-runner/test/index.ts @@ -71,11 +71,7 @@ describe("Hardhat Node plugin", () => { tasks: [buildOverride], }); - // The task may throw because of the ESM re-run guard, but we only - // care about the build invocation args captured before that point. - try { - await hre.tasks.getTask(["test", "nodejs"]).run({}); - } catch {} + await hre.tasks.getTask(["test", "nodejs"]).run({}); assert.equal(buildArgs.length, 1); assert.equal(buildArgs[0].noTests, false); @@ -92,24 +88,36 @@ describe("Hardhat Node plugin", () => { tasks: [buildOverride], }); - try { - await hre.tasks.getTask(["test", "nodejs"]).run({}); - } catch {} + await hre.tasks.getTask(["test", "nodejs"]).run({}); assert.equal(buildArgs.length, 1); assert.equal(buildArgs[0].noTests, true); }); - it("should skip compilation when noCompile is true regardless of splitTestsCompilation", async () => { + it("should skip compilation when noCompile is true without splitTestsCompilation", async () => { const { buildArgs, buildOverride } = buildArgCaptor(); const hre = await createHardhatRuntimeEnvironment({ plugins: [HardhatNodeTestRunnerPlugin], tasks: [buildOverride], }); - try { - await hre.tasks.getTask(["test", "nodejs"]).run({ noCompile: true }); - } catch {} + await hre.tasks.getTask(["test", "nodejs"]).run({ noCompile: true }); + + assert.equal(buildArgs.length, 0); + }); + + it("should skip compilation when noCompile is true with splitTestsCompilation", async () => { + const { buildArgs, buildOverride } = buildArgCaptor(); + const hre = await createHardhatRuntimeEnvironment({ + solidity: { + version: "0.8.28", + splitTestsCompilation: true, + }, + plugins: [HardhatNodeTestRunnerPlugin], + tasks: [buildOverride], + }); + + await hre.tasks.getTask(["test", "nodejs"]).run({ noCompile: true }); assert.equal(buildArgs.length, 0); }); From 9641ac4d5f87a34f34d86c447b58631aca57e3bf Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 18:44:04 -0300 Subject: [PATCH 62/83] Update hardhat-ignition --- .../src/internal/tasks/deploy.ts | 3 +- .../src/internal/tasks/visualize.ts | 3 +- .../test/deploy/build-invocation.ts | 111 ++++++++++++++++++ .../test/plan/build-invocation.ts | 95 +++++++++++++++ 4 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 packages/hardhat-ignition/test/deploy/build-invocation.ts create mode 100644 packages/hardhat-ignition/test/plan/build-invocation.ts diff --git a/packages/hardhat-ignition/src/internal/tasks/deploy.ts b/packages/hardhat-ignition/src/internal/tasks/deploy.ts index 21f6b168004..bc3736927b7 100644 --- a/packages/hardhat-ignition/src/internal/tasks/deploy.ts +++ b/packages/hardhat-ignition/src/internal/tasks/deploy.ts @@ -154,9 +154,10 @@ const taskDeploy: NewTaskActionFunction = async ( ); } + const noTests = hre.config.solidity.splitTestsCompilation; await hre.tasks.getTask("build").run({ quiet: true, - noTests: true, + noTests, defaultBuildProfile: "production", }); diff --git a/packages/hardhat-ignition/src/internal/tasks/visualize.ts b/packages/hardhat-ignition/src/internal/tasks/visualize.ts index a890af10245..2829570282d 100644 --- a/packages/hardhat-ignition/src/internal/tasks/visualize.ts +++ b/packages/hardhat-ignition/src/internal/tasks/visualize.ts @@ -22,7 +22,8 @@ const visualizeTask: NewTaskActionFunction = async ( { noOpen, modulePath }: { noOpen: boolean; modulePath: string }, hre: HardhatRuntimeEnvironment, ) => { - await hre.tasks.getTask("build").run({ noTests: true, quiet: true }); + const noTests = hre.config.solidity.splitTestsCompilation; + await hre.tasks.getTask("build").run({ noTests, quiet: true }); const userModule = await loadModule(hre.config.paths.ignition, modulePath); diff --git a/packages/hardhat-ignition/test/deploy/build-invocation.ts b/packages/hardhat-ignition/test/deploy/build-invocation.ts new file mode 100644 index 00000000000..2464cbd1452 --- /dev/null +++ b/packages/hardhat-ignition/test/deploy/build-invocation.ts @@ -0,0 +1,111 @@ +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { assert } from "chai"; +import { overrideTask } from "hardhat/config"; +import { createHardhatRuntimeEnvironment } from "hardhat/hre"; + +import hardhatIgnitionPlugin from "../../src/index.js"; + +describe("deploy - build invocation", function () { + function buildArgCaptor() { + const buildArgs: any[] = []; + const buildOverride = overrideTask("build") + .setAction(async () => ({ + default: async (args: any) => { + buildArgs.push(args); + return { contractRootPaths: [], testRootPaths: [] }; + }, + })) + .build(); + return { buildArgs, buildOverride }; + } + + function getProjectConfig() { + const projectPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../fixture-projects", + "minimal", + ); + + const configPath = path.join(projectPath, "hardhat.config.js"); + + return { projectPath, configPath }; + } + + it("should call build without noTests when splitTestsCompilation is false", async function () { + const { buildArgs, buildOverride } = buildArgCaptor(); + const { projectPath, configPath } = getProjectConfig(); + + const { default: userConfig } = await import( + pathToFileURL(configPath).href + ); + + const hre = await createHardhatRuntimeEnvironment( + { + ...userConfig, + plugins: [hardhatIgnitionPlugin], + tasks: [buildOverride], + }, + { config: configPath }, + projectPath, + ); + + // The deploy task continues after build and may fail looking for artifacts; + // we only care about the build invocation args captured before that point. + try { + await hre.tasks.getTask(["ignition", "deploy"]).run({ + modulePath: path.join( + projectPath, + "ignition", + "modules", + "MyModule.js", + ), + }); + } catch {} + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, false); + assert.equal(buildArgs[0].defaultBuildProfile, "production"); + assert.equal(buildArgs[0].quiet, true); + }); + + it("should call build with noTests when splitTestsCompilation is true", async function () { + const { buildArgs, buildOverride } = buildArgCaptor(); + const { projectPath, configPath } = getProjectConfig(); + + const { default: userConfig } = await import( + pathToFileURL(configPath).href + ); + + const hre = await createHardhatRuntimeEnvironment( + { + ...userConfig, + solidity: { + ...userConfig.solidity, + splitTestsCompilation: true, + }, + plugins: [hardhatIgnitionPlugin], + tasks: [buildOverride], + }, + { config: configPath }, + projectPath, + ); + + try { + await hre.tasks.getTask(["ignition", "deploy"]).run({ + modulePath: path.join( + projectPath, + "ignition", + "modules", + "MyModule.js", + ), + }); + } catch {} + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, true); + assert.equal(buildArgs[0].defaultBuildProfile, "production"); + assert.equal(buildArgs[0].quiet, true); + }); +}); diff --git a/packages/hardhat-ignition/test/plan/build-invocation.ts b/packages/hardhat-ignition/test/plan/build-invocation.ts new file mode 100644 index 00000000000..21cd56fafd7 --- /dev/null +++ b/packages/hardhat-ignition/test/plan/build-invocation.ts @@ -0,0 +1,95 @@ +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { assert } from "chai"; +import { overrideTask } from "hardhat/config"; +import { createHardhatRuntimeEnvironment } from "hardhat/hre"; + +import hardhatIgnitionPlugin from "../../src/index.js"; + +describe("visualize - build invocation", function () { + function buildArgCaptor() { + const buildArgs: any[] = []; + const buildOverride = overrideTask("build") + .setAction(async () => ({ + default: async (args: any) => { + buildArgs.push(args); + return { contractRootPaths: [], testRootPaths: [] }; + }, + })) + .build(); + return { buildArgs, buildOverride }; + } + + function getProjectConfig() { + const projectPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../fixture-projects", + "minimal", + ); + + const configPath = path.join(projectPath, "hardhat.config.js"); + + return { projectPath, configPath }; + } + + it("should call build without noTests when splitTestsCompilation is false", async function () { + const { buildArgs, buildOverride } = buildArgCaptor(); + const { projectPath, configPath } = getProjectConfig(); + + const { default: userConfig } = await import( + pathToFileURL(configPath).href + ); + + const hre = await createHardhatRuntimeEnvironment( + { + ...userConfig, + plugins: [hardhatIgnitionPlugin], + tasks: [buildOverride], + }, + { config: configPath }, + projectPath, + ); + + await hre.tasks.getTask(["ignition", "visualize"]).run({ + noOpen: true, + modulePath: path.join(projectPath, "ignition", "modules", "MyModule.js"), + }); + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, false); + assert.equal(buildArgs[0].quiet, true); + }); + + it("should call build with noTests when splitTestsCompilation is true", async function () { + const { buildArgs, buildOverride } = buildArgCaptor(); + const { projectPath, configPath } = getProjectConfig(); + + const { default: userConfig } = await import( + pathToFileURL(configPath).href + ); + + const hre = await createHardhatRuntimeEnvironment( + { + ...userConfig, + solidity: { + ...userConfig.solidity, + splitTestsCompilation: true, + }, + plugins: [hardhatIgnitionPlugin], + tasks: [buildOverride], + }, + { config: configPath }, + projectPath, + ); + + await hre.tasks.getTask(["ignition", "visualize"]).run({ + noOpen: true, + modulePath: path.join(projectPath, "ignition", "modules", "MyModule.js"), + }); + + assert.equal(buildArgs.length, 1); + assert.equal(buildArgs[0].noTests, true); + assert.equal(buildArgs[0].quiet, true); + }); +}); From 24ef213fe0389af51dbbef7c24dfc860b9264c4b Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 16:31:49 -0300 Subject: [PATCH 63/83] Improve tests --- .../test/deploy/build-invocation.ts | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/packages/hardhat-ignition/test/deploy/build-invocation.ts b/packages/hardhat-ignition/test/deploy/build-invocation.ts index 2464cbd1452..cb7335ec361 100644 --- a/packages/hardhat-ignition/test/deploy/build-invocation.ts +++ b/packages/hardhat-ignition/test/deploy/build-invocation.ts @@ -12,9 +12,9 @@ describe("deploy - build invocation", function () { const buildArgs: any[] = []; const buildOverride = overrideTask("build") .setAction(async () => ({ - default: async (args: any) => { + default: async (args: any, _hre, runSuper) => { buildArgs.push(args); - return { contractRootPaths: [], testRootPaths: [] }; + return runSuper(args); }, })) .build(); @@ -51,18 +51,9 @@ describe("deploy - build invocation", function () { projectPath, ); - // The deploy task continues after build and may fail looking for artifacts; - // we only care about the build invocation args captured before that point. - try { - await hre.tasks.getTask(["ignition", "deploy"]).run({ - modulePath: path.join( - projectPath, - "ignition", - "modules", - "MyModule.js", - ), - }); - } catch {} + await hre.tasks.getTask(["ignition", "deploy"]).run({ + modulePath: path.join(projectPath, "ignition", "modules", "MyModule.js"), + }); assert.equal(buildArgs.length, 1); assert.equal(buildArgs[0].noTests, false); @@ -92,16 +83,9 @@ describe("deploy - build invocation", function () { projectPath, ); - try { - await hre.tasks.getTask(["ignition", "deploy"]).run({ - modulePath: path.join( - projectPath, - "ignition", - "modules", - "MyModule.js", - ), - }); - } catch {} + await hre.tasks.getTask(["ignition", "deploy"]).run({ + modulePath: path.join(projectPath, "ignition", "modules", "MyModule.js"), + }); assert.equal(buildArgs.length, 1); assert.equal(buildArgs[0].noTests, true); From 4f5a3c546cbfe383dfce5aa111b4f43a1bcc1941 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 18:55:41 -0300 Subject: [PATCH 64/83] Add PLUGIN_MIGRATION_GUIDE.md --- PLUGIN_MIGRATION_GUIDE.md | 285 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 PLUGIN_MIGRATION_GUIDE.md diff --git a/PLUGIN_MIGRATION_GUIDE.md b/PLUGIN_MIGRATION_GUIDE.md new file mode 100644 index 00000000000..9d30bc94df0 --- /dev/null +++ b/PLUGIN_MIGRATION_GUIDE.md @@ -0,0 +1,285 @@ +# Plugin Migration Guide: `splitTestsCompilation` + +## Overview + +Hardhat 3 introduces a `splitTestsCompilation` config field that controls whether Solidity test files are compiled in a separate pass from contract files. + +```typescript +export default { + solidity: { + version: "0.8.28", + splitTestsCompilation: true, // opt in to the previous two-pass behavior + }, +}; +``` + +- **Default: `false`** — contracts and tests are compiled together in a single pass under `scope: "contracts"`. +- **`true`** — contracts and tests are compiled in separate passes (the previous default behavior). + +This guide covers what changes for plugin authors and how to adapt. + +--- + +## Configuration + +The field is accepted in all object-typed Solidity user configs (`SingleVersionSolidityUserConfig`, `MultiVersionSolidityUserConfig`, `BuildProfilesSolidityUserConfig`). String and string-array configs always resolve to `false`. + +The resolved value is available at `hre.config.solidity.splitTestsCompilation`. + +--- + +## Build System (`hre.solidity`) + +### `scope: "tests"` is rejected when `splitTestsCompilation` is `false` + +When `splitTestsCompilation` is `false`, tests are compiled together with contracts under `scope: "contracts"`. Using `scope: "tests"` is a logic error and throws `HardhatError` (code 916, `SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED`) in the following APIs: + +- `hre.solidity.getRootFilePaths({ scope: "tests" })` +- `hre.solidity.build(rootFiles, { scope: "tests" })` +- `hre.solidity.getCompilationJobs(rootFiles, { scope: "tests" })` +- `hre.solidity.emitArtifacts(compilationJob, compilerOutput, { scope: "tests" })` +- `hre.solidity.cleanupArtifacts(rootFiles, { scope: "tests" })` + +**Exception:** `hre.solidity.getArtifactsDirectory("tests")` does **not** throw. It returns the main artifacts path (same as `"contracts"`), since it is a read-only query with no side effects. + +### `getRootFilePaths({ scope: "contracts" })` + +When `splitTestsCompilation` is `false`, this returns **all** build roots — contract roots, test roots, and npm roots — together. + +When `splitTestsCompilation` is `true`, it returns contract roots only (unchanged). + +### `getArtifactsDirectory(scope)` + +| `splitTestsCompilation` | `scope: "contracts"` | `scope: "tests"` | +| --- | --- | --- | +| `false` | `artifactsPath` | `artifactsPath` | +| `true` | `artifactsPath` | `cachePath/test-artifacts` | + +### `emitArtifacts()` + +When `splitTestsCompilation` is `false`: + +- All artifacts go to the main artifacts directory. +- **Test roots do not get per-source `artifacts.d.ts` files.** Only contract roots emit TypeScript declarations. + +### `cleanupArtifacts()` + +When `splitTestsCompilation` is `false`: + +- Cleanup operates on the main artifacts directory. +- Duplicate contract-name detection includes both contract and test artifacts. +- `onCleanUpArtifacts` receives the mixed contract/test artifact set. + +### File classification is unchanged + +`hre.solidity.getScope(fsPath)` continues to classify files as `"contracts"` or `"tests"` based on path and suffix rules. Use this API to distinguish contract files from test files when processing mixed sets. + +--- + +## Build Task (`hardhat build` / `hardhat compile`) + +### When `splitTestsCompilation` is `false` + +The build task uses a **single compilation pass** under `scope: "contracts"`. + +| Scenario | Behavior | +| --- | --- | +| Full build (no flags, no files) | Compiles all contract and test roots together. Runs cleanup. Regenerates top-level `artifacts.d.ts`. Fires `onCleanUpArtifacts`. | +| Explicit `files` | Partial build of exactly those files. No cleanup. No `artifacts.d.ts` regeneration. | +| `--no-tests` (no files) | Partial build of contract roots only. No cleanup. No `artifacts.d.ts` regeneration. | +| `--no-contracts` (no files) | Partial build of test roots only. No cleanup. No `artifacts.d.ts` regeneration. | +| `files` + compatible flag (e.g. contract files + `--no-tests`) | Partial build of the provided files. The flag filters out any incompatible roots from the resolved set. No cleanup. No `artifacts.d.ts` regeneration. | +| `files` + incompatible flag (e.g. test files + `--no-tests`) | Throws `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS`. | + +**Important:** `--no-tests` and `--no-contracts` behave as synthetic partial builds when `splitTestsCompilation` is `false`. This is different from `splitTestsCompilation: true`, where `--no-tests` runs a full contracts build with cleanup. Plugins that depend on cleanup running after `--no-tests` should account for this. + +### When `splitTestsCompilation` is `true` + +Current two-pass behavior is preserved. `--no-tests` and `--no-contracts` each skip one full pass with cleanup. + +### Return value + +Both modes return: + +```typescript +{ + contractRootPaths: string[]; + testRootPaths: string[]; +} +``` + +The arrays reflect the roots actually built, partitioned using `getScope()`. + +--- + +## Artifact Manager (`hre.artifacts` / `context.artifacts`) + +When `splitTestsCompilation` is `false`, both contract and test artifacts live under the same `paths.artifacts` directory. This means: + +- `getAllArtifactPaths()` includes test artifacts. +- `getAllFullyQualifiedNames()` includes test artifacts. +- Bare-name lookup can become **ambiguous** if a test contract and a source contract share the same name. Ambiguous names type to `never` in the generated `artifacts.d.ts`. +- Fully qualified name lookup continues to work without ambiguity. + +Plugins using `context.artifacts` must no longer assume that "artifacts path" means "contracts only." + +### Filtering test artifacts + +If your plugin needs contract-only artifacts, filter using `getScope()`: + +```typescript +// Before (assumed contracts-only) +const artifactPaths = await context.artifacts.getAllArtifactPaths(); + +// After (explicit contract-only filtering) +const allArtifactPaths = await context.artifacts.getAllArtifactPaths(); +const contractArtifactPaths = []; + +for (const artifactPath of allArtifactPaths) { + // Derive source path from artifact path using the layout invariant: + // the artifact directory mirrors the source file's relative path from + // the project root. + const relativeFromArtifacts = path.relative( + context.config.paths.artifacts, + artifactPath, + ); + const parts = relativeFromArtifacts.split(path.sep); + const sourceRelative = parts.slice(0, -1).join(path.sep); + const sourcePath = path.resolve(context.config.paths.root, sourceRelative); + + const scope = await context.solidity.getScope(sourcePath); + if (scope === "contracts") { + contractArtifactPaths.push(artifactPath); + } +} +``` + +Note: `getScope()` defaults to `"contracts"` for files that don't exist on disk, so non-local artifacts (e.g. npm dependencies) are never filtered out. + +--- + +## Solidity Hooks + +### `build` hook + +When `splitTestsCompilation` is `false`, full builds call the hook **once** with `scope: "contracts"` and a mixed set of contract and test roots. Plugins that need contract-only behavior must filter per file with `getScope()`. + +### `preprocessProjectFileBeforeBuilding` + +The same compilation may include both contract and test files. Plugins can distinguish with `context.solidity.getScope(fsPath)`. + +### `preprocessSolcInputBeforeBuilding` + +`solcInput.sources` may contain both contract and test sources together when `splitTestsCompilation` is `false`. + +### `onCleanUpArtifacts` + +When `splitTestsCompilation` is `false`, this hook only fires during full builds (not partial builds from `--no-tests`, `--no-contracts`, or explicit files). It receives mixed contract/test artifact paths. + +### Unchanged hooks + +`downloadCompilers`, `getCompiler`, `invokeSolc`, `readSourceFile`, and `readNpmPackageRemappings` are not affected. + +--- + +## Compile Cache + +Cache entries now store per-root output metadata (artifacts directory and whether a TypeScript declaration file was emitted). Toggling `splitTestsCompilation` invalidates cache hits through output-layout mismatch. + +- Old cache entries without the new metadata are treated as misses. +- Toggling from `true` to `false` may leave an orphaned `cache/test-artifacts/` directory. This is cleaned up by `hardhat clean` but not by a regular build. + +--- + +## Tasks That Call `build` + +The following tasks adapt their `build()` call based on `splitTestsCompilation`: + +| Task | `splitTestsCompilation: false` | `splitTestsCompilation: true` | +| --- | --- | --- | +| `hardhat run` | `build()` (compiles tests too) | `build({ noTests: true })` | +| `hardhat test` (top-level) | `build()` (compiles tests too) | `build({ noTests: true })` | +| `hardhat console` | `build()` (compiles tests too) | `build({ noTests: true })` | +| `hardhat test mocha` | `build()` (compiles tests too) | `build({ noTests: true })` | +| `hardhat test nodejs` | `build()` (compiles tests too) | `build({ noTests: true })` | +| `ignition deploy` | `build({ quiet, defaultBuildProfile: "production" })` | `build({ noTests: true, quiet, defaultBuildProfile: "production" })` | +| `ignition visualize` | `build({ quiet })` | `build({ noTests: true, quiet })` | + +### Plugin pattern for calling `build` + +If your plugin calls `build` and previously passed `noTests: true`, update it to branch on the config: + +```typescript +// Before +await hre.tasks.getTask("build").run({ noTests: true }); + +// After +const noTests = hre.config.solidity.splitTestsCompilation; +await hre.tasks.getTask("build").run({ noTests }); +``` + +--- + +## Solidity Test Runner (`hardhat test solidity`) + +The Solidity test runner has its own compilation logic, separate from the task callers listed above. + +### Early validation (both modes) + +Before branching on `splitTestsCompilation`, the runner validates that all provided `testFiles` are classified as tests by `getScope()`. Non-test files throw `SELECTED_FILES_ARENT_SOLIDITY_TESTS`. + +### When `splitTestsCompilation` is `false` + +- `noCompile === true` skips compilation entirely. If a selected Solidity test file has not been compiled, the runner throws `SELECTED_TEST_FILES_NOT_COMPILED`. +- `noCompile !== true` calls `build({ files: testFiles })` once, without `noTests` or `noContracts`. When `testFiles` is empty this is a full build; otherwise a partial build of the specified files. +- The runner uses `testRootPaths` from the build return value to determine which tests to run. +- Artifacts and build info are read from a single directory: `getArtifactsDirectory("contracts")`. + +### When `splitTestsCompilation` is `true` + +Current two-build behavior is preserved: + +- The first build (contracts) is guarded by `noCompile`. +- The second build (tests) is unconditional — Solidity tests are always compiled regardless of `--no-compile`. +- In split mode, `--no-compile` means "do not compile contracts", not "skip all compilation". + +--- + +## TypeChain + +TypeChain filters test artifacts before generating types when `splitTestsCompilation` is `false`. It uses `context.solidity.getScope()` to classify each artifact's source file and only generates types for contract-scope artifacts. + +Test artifacts never receive TypeChain output regardless of the `splitTestsCompilation` setting. + +--- + +## Bare-Name Ambiguity + +When `splitTestsCompilation` is `false`, test artifacts are visible through `hre.artifacts`. This affects any API that resolves artifacts by bare contract name: + +- `hardhat-ethers`: `getContractFactory`, `getContractAt`, `deployContract` +- `hardhat-viem`: `deployContract`, `sendDeploymentTransaction`, `getContractAt` +- `hardhat-verify`: automatic contract inference via `getAllFullyQualifiedNames()` +- `hardhat-ignition`: artifact resolution through `hre.artifacts` + +If a test contract and a source contract share the same name, bare-name resolution becomes ambiguous. The fix is to use fully qualified names (e.g. `"contracts/Foo.sol:Foo"` instead of `"Foo"`). + +When `splitTestsCompilation` is `true`, this ambiguity cannot arise because test artifacts are stored separately and not visible to helpers that only look at the main artifacts directory. + +--- + +## Indirectly Affected Built-In Plugins + +- **`hardhat flatten`**: Without explicit files, the default flatten target set expands to include Solidity tests when `splitTestsCompilation` is `false`. +- **`builtin:network-manager` / `builtin:node`**: The EDR contract decoder sees the mixed build-info set when contract and test build infos live under the same artifacts tree. + +--- + +## Quick Checklist + +- [ ] If your plugin calls `build({ noTests: true })`, switch to `const noTests = hre.config.solidity.splitTestsCompilation` and pass it through. +- [ ] If your plugin calls any low-level `hre.solidity` API with `scope: "tests"`, guard it with a `splitTestsCompilation` check or use `scope: "contracts"`. +- [ ] If your plugin reads from `context.artifacts` and assumes contracts-only, filter with `getScope()`. +- [ ] If your plugin handles the `build` hook and assumes contract-only roots, filter per file with `getScope()`. +- [ ] If your plugin handles `onCleanUpArtifacts`, be aware it receives mixed artifact paths and only fires on full builds when `splitTestsCompilation` is `false`. +- [ ] If your plugin resolves artifacts by bare name, document that users may need fully qualified names when test and contract names collide. From 3d2cb1023e3a58919843cb0070726049411e9fd2 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 12 Apr 2026 19:24:20 -0300 Subject: [PATCH 65/83] Add empty file to trigger the full CI --- packages/hardhat/src/internal/to-be-deleted.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/hardhat/src/internal/to-be-deleted.ts diff --git a/packages/hardhat/src/internal/to-be-deleted.ts b/packages/hardhat/src/internal/to-be-deleted.ts new file mode 100644 index 00000000000..b11eb19e791 --- /dev/null +++ b/packages/hardhat/src/internal/to-be-deleted.ts @@ -0,0 +1 @@ +// Empty file to trigger the full CI From 1efe5e659576c38b93cd4efaab4723df5ddf5e18 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 16:53:09 -0300 Subject: [PATCH 66/83] Remove code duplication in the SolidityBuildSystemImplementation --- .../solidity/build-system/build-system.ts | 87 ++++++++----------- 1 file changed, 36 insertions(+), 51 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts index c04662110e4..11133d45df7 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts @@ -178,6 +178,8 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { const scope = options.scope ?? "contracts"; const unified = !this.#options.solidityConfig.splitTestsCompilation; + this.#ensureSplitCompilationModeIfTestsScope(scope); + switch (scope) { case "contracts": { const localContractFiles = ( @@ -220,12 +222,6 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { ); } case "tests": { - if (unified) { - throw new HardhatError( - HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, - ); - } - let rootFilePaths = ( await Promise.all([ getAllFilesMatching(this.#options.solidityTestsPath, (f) => @@ -253,21 +249,14 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { public async build( rootFilePaths: string[], - _options?: BuildOptions, + options?: BuildOptions, ): Promise> { - if ( - !this.#options.solidityConfig.splitTestsCompilation && - _options?.scope === "tests" - ) { - throw new HardhatError( - HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, - ); - } + this.#ensureSplitCompilationModeIfTestsScope(options?.scope); return this.#hooks.runHandlerChain( "solidity", "build", - [rootFilePaths, _options], + [rootFilePaths, options], async (_context, nextRootFilePaths, nextOptions) => this.#build(nextRootFilePaths, nextOptions), ); @@ -275,25 +264,27 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { async #build( rootFilePaths: string[], - _options?: BuildOptions, + options?: BuildOptions, ): Promise> { - const options: Required = { + const resolvedOptions: Required = { buildProfile: DEFAULT_BUILD_PROFILE, concurrency: Math.max(os.cpus().length - 1, 1), force: false, isolated: false, quiet: false, scope: "contracts", - ..._options, + ...options, }; - await this.#downloadConfiguredCompilers(options.quiet); + await this.#downloadConfiguredCompilers(resolvedOptions.quiet); - const { buildProfile } = this.#getBuildProfile(options.buildProfile); + const { buildProfile } = this.#getBuildProfile( + resolvedOptions.buildProfile, + ); const compilationJobsResult = await this.getCompilationJobs( rootFilePaths, - options, + resolvedOptions, ); if (!compilationJobsResult.success) { @@ -301,7 +292,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { } const spinner = createSpinner({ - text: `Compiling your Solidity ${options.scope}...`, + text: `Compiling your Solidity ${resolvedOptions.scope}...`, enabled: true, }); spinner.start(); @@ -327,7 +318,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { async (runnableCompilationJob) => { const { output, compiler } = await this.runCompilationJob( runnableCompilationJob, - options, + resolvedOptions, ); return { @@ -337,7 +328,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { }; }, { - concurrency: options.concurrency, + concurrency: resolvedOptions.concurrency, // An error when running the compiler is not a compilation failure, but // a fatal failure trying to run it, so we just throw on the first error stopOnError: true, @@ -362,7 +353,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { const emitArtifactsResult = await this.emitArtifacts( compilationResult.compilationJob, compilationResult.compilerOutput, - options, + resolvedOptions, ); const { artifactsPerFile } = emitArtifactsResult; @@ -378,7 +369,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { compilationResult, emitArtifactsResult, buildProfile.isolated, - options.scope, + resolvedOptions.scope, ); }), ); @@ -446,10 +437,10 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { }); } - if (!options.quiet) { + if (!resolvedOptions.quiet) { if (isSuccessfulBuild) { await this.#printCompilationResult(runnableCompilationJobs, { - scope: options.scope, + scope: resolvedOptions.scope, }); } } @@ -464,14 +455,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { rootFilePaths: string[], options?: GetCompilationJobsOptions, ): Promise { - if ( - !this.#options.solidityConfig.splitTestsCompilation && - options?.scope === "tests" - ) { - throw new HardhatError( - HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, - ); - } + this.#ensureSplitCompilationModeIfTestsScope(options?.scope); await this.#downloadConfiguredCompilers(options?.quiet); @@ -886,13 +870,10 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { options: { scope?: BuildScope } = {}, ): Promise { const scope = options.scope ?? "contracts"; - const unified = !this.#options.solidityConfig.splitTestsCompilation; - if (unified && scope === "tests") { - throw new HardhatError( - HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, - ); - } + this.#ensureSplitCompilationModeIfTestsScope(scope); + + const unified = !this.#options.solidityConfig.splitTestsCompilation; const artifactsPerFile = new Map(); const typeFilePaths = new Map(); @@ -1061,14 +1042,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { ): Promise { const scope = options.scope ?? "contracts"; - if ( - !this.#options.solidityConfig.splitTestsCompilation && - scope === "tests" - ) { - throw new HardhatError( - HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, - ); - } + this.#ensureSplitCompilationModeIfTestsScope(scope); log(`Cleaning up artifacts`); const artifactsDirectory = await this.getArtifactsDirectory(scope); @@ -1466,6 +1440,17 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { } } } + + #ensureSplitCompilationModeIfTestsScope(scope: BuildScope = "contracts") { + if ( + scope === "tests" && + !this.#options.solidityConfig.splitTestsCompilation + ) { + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED, + ); + } + } } function toForwardSlash(str: string): string { From ce19625539781f30b3fa108f04bb2f0b6b34128b Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 16:55:29 -0300 Subject: [PATCH 67/83] Small type-safety improvement in build task --- .../src/internal/builtin-plugins/solidity/tasks/build.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts index 80227e00ce6..deb546a3bc4 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts @@ -63,8 +63,8 @@ const buildAction: NewTaskActionFunction = async ( } if (hre.config.solidity.splitTestsCompilation) { - const contractRootPaths = []; - const testRootPaths = []; + const contractRootPaths: string[] = []; + const testRootPaths: string[] = []; const shouldBuildContracts = !args.noContracts && From 3602f5ceeef10edb7aef9e8e80599919d939c4c9 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 16:56:58 -0300 Subject: [PATCH 68/83] Improve error descriptor --- packages/hardhat-errors/src/descriptors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index bdb54ea1b46..3f861d62618 100644 --- a/packages/hardhat-errors/src/descriptors.ts +++ b/packages/hardhat-errors/src/descriptors.ts @@ -1208,7 +1208,7 @@ Run \`hardhat build\` to compile your project before running tests with \`--no-c {files} -Double-check the files that you are providing to the \`test solidity\` task`, +Double-check the files that you are providing to the \`test solidity\` task.`, websiteTitle: "Invalid Solidity test files", websiteDescription: `You ran the \`test solidity\` task with files that aren't classified as Solidity tests.`, }, From c1ef662f9211a46e7ab5a63f3aa8a05a93960b21 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 17:32:24 -0300 Subject: [PATCH 69/83] Update plugin migration guide --- PLUGIN_MIGRATION_GUIDE.md | 218 ++++++++------------------------------ 1 file changed, 42 insertions(+), 176 deletions(-) diff --git a/PLUGIN_MIGRATION_GUIDE.md b/PLUGIN_MIGRATION_GUIDE.md index 9d30bc94df0..da921b37e40 100644 --- a/PLUGIN_MIGRATION_GUIDE.md +++ b/PLUGIN_MIGRATION_GUIDE.md @@ -2,7 +2,7 @@ ## Overview -Hardhat 3 introduces a `splitTestsCompilation` config field that controls whether Solidity test files are compiled in a separate pass from contract files. +This version of Hardhat introduces a `splitTestsCompilation` Solidity config field that controls whether Solidity test files are compiled in a separate pass from contract files. Previous to this version, they were always compiled separately. ```typescript export default { @@ -18,21 +18,17 @@ export default { This guide covers what changes for plugin authors and how to adapt. ---- - ## Configuration The field is accepted in all object-typed Solidity user configs (`SingleVersionSolidityUserConfig`, `MultiVersionSolidityUserConfig`, `BuildProfilesSolidityUserConfig`). String and string-array configs always resolve to `false`. The resolved value is available at `hre.config.solidity.splitTestsCompilation`. ---- - ## Build System (`hre.solidity`) ### `scope: "tests"` is rejected when `splitTestsCompilation` is `false` -When `splitTestsCompilation` is `false`, tests are compiled together with contracts under `scope: "contracts"`. Using `scope: "tests"` is a logic error and throws `HardhatError` (code 916, `SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED`) in the following APIs: +When `splitTestsCompilation` is `false`, tests are compiled together with contracts under `scope: "contracts"`. Using `scope: "tests"` is a logic error and throws a `HardhatError` with descriptor `SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED` in the following APIs: - `hre.solidity.getRootFilePaths({ scope: "tests" })` - `hre.solidity.build(rootFiles, { scope: "tests" })` @@ -50,31 +46,39 @@ When `splitTestsCompilation` is `true`, it returns contract roots only (unchange ### `getArtifactsDirectory(scope)` -| `splitTestsCompilation` | `scope: "contracts"` | `scope: "tests"` | -| --- | --- | --- | -| `false` | `artifactsPath` | `artifactsPath` | -| `true` | `artifactsPath` | `cachePath/test-artifacts` | - -### `emitArtifacts()` +| `splitTestsCompilation` | `scope: "contracts"` | `scope: "tests"` | +| ----------------------- | -------------------- | -------------------------- | +| `false` | `artifactsPath` | `artifactsPath` | +| `true` | `artifactsPath` | `cachePath/test-artifacts` | -When `splitTestsCompilation` is `false`: +### File classification is unchanged -- All artifacts go to the main artifacts directory. -- **Test roots do not get per-source `artifacts.d.ts` files.** Only contract roots emit TypeScript declarations. +`hre.solidity.getScope(fsPath)` continues to classify files as `"contracts"` or `"tests"` based on path and suffix rules. Use this API to distinguish contract files from test files when processing mixed sets. ### `cleanupArtifacts()` When `splitTestsCompilation` is `false`: - Cleanup operates on the main artifacts directory. -- Duplicate contract-name detection includes both contract and test artifacts. -- `onCleanUpArtifacts` receives the mixed contract/test artifact set. +- Duplicate contract-name detection runs across the mixed contract/test artifact set. +- `onCleanUpArtifacts` receives the mixed contract/test artifact set, so if you are hooking into it, you may need to adapt your Hook Handler. See below. -### File classification is unchanged +## Artifacts -`hre.solidity.getScope(fsPath)` continues to classify files as `"contracts"` or `"tests"` based on path and suffix rules. Use this API to distinguish contract files from test files when processing mixed sets. +When `splitTestsCompilation` is `false`, both contract and test artifacts live under the same `paths.artifacts` directory. This means: ---- +- `getAllArtifactPaths()` includes test artifacts. +- `getAllFullyQualifiedNames()` includes test artifacts. +- Bare-name lookup can become **ambiguous** if a test contract and a source contract share the same name. Ambiguous names type to `never` in the generated `artifacts.d.ts`. Users hitting a collision should switch to fully qualified names (e.g. `"contracts/Foo.sol:Foo"` instead of `"Foo"`); this affects APIs like `hardhat-ethers`'s `getContractFactory` / `getContractAt` / `deployContract`, `hardhat-viem`'s `deployContract` / `getContractAt`, `hardhat-verify`'s automatic contract inference, and `hardhat-ignition`'s artifact resolution. +- Fully qualified name lookup continues to work without ambiguity. +- **Test roots do not get per-source `artifacts.d.ts` files.** Only contract roots emit TypeScript declarations. They are not part of the `ArtifactMap` interface from `hardhat/types/artifacts`. + - This means that test contracts aren't part of the autocompletion in the `ethers` and `viem` plugins. + +Plugins using `hre.artifacts` must no longer assume that "artifacts path" means "contracts only." + +### Filtering test artifacts + +You can take a look at the `hardhat-typechain` plugin to understand how to filter out the test artifacts. ## Build Task (`hardhat build` / `hardhat compile`) @@ -82,14 +86,15 @@ When `splitTestsCompilation` is `false`: The build task uses a **single compilation pass** under `scope: "contracts"`. -| Scenario | Behavior | -| --- | --- | -| Full build (no flags, no files) | Compiles all contract and test roots together. Runs cleanup. Regenerates top-level `artifacts.d.ts`. Fires `onCleanUpArtifacts`. | -| Explicit `files` | Partial build of exactly those files. No cleanup. No `artifacts.d.ts` regeneration. | -| `--no-tests` (no files) | Partial build of contract roots only. No cleanup. No `artifacts.d.ts` regeneration. | -| `--no-contracts` (no files) | Partial build of test roots only. No cleanup. No `artifacts.d.ts` regeneration. | -| `files` + compatible flag (e.g. contract files + `--no-tests`) | Partial build of the provided files. The flag filters out any incompatible roots from the resolved set. No cleanup. No `artifacts.d.ts` regeneration. | -| `files` + incompatible flag (e.g. test files + `--no-tests`) | Throws `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS`. | + +| Scenario | Behavior | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Full build (no flags, no files) | Compiles all contracts and tests. Runs cleanup. Regenerates top-level `artifacts.d.ts`. Fires `onCleanUpArtifacts`.| +| Explicit `files` | Partial build of exactly those files. No cleanup. No `artifacts.d.ts` regeneration. No `onCleanupArtifacts`. | +| `--no-tests` (no files) | Partial build of contract roots only. No cleanup. No `artifacts.d.ts` regeneration. No `onCleanupArtifacts`. | +| `--no-contracts` (no files) | Partial build of test roots only. No cleanup. No `artifacts.d.ts` regeneration. No `onCleanupArtifacts`. | +| `files` + compatible flag (e.g. contract files + `--no-tests`) | Partial build of the provided files. No cleanup. No `artifacts.d.ts` regeneration. No `onCleanupArtifacts`. | +| `files` + incompatible flag (e.g. test files + `--no-tests`) | Throws `HardhatError` with descriptor `SOLIDITY.INCOMPATIBLE_FILES_WITH_BUILD_FLAGS`. | **Important:** `--no-tests` and `--no-contracts` behave as synthetic partial builds when `splitTestsCompilation` is `false`. This is different from `splitTestsCompilation: true`, where `--no-tests` runs a full contracts build with cleanup. Plugins that depend on cleanup running after `--no-tests` should account for this. @@ -110,53 +115,18 @@ Both modes return: The arrays reflect the roots actually built, partitioned using `getScope()`. ---- - -## Artifact Manager (`hre.artifacts` / `context.artifacts`) - -When `splitTestsCompilation` is `false`, both contract and test artifacts live under the same `paths.artifacts` directory. This means: - -- `getAllArtifactPaths()` includes test artifacts. -- `getAllFullyQualifiedNames()` includes test artifacts. -- Bare-name lookup can become **ambiguous** if a test contract and a source contract share the same name. Ambiguous names type to `never` in the generated `artifacts.d.ts`. -- Fully qualified name lookup continues to work without ambiguity. - -Plugins using `context.artifacts` must no longer assume that "artifacts path" means "contracts only." - -### Filtering test artifacts +### Plugin pattern for calling `build` -If your plugin needs contract-only artifacts, filter using `getScope()`: +If your plugin calls `build` and previously passed `noTests: true`, update it to branch on the config. When `splitTestsCompilation` is `false` there is no cheap "contracts-only" pass to fall back to — skipping tests would require an extra partial build — so the flag is only meaningful in split mode: ```typescript -// Before (assumed contracts-only) -const artifactPaths = await context.artifacts.getAllArtifactPaths(); - -// After (explicit contract-only filtering) -const allArtifactPaths = await context.artifacts.getAllArtifactPaths(); -const contractArtifactPaths = []; - -for (const artifactPath of allArtifactPaths) { - // Derive source path from artifact path using the layout invariant: - // the artifact directory mirrors the source file's relative path from - // the project root. - const relativeFromArtifacts = path.relative( - context.config.paths.artifacts, - artifactPath, - ); - const parts = relativeFromArtifacts.split(path.sep); - const sourceRelative = parts.slice(0, -1).join(path.sep); - const sourcePath = path.resolve(context.config.paths.root, sourceRelative); - - const scope = await context.solidity.getScope(sourcePath); - if (scope === "contracts") { - contractArtifactPaths.push(artifactPath); - } -} -``` - -Note: `getScope()` defaults to `"contracts"` for files that don't exist on disk, so non-local artifacts (e.g. npm dependencies) are never filtered out. +// Before +await hre.tasks.getTask("build").run({ noTests: true }); ---- +// After: only skip tests when they live in a separate compilation pass. +const noTests = hre.config.solidity.splitTestsCompilation; +await hre.tasks.getTask("build").run({ noTests }); +``` ## Solidity Hooks @@ -174,112 +144,8 @@ The same compilation may include both contract and test files. Plugins can disti ### `onCleanUpArtifacts` -When `splitTestsCompilation` is `false`, this hook only fires during full builds (not partial builds from `--no-tests`, `--no-contracts`, or explicit files). It receives mixed contract/test artifact paths. +When `splitTestsCompilation` is `false`, this hook only fires during full builds (not partial builds from `--no-tests`, `--no-contracts`, or explicit files). It receives mixed contract/test artifact paths. Take a look at the `hardhat-typechain` plugin for an example of how to filter out test artifacts. ### Unchanged hooks `downloadCompilers`, `getCompiler`, `invokeSolc`, `readSourceFile`, and `readNpmPackageRemappings` are not affected. - ---- - -## Compile Cache - -Cache entries now store per-root output metadata (artifacts directory and whether a TypeScript declaration file was emitted). Toggling `splitTestsCompilation` invalidates cache hits through output-layout mismatch. - -- Old cache entries without the new metadata are treated as misses. -- Toggling from `true` to `false` may leave an orphaned `cache/test-artifacts/` directory. This is cleaned up by `hardhat clean` but not by a regular build. - ---- - -## Tasks That Call `build` - -The following tasks adapt their `build()` call based on `splitTestsCompilation`: - -| Task | `splitTestsCompilation: false` | `splitTestsCompilation: true` | -| --- | --- | --- | -| `hardhat run` | `build()` (compiles tests too) | `build({ noTests: true })` | -| `hardhat test` (top-level) | `build()` (compiles tests too) | `build({ noTests: true })` | -| `hardhat console` | `build()` (compiles tests too) | `build({ noTests: true })` | -| `hardhat test mocha` | `build()` (compiles tests too) | `build({ noTests: true })` | -| `hardhat test nodejs` | `build()` (compiles tests too) | `build({ noTests: true })` | -| `ignition deploy` | `build({ quiet, defaultBuildProfile: "production" })` | `build({ noTests: true, quiet, defaultBuildProfile: "production" })` | -| `ignition visualize` | `build({ quiet })` | `build({ noTests: true, quiet })` | - -### Plugin pattern for calling `build` - -If your plugin calls `build` and previously passed `noTests: true`, update it to branch on the config: - -```typescript -// Before -await hre.tasks.getTask("build").run({ noTests: true }); - -// After -const noTests = hre.config.solidity.splitTestsCompilation; -await hre.tasks.getTask("build").run({ noTests }); -``` - ---- - -## Solidity Test Runner (`hardhat test solidity`) - -The Solidity test runner has its own compilation logic, separate from the task callers listed above. - -### Early validation (both modes) - -Before branching on `splitTestsCompilation`, the runner validates that all provided `testFiles` are classified as tests by `getScope()`. Non-test files throw `SELECTED_FILES_ARENT_SOLIDITY_TESTS`. - -### When `splitTestsCompilation` is `false` - -- `noCompile === true` skips compilation entirely. If a selected Solidity test file has not been compiled, the runner throws `SELECTED_TEST_FILES_NOT_COMPILED`. -- `noCompile !== true` calls `build({ files: testFiles })` once, without `noTests` or `noContracts`. When `testFiles` is empty this is a full build; otherwise a partial build of the specified files. -- The runner uses `testRootPaths` from the build return value to determine which tests to run. -- Artifacts and build info are read from a single directory: `getArtifactsDirectory("contracts")`. - -### When `splitTestsCompilation` is `true` - -Current two-build behavior is preserved: - -- The first build (contracts) is guarded by `noCompile`. -- The second build (tests) is unconditional — Solidity tests are always compiled regardless of `--no-compile`. -- In split mode, `--no-compile` means "do not compile contracts", not "skip all compilation". - ---- - -## TypeChain - -TypeChain filters test artifacts before generating types when `splitTestsCompilation` is `false`. It uses `context.solidity.getScope()` to classify each artifact's source file and only generates types for contract-scope artifacts. - -Test artifacts never receive TypeChain output regardless of the `splitTestsCompilation` setting. - ---- - -## Bare-Name Ambiguity - -When `splitTestsCompilation` is `false`, test artifacts are visible through `hre.artifacts`. This affects any API that resolves artifacts by bare contract name: - -- `hardhat-ethers`: `getContractFactory`, `getContractAt`, `deployContract` -- `hardhat-viem`: `deployContract`, `sendDeploymentTransaction`, `getContractAt` -- `hardhat-verify`: automatic contract inference via `getAllFullyQualifiedNames()` -- `hardhat-ignition`: artifact resolution through `hre.artifacts` - -If a test contract and a source contract share the same name, bare-name resolution becomes ambiguous. The fix is to use fully qualified names (e.g. `"contracts/Foo.sol:Foo"` instead of `"Foo"`). - -When `splitTestsCompilation` is `true`, this ambiguity cannot arise because test artifacts are stored separately and not visible to helpers that only look at the main artifacts directory. - ---- - -## Indirectly Affected Built-In Plugins - -- **`hardhat flatten`**: Without explicit files, the default flatten target set expands to include Solidity tests when `splitTestsCompilation` is `false`. -- **`builtin:network-manager` / `builtin:node`**: The EDR contract decoder sees the mixed build-info set when contract and test build infos live under the same artifacts tree. - ---- - -## Quick Checklist - -- [ ] If your plugin calls `build({ noTests: true })`, switch to `const noTests = hre.config.solidity.splitTestsCompilation` and pass it through. -- [ ] If your plugin calls any low-level `hre.solidity` API with `scope: "tests"`, guard it with a `splitTestsCompilation` check or use `scope: "contracts"`. -- [ ] If your plugin reads from `context.artifacts` and assumes contracts-only, filter with `getScope()`. -- [ ] If your plugin handles the `build` hook and assumes contract-only roots, filter per file with `getScope()`. -- [ ] If your plugin handles `onCleanUpArtifacts`, be aware it receives mixed artifact paths and only fires on full builds when `splitTestsCompilation` is `false`. -- [ ] If your plugin resolves artifacts by bare name, document that users may need fully qualified names when test and contract names collide. From c90a60488f7ca9acbb47c5cbcee4ea43062c3134 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 13 Apr 2026 17:32:39 -0300 Subject: [PATCH 70/83] Remove the spec --- SPLIT_TESTS_COMPILATION_SPEC.md | 856 -------------------------------- 1 file changed, 856 deletions(-) delete mode 100644 SPLIT_TESTS_COMPILATION_SPEC.md diff --git a/SPLIT_TESTS_COMPILATION_SPEC.md b/SPLIT_TESTS_COMPILATION_SPEC.md deleted file mode 100644 index a7ad3daa40b..00000000000 --- a/SPLIT_TESTS_COMPILATION_SPEC.md +++ /dev/null @@ -1,856 +0,0 @@ - - -# Spec: `splitTestsCompilation` Config Field - -## Overview - -Add a `splitTestsCompilation` boolean field to the Solidity user config that controls whether Solidity test files are compiled in a separate pass from contract files. - -- Default: `false` -- When `false`: contracts and tests are compiled together by default -- When `true`: current behavior is preserved - -The long-term goal is to keep the split available for future dynamic-linking work, but stop paying for it by default until that functionality exists. - ---- - -# Part 1: Behavioral Changes - -## `hardhat` package - -### Configuration - -A new optional boolean field `splitTestsCompilation` is accepted in all object-typed Solidity user configs: - -- `SingleVersionSolidityUserConfig` -- `MultiVersionSolidityUserConfig` -- `BuildProfilesSolidityUserConfig` - -It defaults to `false`. - -```typescript -export default { - solidity: { - version: "0.8.28", - splitTestsCompilation: true, - }, -}; -``` - -String-typed and string-array Solidity configs do not accept the field and always resolve it to `false`. - -### Build System (`hre.solidity`) - -#### `getScope(fsPath)` — Unchanged - -File classification into `"contracts"` and `"tests"` is unchanged. - -- Files under `paths.tests.solidity` ending in `.sol` are tests -- Files under `paths.sources.solidity` ending in `.t.sol` are tests -- Everything else falls back to contracts - -This remains the source of truth for classifying local Solidity files. - -#### `getRootFilePaths({ scope })` - -| `splitTestsCompilation` | `scope: "contracts"` | `scope: "tests"` | -| --- | --- | --- | -| `false` | Returns all build roots: contract roots, test roots, and `npmFilesToBuild` roots | Throws `HardhatError` | -| `true` | Returns contract roots only | Returns test roots only | - -When `splitTestsCompilation === false`, `getRootFilePaths({ scope: "tests" })` is a logic error and throws a new `HardhatError` (for example `SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED`). - -#### Low-Level `build`, `getCompilationJobs`, `emitArtifacts`, and `cleanupArtifacts` - -When `splitTestsCompilation === false`, the low-level Solidity build-system API also rejects `scope: "tests"` as a logic error, using the same `HardhatError` family as `getRootFilePaths({ scope: "tests" })`. - -Affected methods: - -- `hre.solidity.build(rootFiles, { scope: "tests" })` -- `hre.solidity.getCompilationJobs(rootFiles, { scope: "tests" })` -- `hre.solidity.emitArtifacts(compilationJob, compilerOutput, { scope: "tests" })` -- `hre.solidity.cleanupArtifacts(rootFiles, { scope: "tests" })` - -`runCompilationJob` is not affected because its options type (`RunCompilationJobOptions`) does not include `scope` — by the time a compilation job runs, scope selection has already happened. - -When `splitTestsCompilation === false` and `scope: "contracts"` is used: - -- `scope` still influences user-facing logging and hook behavior -- artifacts are written to the main artifacts directory -- whether a root emits per-source `artifacts.d.ts` is decided per root file using `getScope()` -- low-level callers cannot opt into contracts/tests separation - -When `splitTestsCompilation === true`, low-level behavior is unchanged. - -#### `getArtifactsDirectory(scope)` - -| `splitTestsCompilation` | `scope: "contracts"` | `scope: "tests"` | -| ----------------------- | -------------------- | -------------------------- | -| `false` | `artifactsPath` | `artifactsPath` | -| `true` | `artifactsPath` | `cachePath/test-artifacts` | - -When `splitTestsCompilation === false`, both scopes point to the main artifacts directory because both contract and test artifacts live there. - -Unlike `getRootFilePaths`, `build`, `getCompilationJobs`, `emitArtifacts`, and `cleanupArtifacts`, `getArtifactsDirectory` does **not** throw when called with `scope: "tests"` in unified mode. It is a read-only query with no side effects, so returning the shared artifacts path is safe and minimizes migration friction for plugins that only need to know where artifacts live. - -#### `emitArtifacts()` - -When `splitTestsCompilation === false`: - -- All contract JSON artifacts are emitted into the main artifacts directory -- Build info and build info output files are emitted into `artifacts/build-info` -- Test roots do not emit per-source `artifacts.d.ts`. No typescript types for tests -- Contract roots still emit per-source `artifacts.d.ts` when built under `scope: "contracts"` - -When `splitTestsCompilation === true`, behavior is unchanged. - -#### `cleanupArtifacts()` - -When `splitTestsCompilation === false`: - -- Cleanup operates on the main artifacts directory -- Reachability is computed against the root-file set passed to cleanup, exactly as today -- Duplicate contract-name detection includes both contract and test artifacts -- The top-level `artifacts.d.ts` is written from the mixed artifact set when cleanup runs in `scope: "contracts"` to still type repeated bare names as `never`, as they can still create collisions of bare names with respect to `hre.artifacts` -- `onCleanUpArtifacts` receives mixed contract and test artifact paths when cleanup runs in `scope: "contracts"` - -When `splitTestsCompilation === true`, behavior is unchanged. - -### Artifact Manager APIs (`hre.artifacts` / `context.artifacts`) - -When `splitTestsCompilation === false`, `hre.artifacts` and `context.artifacts` expose both contract and test artifacts because both are stored under `paths.artifacts`. - -This is an accepted behavior change. - -Consequences: - -- `getAllArtifactPaths()` includes test artifacts -- `getAllFullyQualifiedNames()` includes test artifacts -- bare-name artifact lookup can become ambiguous if a test contract and a contract share the same name. Ambiguous names still type to `never` in the generated `artifacts.d.ts` -- plugins using `context.artifacts` must no longer assume that "artifacts path" means "contracts only" - -Test artifacts still do not receive TypeScript support: - -- no per-source `artifacts.d.ts` is emitted for test roots -- no TypeChain-generated types are emitted for test artifacts - -### Compile Cache - -`splitTestsCompilation` changes the output layout, so the compile cache needs explicit output-layout validation. - -When `splitTestsCompilation === false`, compile-cache entries must store per-root output metadata: - -- the artifacts directory used for that root -- whether that root emitted a TypeScript declaration file - -Cache-hit validation must compare the cached output layout against the expected layout for the current build before checking file existence. - -Expected output layout is derived from: - -- `splitTestsCompilation` -- build `scope` -- the root file path, classified with the same logic as `getScope()` - -Additional rules: - -- old cache entries that do not have the new metadata are treated as misses -- toggling `splitTestsCompilation` invalidates cache hits through output-layout mismatch, not through the compilation-job `buildId` -- toggling from `true` to `false` may leave an orphaned `cache/test-artifacts/` directory; this is cleaned up by `hardhat clean` but not by a regular build - -This keeps cache hits fast: - -- expected layout is computed from strings and config, without extra filesystem traversal -- the fast path remains "compare cached metadata, then perform the existing output-file existence checks" - -### Build Task (`hardhat build` / `hardhat compile`) - -The high-level build task becomes mode-dependent. - -#### Mode-independent validation - -Before branching on `splitTestsCompilation`, the build task validates that explicit files are compatible with `--no-tests` / `--no-contracts`: - -- If `--no-contracts` is set and any explicit file is a contract, throw `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` -- If `--no-tests` is set and any explicit file is a test, throw `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` - -This validation applies identically in both modes. It uses a new `HardhatError` (`INCOMPATIBLE_FILES_WITH_BUILD_FLAGS`) rather than the old `UNRECOGNIZED_FILES_NOT_COMPILED` or `FILES_WITH_SCOPE_FILTERS_NOT_SUPPORTED` errors. - -#### When `splitTestsCompilation === false` - -- `build` uses a single compilation pass -- the pass runs with `scope: "contracts"` -- the existing contracts-scope log text is kept - -Behavior by input mode when `splitTestsCompilation === false`: - -1. Full build: `files.length === 0`, `noTests === false`, `noContracts === false` - - - Build all contract and test roots together - - Run cleanup once on the main artifacts directory - - Regenerate the top-level `artifacts.d.ts` - - Invoke `onCleanUpArtifacts` once with the mixed artifact set - -2. Explicit files: `files.length > 0` - - - Build exactly the provided files in a single pass, regardless of scope - - This is a partial build - - No cleanup runs - - No top-level `artifacts.d.ts` regeneration occurs - - `onCleanUpArtifacts` does not run - - Return values are partitioned into `contractRootPaths` and `testRootPaths` using `getScope()` - -3. `--no-tests` with no explicit `files` - - - Behaves as if all contract roots had been passed through `files` - - This is a partial build - - No cleanup runs - - No top-level `artifacts.d.ts` regeneration occurs - - `onCleanUpArtifacts` does not run - - Any stale artifacts or stale build-info files remain, exactly like any other partial build - - Note: this is a behavior change from `splitTestsCompilation === true`, where `--no-tests` runs a full contracts build with cleanup. The migration guide (Phase 11) should call this out. - -4. `--no-contracts` with no explicit `files` - - - Behaves as if all test roots had been passed through `files` - - This is a partial build - - No cleanup runs - - No top-level `artifacts.d.ts` regeneration occurs - - `onCleanUpArtifacts` does not run - - Any stale artifacts or stale build-info files remain, exactly like any other partial build - -5. Explicit `files` with `--no-tests` - - - Compatible contract files build normally as a partial build; test files in the file list would have been caught by the mode-independent validation above - - This is a partial build (no cleanup) - - The `--no-tests` flag also filters out test roots from the resolved set, so it is meaningful even when explicit files are provided - -6. Explicit `files` with `--no-contracts` - - - Compatible test files build normally as a partial build; contract files in the file list would have been caught by the mode-independent validation above - - This is a partial build (no cleanup) - - The `--no-contracts` flag also filters out contract roots from the resolved set, so it is meaningful even when explicit files are provided - -#### When `splitTestsCompilation === true` - -- current behavior is preserved -- The task builds contracts and tests in two separate passes -- `--no-tests` and `--no-contracts` skip a scope exactly as they do today -- explicit `files` are routed to the matching scope with `getScope()` -- explicit `files` can be combined with `--no-tests` or `--no-contracts` when compatible (e.g., contract files with `--no-tests`); incompatible combinations are caught by the mode-independent validation above -- Scope-specific cleanup remains unchanged - -Both modes return: - -```typescript -{ - contractRootPaths: string[]; - testRootPaths: string[]; -} -``` - -The arrays always reflect the roots actually built by the task. - -### Other Built-In Tasks That Call `build` - -`run`, top-level `test`, and `console` currently compile only contracts by passing `noTests: true` to `build`. - -When `splitTestsCompilation === false`: - -- they must call `build()` without `noTests` -- they therefore compile Solidity tests too as part of the unified build - -When `splitTestsCompilation === true`: - -- current behavior is preserved -- they continue to pass `noTests: true` - -### Solidity Test Runner (`hardhat test solidity`) - -Before branching on `splitTestsCompilation`, the runner validates that all provided `testFiles` are classified as tests by `getScope()`, throwing `SELECTED_FILES_ARENT_SOLIDITY_TESTS` if any are not. This validation runs in both modes. - -When `splitTestsCompilation === false`: - -- `noCompile === true` skips compilation entirely -- `noCompile !== true` calls `build({ files: testFiles })` once, without `noTests` or `noContracts` — a full build when `testFiles` is empty, a partial build of the specified files otherwise -- `testFiles` controls both which files are compiled and which tests are executed -- the runner uses `testRootPaths` from the build return value to determine which tests to run -- when `noCompile === true`, selected test roots must still be validated against the compiled artifacts available on disk -- if a selected Solidity test file exists but has not been compiled, the task throws `SELECTED_TEST_FILES_NOT_COMPILED` -- only the selected test roots are used for: - - deciding which suites to execute - - deprecated-test warnings -- artifacts and build info are read from a single directory: `getArtifactsDirectory("contracts")` - -When `splitTestsCompilation === true`, current behavior is preserved: - -- the first build (contracts) is guarded by `noCompile` -- the second build (tests) is unconditional — Solidity tests are always compiled regardless of `--no-compile` -- this is intentional: in split mode, `--no-compile` means "do not compile contracts", not "skip all compilation" - -### Warning Suppression — Unchanged - -Warning suppression continues to identify test files by path and `.t.sol` suffix, independently of whether compilation is split. - -### Coverage Plugin — Unchanged - -The coverage plugin already uses `context.solidity.getScope(fsPath)` to skip test files. Since classification is unchanged, test files continue to be excluded from instrumentation in both modes. - -### Gas Analytics — Unchanged - -Gas analytics behavior is not affected by `splitTestsCompilation`. - -- It does not depend on Solidity compile-time scope partitioning -- It operates on executed tests and their gas reports -- In unified mode, if a selected Solidity test run compiles more tests than it executes, gas analytics still reflects only the suites that actually ran -- Snapshot and snapshot-check behavior is unchanged - -## `hardhat-typechain` package - -### Type Generation - -When `splitTestsCompilation === false`, unified builds run with `scope: "contracts"`, and `context.artifacts` sees both contract and test artifacts. TypeChain must therefore filter test artifacts before generating types. - -Updated behavior: - -- keep the existing `options?.scope === "tests"` early return - - this preserves current split behavior -- after a successful build, collect all artifact paths from `context.artifacts` -- classify each artifact by its source file using `context.solidity.getScope()`: - - derive the source file path from the artifact path by computing its path relative to `context.config.paths.artifacts` and resolving that against `context.config.paths.root` - - this derivation works because of a known invariant in the artifact layout: each local Solidity source file produces a directory in the artifacts folder whose relative path from the artifacts root mirrors the source file's relative path from the project root - - `getScope()` defaults to `"contracts"` for files that don't exist on disk, so non-local artifacts (e.g. npm dependencies) are never filtered out -- pass only contract-scope artifact paths to `generateTypes()` - -Test artifacts never receive TypeChain output. - -## `hardhat-mocha` package - -When `splitTestsCompilation === false`: - -- `noCompile === true` skips compilation entirely -- `noCompile !== true` calls `build()` without `noTests` -- JS/TS test runs therefore compile Solidity tests too as part of the unified build - -When `splitTestsCompilation === true`, current behavior is preserved: - -- `noCompile === true` skips compilation entirely -- `noCompile !== true` keeps calling `build({ noTests: true })` - -## `hardhat-node-test-runner` package - -When `splitTestsCompilation === false`: - -- `noCompile === true` skips compilation entirely -- `noCompile !== true` calls `build()` without `noTests` -- JS/TS test runs therefore compile Solidity tests too as part of the unified build - -When `splitTestsCompilation === true`, current behavior is preserved: - -- `noCompile === true` skips compilation entirely -- `noCompile !== true` keeps calling `build({ noTests: true })` - -## `hardhat-ignition` package - -When `splitTestsCompilation === false`: - -- `deploy` calls `build()` without `noTests` -- `deploy` still passes `quiet: true` and `defaultBuildProfile: "production"` -- `visualize` calls `build()` without `noTests` -- `visualize` still passes `quiet: true` -- these tasks therefore compile Solidity tests too as part of the unified build -- artifact resolution through `hre.artifacts` sees test artifacts too, so bare-name resolution and build-info lookup can become ambiguous in the same way as other artifact consumers - -Note that by using `defaultBuildProfile: "production"` we still get isolated builds, so the extra tests being compiled won't be present in the deployed contract's build-info files. - -When `splitTestsCompilation === true`, current behavior is preserved: - -- `deploy` keeps calling `build({ noTests: true, quiet: true, defaultBuildProfile: "production" })` -- `visualize` keeps calling `build({ noTests: true, quiet: true })` - -## Solidity Hooks Impact Summary - -| Hook | Impact | Details | -| --- | --- | --- | -| `build` | Receives different scope/root files in unified mode | Full unified builds call the hook once with `scope: "contracts"` and mixed contract/test roots. Synthetic partial builds (`--no-tests`, `--no-contracts`) and explicit-file builds call it once with only the selected roots. Plugins that need contract-only behavior must filter per file with `getScope()`. | -| `preprocessProjectFileBeforeBuilding` | Mixed sources possible | In unified mode, the same compilation may include both contract and test files. Plugins can distinguish with `context.solidity.getScope(fsPath)`. | -| `preprocessSolcInputBeforeBuilding` | Mixed sources possible | In unified mode, `solcInput.sources` may contain both contract and test sources together. | -| `onCleanUpArtifacts` | Mixed artifact set, but only on full unified cleanup | In unified mode, this hook only runs for the full-build path. It receives mixed contract/test artifact paths. Partial builds do not trigger it. | -| `downloadCompilers` | No change | Compiler download is still driven by resolved compiler configs. | -| `getCompiler` | No change | Compiler selection is unchanged. | -| `invokeSolc` | No change | Compiler invocation remains scope-unaware. | -| `readSourceFile` | No change | File reading is unchanged. | -| `readNpmPackageRemappings` | No change | NPM remapping resolution is unchanged. | - -## Unaffected areas - -- `builtin:clean`: It deletes the `cache` and `artifacts` directories entirely, so it does not depend on how Solidity outputs were partitioned before cleanup. -- `builtin:telemetry`: It only exposes telemetry configuration tasks and does not interact with Solidity root discovery, build scopes, artifacts, or cleanup. -- `@nomicfoundation/hardhat-keystore`: It only adds config/configuration-variable hooks and keystore tasks. It does not compile Solidity or read artifacts. -- `@nomicfoundation/hardhat-ledger`: It only affects network/account configuration and request handling. It does not interact with Solidity build scopes or artifact layout. -- `@nomicfoundation/hardhat-network-helpers`: It only augments network connections with helper methods. It does not trigger builds, classify Solidity files, or consume artifacts/build info. -- `@nomicfoundation/hardhat-ethers-chai-matchers`: It only registers Chai matchers at network-connection time. It does not inspect build scopes, root-file discovery, or artifact trees itself. -- `@nomicfoundation/hardhat-viem-assertions`: It only registers viem assertion helpers at network-connection time. It does not inspect build scopes, root-file discovery, or artifact trees itself. - -Toolbox packages are not listed separately because they are meta-plugins: they inherit whatever behavior changes apply to the plugins they bundle. - -## Indirectly affected integrations - -These packages do not define Solidity build scopes themselves, but they call APIs whose behavior changes in unified mode. They therefore inherit user-visible behavior changes even though they are not the source of the contracts-vs-tests split. - -- `builtin:flatten`: `hardhat flatten` without explicit `files` calls `solidity.getRootFilePaths()` with the default scope. When `splitTestsCompilation === false`, that now returns both contract and test roots, so the default flatten target set expands to include Solidity tests. Explicit `files` behavior is unchanged. -- `builtin:network-manager`: the EDR contract decoder is initialized from all build infos visible through `context.artifacts`. In unified mode, once contract and test build infos live under the same artifacts tree, the decoder sees both. This does not change scope logic, but it means decoding/metadata availability follows the mixed artifact set on disk. This is inevitable if we want to unify the compilation. In a future iteration, Hardhat could tell EDR which files to ignore from each build info based on `getScope()`, but for now the decoder just sees everything. -- `builtin:node`: it inherits the `builtin:network-manager` behavior above because starting the node creates an EDR provider through `hre.network.connect()`. In unified mode, the node's decoder therefore sees the mixed build-info set. Its separate build-info watcher behavior is otherwise unchanged. -- `@nomicfoundation/hardhat-ethers`: its helpers resolve artifacts through `context.artifacts` / `hre.artifacts`. In unified mode, test artifacts are visible there too, so bare-name helpers like `getContractFactory`, `getContractAt`, and `deployContract` can now become ambiguous if a test contract and a contract share the same name. Fully qualified names continue to work. (\*) -- `@nomicfoundation/hardhat-viem`: same artifact-resolution effect as `@nomicfoundation/hardhat-ethers`. Bare-name helpers like `deployContract`, `sendDeploymentTransaction`, and `getContractAt` can now see test artifacts in unified mode, so ambiguity behavior may change. (\*) -- `@nomicfoundation/hardhat-verify`: explicit `--contract ` verification remains predictable, but inference mode scans `hre.artifacts.getAllFullyQualifiedNames()`. In unified mode that candidate set includes test artifacts too, so automatic contract inference and multiple-match errors can now involve test contracts. The mitigation remains to pass an explicit fully qualified name when inference becomes ambiguous. -- `@nomicfoundation/hardhat-ignition-ethers`: it adds no new scope logic of its own, but it inherits the dedicated `hardhat-ignition` behavior above and the `hardhat-ethers` behavior above. -- `@nomicfoundation/hardhat-ignition-viem`: it adds no new scope logic of its own, but it inherits the dedicated `hardhat-ignition` behavior above and the `hardhat-viem` behavior above. - -(\*) Note that helper-level bare-name ambiguity errors provide enough information for users to fix the issue by switching to fully qualified names, so this is an accepted behavior change. If a user has a test contract that shares a name with a contract, they will need to disambiguate with fully qualified names when `splitTestsCompilation === false`. When `splitTestsCompilation === true`, this ambiguity cannot arise because test artifacts are stored separately and not visible to helpers that only look at the main artifacts directory. - -Toolbox packages (`@nomicfoundation/hardhat-toolbox-mocha-ethers` and `@nomicfoundation/hardhat-toolbox-viem`) are still not listed separately as independent integration surfaces. They just re-export bundles of plugins, so they inherit the combined behavior changes of the plugins above. - ---- - -# Part 2: Phased Implementation Plan - -The implementation should be split into smaller phases so that each phase introduces one coherent behavior change and has its own validation surface. - -## Phase 1: Config And Plumbing - -Add the new config field, validate it, resolve it, and pass it through to the build-system constructor. No behavior changes yet. - -### Changes - -1. `packages/hardhat/src/internal/builtin-plugins/solidity/type-extensions.ts` - - - Add `splitTestsCompilation?: boolean` to `CommonSolidityUserConfig` - - Add `splitTestsCompilation: boolean` to `SolidityConfig` - - Add inline JSDoc explaining the field - -2. `packages/hardhat/src/internal/builtin-plugins/solidity/config.ts` - - - Add `splitTestsCompilation: z.boolean().optional()` to all object-typed Solidity user-config schemas - - Resolve object configs with `solidityConfig.splitTestsCompilation ?? false` - - Resolve string and string-array configs with `false` - -The build system accesses `splitTestsCompilation` through `this.#options.solidityConfig.splitTestsCompilation` — no separate field in `SolidityBuildSystemOptions` is needed since `solidityConfig` already carries the resolved value. - -### Validation - -- Run `pnpm lint` in `packages/hardhat` -- Run `pnpm build` in `packages/hardhat` -- Run existing config tests: `packages/hardhat/test/internal/builtin-plugins/solidity/config.ts` -- Add config tests for: - - `splitTestsCompilation: true` - - `splitTestsCompilation: false` - - omitted field defaults to `false` - - invalid non-boolean values fail validation -- Run `pnpm test` in `packages/hardhat` - -## Phase 2: Build-System Core Semantics - -Implement the new root-discovery, artifact-layout, cleanup, and low-level scope behavior in the Solidity build system. - -### Changes - -1. `packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts` - - - Update `getRootFilePaths()`: - - in unified mode, `scope: "contracts"` returns all roots, including contract roots, test roots, and `npmFilesToBuild` roots - - in unified mode, `scope: "tests"` throws - - Update `getArtifactsDirectory()`: - - in unified mode, both scopes return `artifactsPath` - - Update `emitArtifacts()`: - - emit all artifacts to the shared directory in unified mode - - emit per-source `artifacts.d.ts` only for contract roots in unified `scope: "contracts"` builds - - Update `cleanupArtifacts()`: - - operate on the shared directory in unified mode - - include test artifacts in duplicate-name detection - - pass mixed artifact paths to `onCleanUpArtifacts` for unified contracts-scope cleanup - - Reject unified low-level `scope: "tests"` calls in: - - `build()` - - `getCompilationJobs()` - - `emitArtifacts()` - - `cleanupArtifacts()` - - Delete the scope-level type-file assertion in `#cacheCompilationResult()` (`scope === "tests" || typeFilePath !== undefined`). In unified mode, test roots under `scope: "contracts"` have no type file, so this assertion no longer holds. The cache entry already accepts `typeFilePath?: string` and the cache-hit path already skips `undefined` entries, so removing it is safe. Phase 3 replaces this with proper per-root output-layout validation. - -2. `packages/hardhat/src/types/solidity/build-system.ts` - - - Update JSDoc on: - - `getRootFilePaths()` - - `emitArtifacts()` - - `cleanupArtifacts()` - - `getArtifactsDirectory()` - - `BuildOptions.scope` - - Document the unified-mode behavior and the new `getRootFilePaths({ scope: "tests" })` error - -3. `packages/hardhat/src/internal/builtin-plugins/solidity/type-extensions.ts` - - - Update hook JSDoc for: - - `build` - - `onCleanUpArtifacts` - - `preprocessProjectFileBeforeBuilding` - - `preprocessSolcInputBeforeBuilding` - -4. `packages/hardhat/src/internal/builtin-plugins/solidity/hook-handlers/hre.ts` - - - No changes needed — the existing `solidityConfig` field in `SolidityBuildSystemOptions` already carries the resolved `splitTestsCompilation` value - -5. New `HardhatError` - - - Add an error code for calling `getRootFilePaths({ scope: "tests" })` when unified compilation is enabled - - Use the first free code number in the `CORE.SOLIDITY` category in `packages/hardhat-errors/src/descriptors.ts` - -6. `LazySolidityBuildSystem` - - `LazySolidityBuildSystem` (in `packages/hardhat/src/internal/builtin-plugins/solidity/hook-handlers/hre.ts`) is a pure pass-through wrapper that delegates all calls to the underlying `SolidityBuildSystemImplementation`. It requires no changes itself — all new behavior is handled by the implementation it wraps. - -### Validation - -- Run `pnpm lint` in `packages/hardhat` -- Run `pnpm build` in `packages/hardhat` -- Run existing build-system and scope tests: - - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/build-system.ts` - - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts` -- Add tests for: - - unified `getRootFilePaths({ scope: "contracts" })` returns contract, test, and `npmFilesToBuild` roots together - - unified `getRootFilePaths({ scope: "tests" })` throws - - unified `getArtifactsDirectory("tests")` returns the main artifacts dir - - unified `emitArtifacts()` skips type declarations for test roots - - unified low-level `scope: "tests"` calls throw the new error for: - - `build` - - `getCompilationJobs` - - `emitArtifacts` - - `cleanupArtifacts` - - unified contracts-scope cleanup includes test artifacts in duplicate-name handling -- Run `pnpm test` in `packages/hardhat` - -## Phase 3: Compile Cache - -Update the compile cache so cache hits remain correct and fast when the output layout changes. - -### Changes - -1. `packages/hardhat/src/internal/builtin-plugins/solidity/build-system/cache.ts` - - - Extend `CompileCacheEntry` with: - - `artifactsDirectory` - - `emitsTypeDeclarations` - -2. `packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts` - - Add an internal helper that computes the expected output layout for a root from: - - `splitTestsCompilation` - - build `scope` - - root path classification - - Use that helper during cache-hit validation before file existence checks - - Treat cache entries missing the new fields as misses - - Update `#cacheCompilationResult()` to store the per-root output layout - -### Validation - -- Run `pnpm lint` in `packages/hardhat` -- Run `pnpm build` in `packages/hardhat` -- Run existing partial-compilation tests: - - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/get-compilation-jobs-cache-hits.ts` - - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/cache-hit-results.ts` - - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/partial-compilation/npm-cache-hits.ts` -- Add tests for: - - unified-mode second builds cache-hit both contract and test roots - - unified-mode test roots cache-hit correctly without type declarations - - toggling `splitTestsCompilation` invalidates through output-layout mismatch - - pre-existing cache entries without the new fields are treated as misses -- Run `pnpm test` in `packages/hardhat` - -## Phase 4: Build Task Semantics - -Rewrite the high-level build task to implement the new unified-mode semantics. - -### Changes - -1. `packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts` - - - Add mode-independent validation before the `splitTestsCompilation` branch: - - if `--no-contracts` and any explicit file is a contract, throw `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` - - if `--no-tests` and any explicit file is a test, throw `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` - - Branch on `splitTestsCompilation` - - Unified mode: - - full build when no `files` and no scope-skipping flags - - exact partial build when explicit `files` are provided - - explicit `files` can be combined with `--no-tests` or `--no-contracts` when the files are compatible with the flag; the flag still filters the resolved root set - - synthetic partial build of all contracts for `--no-tests`: call `getRootFilePaths({ scope: "contracts" })` to get all roots, filter to contract roots using `getScope()`, and pass them as `rootFilePaths` to `build()` - - synthetic partial build of all tests for `--no-contracts`: call `getRootFilePaths({ scope: "contracts" })` to get all roots, filter to test roots using `getScope()`, and pass them as `rootFilePaths` to `build()` - - all low-level `solidity.build()` and `solidity.cleanupArtifacts()` calls use `scope: "contracts"`, even when the selected roots are all tests - - the task must never call low-level Solidity build-system APIs with `scope: "tests"` in unified mode - - cleanup runs only for the full unified build - - Split mode: - - preserve the current two-pass behavior - - preserve the current explicit-file routing behavior - - explicit `files` can be combined with `--no-tests` or `--no-contracts` when the files are compatible with the flag; incompatible combinations are caught by the mode-independent validation - - Return `{ contractRootPaths, testRootPaths }` from the roots actually built, partitioning them with `getScope()` - -2. `packages/hardhat-errors/src/descriptors.ts` - - Replace `FILES_WITH_SCOPE_FILTERS_NOT_SUPPORTED` with `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` (same error number 917) - - `UNRECOGNIZED_FILES_NOT_COMPILED` (915) is no longer used in build.ts. After Phase 6, it is no longer used anywhere — the solidity-test runner replaces it with `SELECTED_FILES_ARENT_SOLIDITY_TESTS` (815) - -### Validation - -- Run `pnpm lint` in `packages/hardhat` -- Run `pnpm build` in `packages/hardhat` -- Run existing scope and cleanup tests: - - `packages/hardhat/test/internal/builtin-plugins/solidity/build-system/integration/build-scopes.ts` - - `packages/hardhat/test/internal/builtin-plugins/solidity/tasks/build-cleanup-artifacts.ts` -- Update Phase 2 tests that call build-system APIs directly to go through the build task instead: - - "skips per-source artifacts.d.ts for test roots in unified contracts-scope builds": replace direct `getRootFilePaths` + `build` calls with `hre.tasks.getTask("build").run()` - - "includes test artifacts in duplicate-name detection": replace direct `getRootFilePaths` + `build` + `cleanupArtifacts` calls with `hre.tasks.getTask("build").run()` - - "passes mixed contract and test artifact paths to onCleanUpArtifacts": replace direct `getRootFilePaths` + `build` + `cleanupArtifacts` calls with `hre.tasks.getTask("build").run()` and use an inline plugin to register an `onCleanUpArtifacts` handler that asserts on the received artifact paths -- Add tests for: - - mode-independent: explicit contract files + `--no-contracts` throws `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` - - mode-independent: explicit test files + `--no-tests` throws `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` - - mode-independent: mixed files + `--no-contracts` throws for the contract files - - mode-independent: mixed files + `--no-tests` throws for the test files - - unified full build compiles contracts and tests together - - unified explicit-file builds compile exactly the provided files - - unified explicit-file builds still use low-level `scope: "contracts"` - - unified explicit contract files + `--no-tests` succeeds (partial build, contracts only) - - unified explicit test files + `--no-contracts` succeeds (partial build, tests only) - - unified `--no-tests` behaves like a partial build over all contracts - - unified `--no-contracts` behaves like a partial build over all tests - - unified `--no-contracts` still uses low-level `scope: "contracts"` - - unified `--no-tests` / `--no-contracts` do not run cleanup side effects - - unified full-build cleanup still uses low-level `scope: "contracts"` - - unified mode partitions returned `contractRootPaths` and `testRootPaths` with `getScope()` from the actual roots built - - split mode preserves explicit contract-file builds with `--no-tests` - - split mode preserves explicit test-file builds with `--no-contracts` - - split mode: explicit test files only (no flags) skips the contracts scope entirely - - other split-mode regressions for current behavior -- Run `pnpm test` in `packages/hardhat` -- **Known failures after Phase 4:** 2 tests fail because the solidity-test runner calls `build({ files: testFiles, noContracts: true })` with a file that `getScope()` classifies as a contract (not in the configured test path). The mode-independent validation catches this as an incompatible combination and throws `INCOMPATIBLE_FILES_WITH_BUILD_FLAGS` (917), but the tests expect the old `UNRECOGNIZED_FILES_NOT_COMPILED` (915). Both originate from `solidity-test/task-action.ts`. Phase 6 resolves this by adding an early `getScope()`-based validation in the solidity-test runner that throws `SELECTED_FILES_ARENT_SOLIDITY_TESTS` (815) before the build call, so neither 917 nor 915 fires for this case. - -## Phase 5: Other Built-In Task Callers - -Update the built-in tasks that currently call `build({ noTests: true })`. While `build({ noTests: true })` is technically valid after Phase 4 (it produces a partial contracts-only build), these callers need a full unified build with cleanup — not a partial build — so they must drop `noTests` in unified mode. - -### Changes - -1. `packages/hardhat/src/internal/builtin-plugins/run/task-action.ts` - - - In unified mode, call plain `build()` - - In split mode, keep `noTests: true` - -2. `packages/hardhat/src/internal/builtin-plugins/test/task-action.ts` - - - In unified mode, call plain `build()` - - In split mode, keep `noTests: true` - -3. `packages/hardhat/src/internal/builtin-plugins/console/task-action.ts` - - In unified mode, call plain `build()` - - In split mode, keep `noTests: true` - -### Validation - -- Run `pnpm lint` in `packages/hardhat` -- Run `pnpm build` in `packages/hardhat` -- Run existing task tests: - - `packages/hardhat/test/internal/builtin-plugins/run/task-action.ts` - - `packages/hardhat/test/internal/builtin-plugins/test/task-action.ts` - - `packages/hardhat/test/internal/builtin-plugins/console/task-action.ts` -- Add tests that verify build invocation arguments in both modes -- Run `pnpm test` in `packages/hardhat` - -## Phase 6: Solidity Test Runner - -Update the Solidity test runner for unified builds while preserving selected test execution. Note: the solidity-test runner currently uses `build({ files, noContracts: true })`, which is valid after Phase 4 (it produces a partial test-only build). In unified mode the runner drops `noContracts` and passes `files: testFiles` to build, performing selective compilation without scope flags. - -### Changes - -1. `packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts` - - - Before branching on `splitTestsCompilation`, validate that all provided `testFiles` are classified as tests by `getScope()`, throwing `SELECTED_FILES_ARENT_SOLIDITY_TESTS` if not - - Branch on `hre.config.solidity.splitTestsCompilation` - - Unified mode: - - if `noCompile !== true`, call `build({ files: testFiles })` once, without `noTests` or `noContracts` - - use `testRootPaths` from the build return value to determine which tests to run - - when `noCompile === true`, validate that every selected Solidity test root has compiled artifacts available - - throw `SELECTED_TEST_FILES_NOT_COMPILED` if a selected Solidity test file exists but was not compiled - - use selected test roots for suite execution and deprecated-test warnings - - read artifacts and build info from the main artifacts directory only - - Split mode: - - preserve the current two-build behavior - -2. `packages/hardhat-errors/src/descriptors.ts` - - Add `SELECTED_TEST_FILES_NOT_COMPILED` (814) — thrown when `noCompile` is set and selected test files have not been compiled - - Add `SELECTED_FILES_ARENT_SOLIDITY_TESTS` (815) — thrown when non-test files are passed as test files - -### Validation - -- Run `pnpm lint` in `packages/hardhat` -- Run `pnpm build` in `packages/hardhat` -- Run existing Solidity test runner tests: `packages/hardhat/test/internal/builtin-plugins/solidity-test/task-action.ts` -- Add tests for: - - early validation throws `SELECTED_FILES_ARENT_SOLIDITY_TESTS` for non-test files in both modes - - unified mode performs one build via `build({ files: testFiles })` - - unified mode reads artifacts from a single directory - - unified mode executes only the selected test files - - unified mode compiles only the selected test files (selective compilation) - - deprecated-test warnings are emitted only for selected tests - - unified `noCompile === true` throws `SELECTED_TEST_FILES_NOT_COMPILED` when a selected Solidity test file exists but has not been compiled - - `noCompile === true` works in both modes - - split-mode behavior remains unchanged -- Run `pnpm test` in `packages/hardhat` - -## Phase 7: Artifact API Consumers And TypeChain - -Lock in the accepted artifact-manager behavior change and update TypeChain. - -### Changes - -1. Hardhat package - - - No artifact-manager code changes are required beyond the shared-directory behavior introduced earlier - - Add regressions that make the new behavior explicit - -2. `packages/hardhat-typechain/src/internal/hook-handlers/solidity.ts` - - Keep the existing `options?.scope === "tests"` early return - - In unified mode, collect all artifact paths from `context.artifacts.getAllArtifactPaths()` - - For each artifact path, classify its source file with `context.solidity.getScope()` - - Pass only contract-scope artifact paths to `generateTypes()` - - Do not infer "test artifact" from the artifact path layout alone - -### Validation - -- Run `pnpm lint` and `pnpm build` in `packages/hardhat` -- Run `pnpm lint` and `pnpm build` in `packages/hardhat-typechain` -- Run existing tests: - - `packages/hardhat/test/internal/builtin-plugins/artifacts/artifact-manager.ts` - - `packages/hardhat-typechain/test/index.ts` -- Add tests for: - - unified `hre.artifacts.getAllArtifactPaths()` includes test artifacts - - unified `hre.artifacts.getAllFullyQualifiedNames()` includes test artifacts - - bare-name artifact lookup becomes ambiguous when a test contract and a contract share a name - - fully qualified name lookup still works when a test contract and a contract share a name - - test roots still do not get per-source `artifacts.d.ts` - - TypeChain does not generate types for test artifacts in unified mode - - TypeChain classifies artifacts with `context.solidity.getScope()` rather than artifact-path heuristics - - TypeChain correctly classifies npm-dependency artifacts as contracts (since their source files don't exist on disk, `getScope()` defaults to `"contracts"`) - - TypeChain still skips explicit test-scope builds in split mode -- Run `pnpm test` in `packages/hardhat` -- Run `pnpm test` in `packages/hardhat-typechain` - -## Phase 8: `@nomicfoundation/hardhat-mocha` - -Update the Mocha test runner plugin so its pre-test compilation matches the new unified-build semantics. - -### Changes - -1. `packages/hardhat-mocha/src/task-action.ts` - - Branch on `hre.config.solidity.splitTestsCompilation` - - When `noCompile === true`, preserve the current "skip compilation entirely" behavior - - Unified mode: - - call plain `build()` without `noTests` - - Split mode: - - preserve the current `build({ noTests: true })` behavior - -### Validation - -- Run `pnpm lint` in `packages/hardhat-mocha` -- Run `pnpm build` in `packages/hardhat-mocha` -- Run existing tests: - - `packages/hardhat-mocha/test/index.ts` - - `packages/hardhat-mocha/test/registerFileForTestRunner.ts` -- Add tests for: - - unified mode invokes `build()` without `noTests` - - split mode preserves `build({ noTests: true })` - - `noCompile === true` skips compilation in both modes -- Run `pnpm test` in `packages/hardhat-mocha` - -## Phase 9: `@nomicfoundation/hardhat-node-test-runner` - -Update the node:test runner plugin so its pre-test compilation matches the new unified-build semantics. - -### Changes - -1. `packages/hardhat-node-test-runner/src/task-action.ts` - - Branch on `hre.config.solidity.splitTestsCompilation` - - When `noCompile === true`, preserve the current "skip compilation entirely" behavior - - Unified mode: - - call plain `build()` without `noTests` - - Split mode: - - preserve the current `build({ noTests: true })` behavior - -### Validation - -- Run `pnpm lint` in `packages/hardhat-node-test-runner` -- Run `pnpm build` in `packages/hardhat-node-test-runner` -- Run existing tests: - - `packages/hardhat-node-test-runner/test/index.ts` - - `packages/hardhat-node-test-runner/test/registerFileForTestRunner.ts` -- Add tests for: - - unified mode invokes `build()` without `noTests` - - split mode preserves `build({ noTests: true })` - - `noCompile === true` skips compilation in both modes -- Run `pnpm test` in `packages/hardhat-node-test-runner` - -## Phase 10: `@nomicfoundation/hardhat-ignition` - -Update Ignition's task-level prebuild behavior so it matches the new unified-build semantics. - -### Changes - -1. `packages/hardhat-ignition/src/internal/tasks/deploy.ts` - - - Branch on `hre.config.solidity.splitTestsCompilation` - - Unified mode: - - call `build()` without `noTests` - - preserve `quiet: true` - - preserve `defaultBuildProfile: "production"` - - Split mode: - - preserve `build({ noTests: true, quiet: true, defaultBuildProfile: "production" })` - -2. `packages/hardhat-ignition/src/internal/tasks/visualize.ts` - - Branch on `hre.config.solidity.splitTestsCompilation` - - Unified mode: - - call `build()` without `noTests` - - preserve `quiet: true` - - Split mode: - - preserve `build({ noTests: true, quiet: true })` - -### Validation - -- Run `pnpm lint` in `packages/hardhat-ignition` -- Run `pnpm build` in `packages/hardhat-ignition` -- Run existing tests: - - `packages/hardhat-ignition/test/deploy/build-profile.ts` - - `packages/hardhat-ignition/test/plan/index.ts` -- Add tests for: - - unified `deploy` invokes `build()` without `noTests` - - unified `deploy` still passes `defaultBuildProfile: "production"` - - unified `visualize` invokes `build()` without `noTests` - - split `deploy` preserves `build({ noTests: true, quiet: true, defaultBuildProfile: "production" })` - - split `visualize` preserves `build({ noTests: true, quiet: true })` -- Run `pnpm test` in `packages/hardhat-ignition` - -## Phase 11: Docs And Migration - -Document the shipped behavior for plugin authors and future maintainers. - -### Changes - -1. `PLUGIN_MIGRATION_GUIDE.md` - - Explain `splitTestsCompilation` and the new default - - Document the unified `hre.artifacts` behavior change - - Document the no-test-types rule for test artifacts - - Document that low-level `scope: "tests"` Solidity build-system APIs throw when unified compilation is enabled - - Document unified build-hook behavior - - Document unified cleanup-hook behavior - - Document the synthetic partial-build behavior of `--no-tests` and `--no-contracts` - - Document that `run`, top-level `test`, `console`, `@nomicfoundation/hardhat-mocha`, and `@nomicfoundation/hardhat-node-test-runner` compile Solidity tests in unified mode - - Document that `ignition deploy` and `ignition visualize` compile Solidity tests in unified mode - - Document the accepted bare-name ambiguity changes for artifact consumers, including Ignition - - Document the new compile-cache output-layout behavior - - Provide before/after plugin examples where helpful - -### Validation - -- Review both documents against the implemented behavior -- Ensure all code examples compile and match the real API signatures -- Cross-check the docs against the final tests added in the earlier phases From b7ed426f2f1cb9f7bb6400638f3d26866d82f1b8 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:24:13 -0300 Subject: [PATCH 71/83] Add comment --- .../builtin-plugins/solidity/build-system/build-system.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts index 11133d45df7..76f52a9d4a2 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts @@ -925,6 +925,9 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem { artifactsPerFile.set(formatRootPath(userSourceName, root), paths); + // In split mode, test roots are never part of a "contracts"-scoped pass, + // so the scope guard below is sufficient. In unified mode, both contract + // and test roots share the same pass, so we check individually. const isTestRoot = unified ? (await this.getScope(root.fsPath)) === "tests" : false; From 2163afb89def7e2473e0e2d6b31e9380c367e426 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:28:51 -0300 Subject: [PATCH 72/83] Improve type --- .../src/internal/builtin-plugins/solidity/tasks/build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts index deb546a3bc4..f21553e6623 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts @@ -17,7 +17,7 @@ interface BuildActionArguments { force: boolean; files: string[]; quiet: boolean; - defaultBuildProfile: string | undefined; + defaultBuildProfile: string; noTests: boolean; noContracts: boolean; } From 97a5215bc45de0504e37c4752a70852c85b01847 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:29:12 -0300 Subject: [PATCH 73/83] Improve doc comment --- .../hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts index f21553e6623..35a354fda12 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts @@ -259,7 +259,6 @@ async function getRootsToBuild({ * full unified build. * * Note: The files array should be normalized already. - * @returns */ async function getRootsToBuildInUnifiedMode({ files, From 440968077ebc790716d98f3e51f3cfd7c8d0293a Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:29:40 -0300 Subject: [PATCH 74/83] Rename function --- .../src/internal/builtin-plugins/solidity/tasks/build.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts index 35a354fda12..ac2d296fa27 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/tasks/build.ts @@ -34,7 +34,7 @@ const buildAction: NewTaskActionFunction = async ( const buildProfile = hre.globalOptions.buildProfile ?? args.defaultBuildProfile; - const files = normalizedRootPaths(args.files); + const files = normalizeRootPaths(args.files); const partitionedFiles = await partitionRootPathsByScope(hre.solidity, files); @@ -398,7 +398,7 @@ async function partitionRootPathsByScope( * If a file is an npm root path or absolute file path, it's returned as is. * If it's a relative path it's resolved from the CWD. */ -function normalizedRootPaths(files: string[]): string[] { +function normalizeRootPaths(files: string[]): string[] { return files.map((f) => { if (isNpmRootPath(f)) { return f; From 55586b724ef8ecf8e64cf55a4466697a2578096e Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:35:26 -0300 Subject: [PATCH 75/83] Use the right type --- .../internal/builtin-plugins/solidity-test/task-action.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index 856084d606b..af39604833d 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -3,7 +3,10 @@ import type { EdrArtifactWithMetadata, } from "./edr-artifacts.js"; import type { TestEvent } from "./types.js"; -import type { SolidityBuildSystem } from "../../../types/solidity.js"; +import type { + BuildScope, + SolidityBuildSystem, +} from "../../../types/solidity.js"; import type { NewTaskActionFunction } from "../../../types/tasks.js"; import type { TestRunResult } from "../../../types/test.js"; import type { Result } from "../../../types/utils.js"; @@ -406,7 +409,7 @@ async function validateThatProvidedFilesAreTests( async function loadArtifacts( solidity: SolidityBuildSystem, - scopes: Array<"contracts" | "tests">, + scopes: BuildScope[], ): Promise<{ edrArtifactsWithMetadata: EdrArtifactWithMetadata[]; allBuildInfosAndOutputs: BuildInfoAndOutput[]; From c67029da253eb7fb5db8f087f4d43f06385e61f9 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:36:32 -0300 Subject: [PATCH 76/83] Add a comment --- .../src/internal/builtin-plugins/solidity-test/task-action.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index af39604833d..f1373cc2c3a 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -64,6 +64,9 @@ const runSolidityTests: NewTaskActionFunction = async ( process.env.HH_TEST = "true"; const verbosity = hre.globalOptions.verbosity; + + // NOTE: The resolution from CWD mimics what `build` does. It's important for + // both tasks to be aligned. const resolvedTestFilesArgument = testFiles.map((f) => resolveFromRoot(process.cwd(), f), ); From 32c5c1c8b963e1e0293d0a5ac83bd5800e9661c1 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:38:56 -0300 Subject: [PATCH 77/83] Update PLUGIN_MIGRATION_GUIDE.md Co-authored-by: Luis Schaab --- PLUGIN_MIGRATION_GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PLUGIN_MIGRATION_GUIDE.md b/PLUGIN_MIGRATION_GUIDE.md index da921b37e40..9a6dff69bc0 100644 --- a/PLUGIN_MIGRATION_GUIDE.md +++ b/PLUGIN_MIGRATION_GUIDE.md @@ -42,7 +42,7 @@ When `splitTestsCompilation` is `false`, tests are compiled together with contra When `splitTestsCompilation` is `false`, this returns **all** build roots — contract roots, test roots, and npm roots — together. -When `splitTestsCompilation` is `true`, it returns contract roots only (unchanged). +When `splitTestsCompilation` is `true`, it returns contract roots and npm roots only (unchanged). ### `getArtifactsDirectory(scope)` From 29317108d0300e2460cd02e51b342f69fd97ba00 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:39:07 -0300 Subject: [PATCH 78/83] Update PLUGIN_MIGRATION_GUIDE.md Co-authored-by: Luis Schaab --- PLUGIN_MIGRATION_GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PLUGIN_MIGRATION_GUIDE.md b/PLUGIN_MIGRATION_GUIDE.md index 9a6dff69bc0..57f2910a03b 100644 --- a/PLUGIN_MIGRATION_GUIDE.md +++ b/PLUGIN_MIGRATION_GUIDE.md @@ -117,7 +117,7 @@ The arrays reflect the roots actually built, partitioned using `getScope()`. ### Plugin pattern for calling `build` -If your plugin calls `build` and previously passed `noTests: true`, update it to branch on the config. When `splitTestsCompilation` is `false` there is no cheap "contracts-only" pass to fall back to — skipping tests would require an extra partial build — so the flag is only meaningful in split mode: +If your plugin calls `build` with `noTests: true`, update it to branch on the config. In unified mode (`splitTestsCompilation: false`), passing `noTests: true` turns the build into a partial build — cleanup and `artifacts.d.ts` regeneration are skipped. Since tests are compiled in the same pass as contracts anyway, there is no performance benefit to excluding them, and the plugin loses full-build semantics it likely depends on. ```typescript // Before From 353cf86d83f43aba998d63acf646bec5e3355512 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:43:52 -0300 Subject: [PATCH 79/83] Add hardhat changseet --- .changeset/tender-taxis-lead.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/tender-taxis-lead.md diff --git a/.changeset/tender-taxis-lead.md b/.changeset/tender-taxis-lead.md new file mode 100644 index 00000000000..c81bb91375e --- /dev/null +++ b/.changeset/tender-taxis-lead.md @@ -0,0 +1,6 @@ +--- +"@nomicfoundation/hardhat-errors": patch +"hardhat": minor +--- + +Make the split of contracts and solidity tests compilation optional, and controlled with a new `splitTestsCompilation` config field. From 4fe12feefe3b868b6b651a708155c75849988c2d Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:44:04 -0300 Subject: [PATCH 80/83] Add plugins changesets --- .changeset/great-parents-play.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/great-parents-play.md diff --git a/.changeset/great-parents-play.md b/.changeset/great-parents-play.md new file mode 100644 index 00000000000..070dc1c394f --- /dev/null +++ b/.changeset/great-parents-play.md @@ -0,0 +1,8 @@ +--- +"@nomicfoundation/hardhat-node-test-runner": patch +"@nomicfoundation/hardhat-typechain": patch +"@nomicfoundation/hardhat-ignition": patch +"@nomicfoundation/hardhat-mocha": patch +--- + +Update to the new splitTestsCompilation setting From e3b5324c418293830336762408d2637c9a7e4f07 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:45:56 -0300 Subject: [PATCH 81/83] Add peer bumps --- .peer-bumps.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.peer-bumps.json b/.peer-bumps.json index fed109de40f..6318f1312b2 100644 --- a/.peer-bumps.json +++ b/.peer-bumps.json @@ -86,6 +86,26 @@ "package": "@nomicfoundation/hardhat-solx", "peer": "hardhat", "reason": "It depends on the new CommonSingleVersionSolidityUserConfig type" + }, + { + "package": "@nomicfoundation/hardhat-node-test-runner", + "peer": "hardhat", + "reason": "splitTestsCompilation update" + }, + { + "package": "@nomicfoundation/hardhat-typechain", + "peer": "hardhat", + "reason": "splitTestsCompilation update" + }, + { + "package": "@nomicfoundation/hardhat-ignition", + "peer": "hardhat", + "reason": "splitTestsCompilation update" + }, + { + "package": "@nomicfoundation/hardhat-mocha", + "peer": "hardhat", + "reason": "splitTestsCompilation update" } ] } From 7fb929e58372bed36b6a42331484cb9a1b8963fe Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:46:18 -0300 Subject: [PATCH 82/83] Delete plugin migration guide --- PLUGIN_MIGRATION_GUIDE.md | 151 -------------------------------------- 1 file changed, 151 deletions(-) delete mode 100644 PLUGIN_MIGRATION_GUIDE.md diff --git a/PLUGIN_MIGRATION_GUIDE.md b/PLUGIN_MIGRATION_GUIDE.md deleted file mode 100644 index 57f2910a03b..00000000000 --- a/PLUGIN_MIGRATION_GUIDE.md +++ /dev/null @@ -1,151 +0,0 @@ -# Plugin Migration Guide: `splitTestsCompilation` - -## Overview - -This version of Hardhat introduces a `splitTestsCompilation` Solidity config field that controls whether Solidity test files are compiled in a separate pass from contract files. Previous to this version, they were always compiled separately. - -```typescript -export default { - solidity: { - version: "0.8.28", - splitTestsCompilation: true, // opt in to the previous two-pass behavior - }, -}; -``` - -- **Default: `false`** — contracts and tests are compiled together in a single pass under `scope: "contracts"`. -- **`true`** — contracts and tests are compiled in separate passes (the previous default behavior). - -This guide covers what changes for plugin authors and how to adapt. - -## Configuration - -The field is accepted in all object-typed Solidity user configs (`SingleVersionSolidityUserConfig`, `MultiVersionSolidityUserConfig`, `BuildProfilesSolidityUserConfig`). String and string-array configs always resolve to `false`. - -The resolved value is available at `hre.config.solidity.splitTestsCompilation`. - -## Build System (`hre.solidity`) - -### `scope: "tests"` is rejected when `splitTestsCompilation` is `false` - -When `splitTestsCompilation` is `false`, tests are compiled together with contracts under `scope: "contracts"`. Using `scope: "tests"` is a logic error and throws a `HardhatError` with descriptor `SOLIDITY.SPLIT_TESTS_COMPILATION_DISABLED` in the following APIs: - -- `hre.solidity.getRootFilePaths({ scope: "tests" })` -- `hre.solidity.build(rootFiles, { scope: "tests" })` -- `hre.solidity.getCompilationJobs(rootFiles, { scope: "tests" })` -- `hre.solidity.emitArtifacts(compilationJob, compilerOutput, { scope: "tests" })` -- `hre.solidity.cleanupArtifacts(rootFiles, { scope: "tests" })` - -**Exception:** `hre.solidity.getArtifactsDirectory("tests")` does **not** throw. It returns the main artifacts path (same as `"contracts"`), since it is a read-only query with no side effects. - -### `getRootFilePaths({ scope: "contracts" })` - -When `splitTestsCompilation` is `false`, this returns **all** build roots — contract roots, test roots, and npm roots — together. - -When `splitTestsCompilation` is `true`, it returns contract roots and npm roots only (unchanged). - -### `getArtifactsDirectory(scope)` - -| `splitTestsCompilation` | `scope: "contracts"` | `scope: "tests"` | -| ----------------------- | -------------------- | -------------------------- | -| `false` | `artifactsPath` | `artifactsPath` | -| `true` | `artifactsPath` | `cachePath/test-artifacts` | - -### File classification is unchanged - -`hre.solidity.getScope(fsPath)` continues to classify files as `"contracts"` or `"tests"` based on path and suffix rules. Use this API to distinguish contract files from test files when processing mixed sets. - -### `cleanupArtifacts()` - -When `splitTestsCompilation` is `false`: - -- Cleanup operates on the main artifacts directory. -- Duplicate contract-name detection runs across the mixed contract/test artifact set. -- `onCleanUpArtifacts` receives the mixed contract/test artifact set, so if you are hooking into it, you may need to adapt your Hook Handler. See below. - -## Artifacts - -When `splitTestsCompilation` is `false`, both contract and test artifacts live under the same `paths.artifacts` directory. This means: - -- `getAllArtifactPaths()` includes test artifacts. -- `getAllFullyQualifiedNames()` includes test artifacts. -- Bare-name lookup can become **ambiguous** if a test contract and a source contract share the same name. Ambiguous names type to `never` in the generated `artifacts.d.ts`. Users hitting a collision should switch to fully qualified names (e.g. `"contracts/Foo.sol:Foo"` instead of `"Foo"`); this affects APIs like `hardhat-ethers`'s `getContractFactory` / `getContractAt` / `deployContract`, `hardhat-viem`'s `deployContract` / `getContractAt`, `hardhat-verify`'s automatic contract inference, and `hardhat-ignition`'s artifact resolution. -- Fully qualified name lookup continues to work without ambiguity. -- **Test roots do not get per-source `artifacts.d.ts` files.** Only contract roots emit TypeScript declarations. They are not part of the `ArtifactMap` interface from `hardhat/types/artifacts`. - - This means that test contracts aren't part of the autocompletion in the `ethers` and `viem` plugins. - -Plugins using `hre.artifacts` must no longer assume that "artifacts path" means "contracts only." - -### Filtering test artifacts - -You can take a look at the `hardhat-typechain` plugin to understand how to filter out the test artifacts. - -## Build Task (`hardhat build` / `hardhat compile`) - -### When `splitTestsCompilation` is `false` - -The build task uses a **single compilation pass** under `scope: "contracts"`. - - -| Scenario | Behavior | -| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| Full build (no flags, no files) | Compiles all contracts and tests. Runs cleanup. Regenerates top-level `artifacts.d.ts`. Fires `onCleanUpArtifacts`.| -| Explicit `files` | Partial build of exactly those files. No cleanup. No `artifacts.d.ts` regeneration. No `onCleanupArtifacts`. | -| `--no-tests` (no files) | Partial build of contract roots only. No cleanup. No `artifacts.d.ts` regeneration. No `onCleanupArtifacts`. | -| `--no-contracts` (no files) | Partial build of test roots only. No cleanup. No `artifacts.d.ts` regeneration. No `onCleanupArtifacts`. | -| `files` + compatible flag (e.g. contract files + `--no-tests`) | Partial build of the provided files. No cleanup. No `artifacts.d.ts` regeneration. No `onCleanupArtifacts`. | -| `files` + incompatible flag (e.g. test files + `--no-tests`) | Throws `HardhatError` with descriptor `SOLIDITY.INCOMPATIBLE_FILES_WITH_BUILD_FLAGS`. | - -**Important:** `--no-tests` and `--no-contracts` behave as synthetic partial builds when `splitTestsCompilation` is `false`. This is different from `splitTestsCompilation: true`, where `--no-tests` runs a full contracts build with cleanup. Plugins that depend on cleanup running after `--no-tests` should account for this. - -### When `splitTestsCompilation` is `true` - -Current two-pass behavior is preserved. `--no-tests` and `--no-contracts` each skip one full pass with cleanup. - -### Return value - -Both modes return: - -```typescript -{ - contractRootPaths: string[]; - testRootPaths: string[]; -} -``` - -The arrays reflect the roots actually built, partitioned using `getScope()`. - -### Plugin pattern for calling `build` - -If your plugin calls `build` with `noTests: true`, update it to branch on the config. In unified mode (`splitTestsCompilation: false`), passing `noTests: true` turns the build into a partial build — cleanup and `artifacts.d.ts` regeneration are skipped. Since tests are compiled in the same pass as contracts anyway, there is no performance benefit to excluding them, and the plugin loses full-build semantics it likely depends on. - -```typescript -// Before -await hre.tasks.getTask("build").run({ noTests: true }); - -// After: only skip tests when they live in a separate compilation pass. -const noTests = hre.config.solidity.splitTestsCompilation; -await hre.tasks.getTask("build").run({ noTests }); -``` - -## Solidity Hooks - -### `build` hook - -When `splitTestsCompilation` is `false`, full builds call the hook **once** with `scope: "contracts"` and a mixed set of contract and test roots. Plugins that need contract-only behavior must filter per file with `getScope()`. - -### `preprocessProjectFileBeforeBuilding` - -The same compilation may include both contract and test files. Plugins can distinguish with `context.solidity.getScope(fsPath)`. - -### `preprocessSolcInputBeforeBuilding` - -`solcInput.sources` may contain both contract and test sources together when `splitTestsCompilation` is `false`. - -### `onCleanUpArtifacts` - -When `splitTestsCompilation` is `false`, this hook only fires during full builds (not partial builds from `--no-tests`, `--no-contracts`, or explicit files). It receives mixed contract/test artifact paths. Take a look at the `hardhat-typechain` plugin for an example of how to filter out test artifacts. - -### Unchanged hooks - -`downloadCompilers`, `getCompiler`, `invokeSolc`, `readSourceFile`, and `readNpmPackageRemappings` are not affected. From ff3109b36989258d2de1e79d7f3e7e0ede1fb88e Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Thu, 16 Apr 2026 13:46:56 -0300 Subject: [PATCH 83/83] Delete empty file --- packages/hardhat/src/internal/to-be-deleted.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/hardhat/src/internal/to-be-deleted.ts diff --git a/packages/hardhat/src/internal/to-be-deleted.ts b/packages/hardhat/src/internal/to-be-deleted.ts deleted file mode 100644 index b11eb19e791..00000000000 --- a/packages/hardhat/src/internal/to-be-deleted.ts +++ /dev/null @@ -1 +0,0 @@ -// Empty file to trigger the full CI