# Benchmarks

Performance benchmarks for `enhanced-resolve`, tracked over time via
[CodSpeed](https://codspeed.io/).

Runner stack: [tinybench](https://github.com/tinylibs/tinybench) +
[`@codspeed/core`](https://www.npmjs.com/package/@codspeed/core) with a local
`withCodSpeed()` wrapper ported from webpack's
`test/BenchmarkTestCases.benchmark.mjs`. Locally it falls back to plain
tinybench wall-clock measurements, and under `CodSpeedHQ/action` in CI it
automatically switches to CodSpeed's instrumentation mode.

## Running locally

```sh
npm run benchmark
```

Optional substring filter to run only matching cases:

```sh
npm run benchmark -- realistic
BENCH_FILTER=pathological npm run benchmark
```

Locally the runner uses tinybench's wall-clock measurements and prints a
table of ops/s, mean, p99, and relative margin of error per task. Under CI,
the plugin detects the CodSpeed runner environment and switches to
instruction-counting (`simulation`) or heap-allocation (`memory`) mode
automatically, depending on which CI job invoked it.

The V8 flags in `package.json` (`--no-opt --predictable --hash-seed=1` etc.)
are required by CodSpeed's instrumentation mode for deterministic results —
do not drop them.

### Optional: running real instruction counts locally

If you want to reproduce CI's exact instrument-count numbers on your own
machine (Linux only — the underlying Valgrind tooling has no macOS backend),
install the standalone CodSpeed CLI and wrap `npm run benchmark` with it:

```sh
curl -fsSL https://codspeed.io/install.sh | bash
codspeed run npm run benchmark
```

This is only useful if you want to debug an instruction-count regression
outside CI. Day-to-day benchmark iteration should use `npm run benchmark`
directly (wall-clock mode).

## Layout

```
benchmark/
├── run.mjs                     # entry point: discovers cases, runs bench
└── cases/
    └── <case-name>/
        ├── index.bench.mjs     # default export: register(bench, ctx)
        └── fixture/            # optional: project tree to resolve against
```

Each case directory must contain `index.bench.mjs` exporting a default
function with the signature:

```js
export default function register(bench, { caseName, caseDir, fixtureDir }) {
	bench.add("my case: descriptive name", async () => {
		// ... resolve calls ...
	});
}
```

`fixtureDir` is the absolute path to the case's `fixture/` subdirectory
(which may or may not exist). `caseDir` is the parent directory containing
`index.bench.mjs`.

## Existing cases

| Case                      | What it measures                                                                                                               |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `realistic-midsize`       | Mixed batch of relative/bare/scoped/exports/nested-`node_modules` requests against a synthetic mid-size tree                   |
| `pathological-deep-stack` | 50-deep alias chain, specifically stresses the `doResolve` recursion-check path                                                |
| `stack-churn`             | Several independent depth-60 alias chains — stresses `doResolve`'s per-level stack-allocation pressure                         |
| `alias-realistic`         | Webpack-style `@/components`, `@utils`, `~` aliases — AliasPlugin with a realistic number of entries                           |
| `alias-field`             | `browser` field remapping (AliasFieldPlugin), including the `false`/ignored branch                                             |
| `exports-field`           | Package with nested condition maps and wildcard subpath exports, run under both `require` and `import`                         |
| `imports-field`           | Package-internal `#foo` imports with conditionals and patterns                                                                 |
| `extension-alias`         | `.js` → `.ts` TypeScript-style extension remapping                                                                             |
| `extensions-many`         | Six-extension trial list (`.ts`, `.tsx`, `.mjs`, `.js`, `.jsx`, `.json`), hitting each position                                |
| `fully-specified`         | ESM-style `fullySpecified: true` resolution where extensions are mandatory                                                     |
| `tsconfig-paths`          | TsconfigPathsPlugin with five wildcard path prefixes and a plain-string fallback                                               |
| `roots`                   | RootsPlugin: server-relative (`/…`) requests against a configured root                                                         |
| `restrictions`            | RestrictionsPlugin: path prefix + regex restriction checked on every successful resolve                                        |
| `fallback`                | `fallback` aliases (common Node-built-in polyfill pattern)                                                                     |
| `self-reference`          | SelfReferencePlugin: a package imports itself via its own package name and `exports` map                                       |
| `unsafe-cache`            | UnsafeCachePlugin on vs off, with three passes over the same request list per iteration                                        |
| `unsafe-cache-miss-heavy` | UnsafeCachePlugin with a 42-request batch run miss-pass + hit-pass, stresses the cache-id hot path                             |
| `deep-hierarchy`          | Bare + relative resolution from 10 directory levels deep (walks `ModulesInHierarchicalDirectoriesPlugin`)                      |
| `prefer-relative`         | `preferRelative: true` — bare specifiers attempted as relative before node_modules                                             |
| `main-field`              | MainFieldPlugin with `browser`/`module`/`main` candidates against packages defining different combinations                     |
| `sync-resolver`           | `useSyncFileSystemCalls: true` via `resolveSync` — the loader-resolver hot path                                                |
| `symlinks`                | SymlinkPlugin on vs off, resolves routed through symlink targets created at bench setup                                        |
| `resolve-to-context`      | `resolveToContext: true` directory-only branch of the resolver pipeline                                                        |
| `failed-resolution`       | Error path: missing files and packages, walks the full pipeline before reporting the miss                                      |
| `concurrent-batch`        | 15 resolves through `Promise.all`, exercising in-flight request de-duplication                                                 |
| `large-alias-list`        | 50 non-matching + 8 matching aliases, stresses AliasPlugin's linear scan                                                       |
| `huge-alias-list`         | 300 non-matching + 8 matching aliases, match near end — monorepo-scale AliasPlugin scan                                        |
| `huge-alias-miss`         | 300 aliases + requests that never match any — pure per-option overhead, no `doResolve` recursion                               |
| `multiple-modules`        | `modules: [shared, vendor, node_modules]` mix of root + hierarchical directories                                               |
| `mixed-conditions`        | Nested condition map (browser/worker/node/development/production/...) under 4 condition configurations                         |
| `exports-patterns-many`   | Package with 6 wildcard subpath exports × 4 leaves per prefix — pattern-matcher stress                                         |
| `tsconfig-extends`        | 3-level tsconfig `extends` chain merged into `paths`                                                                           |
| `enforce-extension`       | `enforceExtension: true`, explicit `.js` requests — tighter pipeline than the default                                          |
| `deep-package-subpath`    | `pkg/a`, `pkg/a/b`, `pkg/a/b/c` requests into a single package (lodash/fp style)                                               |
| `query-fragment`          | `?query` and `#fragment` suffixes, exercises ParsePlugin's split/rejoin on the result                                          |
| `prefer-absolute`         | `preferAbsolute: true` with absolute paths — different normal-resolve branch than `preferRelative`                             |
| `cache-predicate`         | `unsafeCache: true` + custom `cachePredicate` filtering which results actually cache                                           |
| `extension-alias-many`    | `extensionAlias` with 3 source extensions each mapping to a list of candidates                                                 |
| `array-alias`             | AliasPlugin with an array alias value (`{ alias: [preferred, fallback] }`)                                                     |
| `main-files`              | Custom `mainFiles: ["main", "entry", "index"]` walked by UseFilePlugin                                                         |
| `description-files-multi` | `descriptionFiles: [package.json, bower.json, component.json]` walked per directory                                            |
| `many-extensions-miss`    | Worst-case extension probing: 5 misses + 1 hit per resolve for a 6-extension list                                              |
| `alias-wildcard-scan`     | Scan of 100 aliases where one is a wildcard (`pkg-*`) — isolates AliasUtils per-item wildcard-detection cost                   |
| `get-paths`               | Micro-bench: `lib/getPaths.js` over a mix of POSIX, Windows, and bare inputs (drives `ModulesInHierarchicalDirectoriesPlugin`) |
| `get-inner-request`       | Micro-bench: `lib/getInnerRequest.js` cold + memoized hot paths (called from most resolve-step plugins)                        |
| `cd-up`                   | Micro-bench: `DescriptionFileUtils.cdUp` single-level calls + walk-to-root (drives `loadDescriptionFile` ancestor walk)        |
| `unsafe-cache-key-build`  | Stress for `UnsafeCachePlugin.getCacheId` over 48 distinct requests with query/fragment variants (3-pass cached)               |
| `modules-flat-addrs`      | 8-deep src dir × 4-entry `modules` list — exercises the flat addr build inside `ModulesUtils.modulesResolveHandler`            |

Add new cases by creating a new directory under `cases/` — `run.mjs` will
pick it up automatically on the next run.

## Adding a CodSpeed-friendly case

A few rules of thumb:

1. **Keep each bench body long enough to be measurable** (batch many resolves
   per iteration). CodSpeed's simulation mode runs the body exactly once under
   instrumentation, so a body that does one `resolver.resolve()` will be
   dominated by instrumentation overhead.
2. **Avoid randomness.** Fixed request lists, fixed seeds. CodSpeed compares
   against the base commit and expects identical work across runs.
3. **Pre-build anything expensive (resolvers, alias lists, fixture paths) outside
   the `bench.add` callback.** The goal is to measure resolve, not setup.
4. **Prefer one focused case per bench file**, so the PR report is easy to
   read.

## CI integration

CI is driven by `.github/workflows/benchmarks.yml`, which uses
`CodSpeedHQ/action@v4` to run the same `npm run benchmark` entry point under
two modes via a matrix:

- `simulation` — CPU / instruction-count measurements (the standard CodSpeed
  performance benchmark).
- `memory` — heap-allocation measurements via the same instrumentation hooks
  (`InstrumentHooks.startBenchmark` / `stopBenchmark`); the CodSpeed runner
  collects allocation stats between those markers instead of instructions.

Both jobs share the same bench cases — `benchmark/with-codspeed.mjs` routes
both modes through the identical instrumented code path, so there is nothing
per-case to add for memory tracking.

Authentication is via the `CODSPEED_TOKEN` repo secret. Both `CODSPEED_TOKEN`
and CodSpeed repo enablement must be configured by a repo admin once — see
the top-level PR description for the handoff.
