Commit 8175dc3c05b for nodejs
commit 8175dc3c05b8a29ef18abfbca0d678cc4af5006c
Author: Murat Kirazkaya <77299279+GroophyLifefor@users.noreply.github.com>
Date: Mon Oct 19 18:51:08 2026 +0300
tools: add lint rule for unread benchmark `n`
If a benchmark declares `n` in its configs, main() must read it.
nodejs/node#59872 renamed a config key from `num` to `n` but left
`{ num: n }` in main(), so `n` resolved to undefined and the
benchmark silently reported near-zero results until #62084 fixed
it.
The rule also found `async_hooks/async-resource-vs-destroy.js`,
which has declared `n: [1e6]` since it was added but never read
it. Remove the unused key.
Refs: https://github.com/nodejs/node/pull/59872
Refs: https://github.com/nodejs/node/pull/62084
Signed-off-by: Murat Kirazkaya <groophylifefor@gmail.com>
Assisted-by: Claude Code
PR-URL: https://github.com/nodejs/node/pull/66807
Refs: https://github.com/nodejs/performance/issues/187
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Ulises Gascón <ulisesgascongonzalez@gmail.com>
Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
diff --git a/benchmark/async_hooks/async-resource-vs-destroy.js b/benchmark/async_hooks/async-resource-vs-destroy.js
index 3bd9dd56934..50085ddcf8a 100644
--- a/benchmark/async_hooks/async-resource-vs-destroy.js
+++ b/benchmark/async_hooks/async-resource-vs-destroy.js
@@ -19,7 +19,6 @@ const bench = common.createBenchmark(main, {
path: '/',
connections: 500,
duration: 5,
- n: [1e6],
});
function buildCurrentResource(getServe) {
diff --git a/benchmark/eslint.config_partial.mjs b/benchmark/eslint.config_partial.mjs
index 7f10460f719..47ef1467390 100644
--- a/benchmark/eslint.config_partial.mjs
+++ b/benchmark/eslint.config_partial.mjs
@@ -9,6 +9,7 @@ export default [
},
},
rules: {
+ 'node-core/benchmark-n-config': 'error',
'prefer-arrow-callback': 'error',
},
},
diff --git a/test/parallel/test-eslint-benchmark-n-config.js b/test/parallel/test-eslint-benchmark-n-config.js
new file mode 100644
index 00000000000..98049592fc8
--- /dev/null
+++ b/test/parallel/test-eslint-benchmark-n-config.js
@@ -0,0 +1,82 @@
+'use strict';
+
+const common = require('../common');
+if ((!common.hasCrypto) || (!common.hasIntl)) {
+ common.skip('ESLint tests require crypto and Intl');
+}
+
+common.skipIfEslintMissing();
+
+const RuleTester = require('../../tools/eslint/node_modules/eslint').RuleTester;
+const rule = require('../../tools/eslint-rules/benchmark-n-config');
+
+const message = '`n` is configured but not read by the benchmark main ' +
+ 'function (destructure `{ n }` or read `conf.n`)';
+
+new RuleTester().run('benchmark-n-config', rule, {
+ valid: [
+ `
+ const bench = common.createBenchmark(main, { len: [1], n: [100] });
+ function main({ len, n }) { bench.end(n); }
+ `,
+ `
+ const bench = common.createBenchmark(main, { n: [100] });
+ function main(conf) { bench.end(conf.n); }
+ `,
+ `
+ const bench = common.createBenchmark(main, { n: [100] });
+ function main(conf) { const { n } = conf; bench.end(n); }
+ `,
+ `
+ const bench = common.createBenchmark(({ n }) => bench.end(n), { n: [1] });
+ `,
+ `
+ const configs = { n: [100] };
+ const bench = common.createBenchmark(main, configs);
+ function main({ n }) { bench.end(n); }
+ `,
+ // Configs without `n` (e.g. duration-driven benchmarks) are not checked.
+ `
+ const bench = common.createBenchmark(main, { duration: [5] });
+ function main({ duration }) {}
+ `,
+ // Non-static configs are skipped.
+ `
+ const bench = common.createBenchmark(main, { ...base, n: [100] });
+ function main({ len }) {}
+ `,
+ ],
+ invalid: [
+ // nodejs/node#59872: the config key was renamed to `n`, but main() kept
+ // reading `num`.
+ {
+ code: `
+ const bench = common.createBenchmark(main, { len: [1], n: [100] });
+ function main({ len, num: n }) { bench.end(n); }
+ `,
+ errors: [{ message }],
+ },
+ {
+ code: `
+ const bench = common.createBenchmark(main, { n: [1e6], duration: [5] });
+ function main({ duration }) {}
+ `,
+ errors: [{ message }],
+ },
+ {
+ code: `
+ const bench = common.createBenchmark(main, { n: [100] });
+ function main(conf) { bench.end(conf.num); }
+ `,
+ errors: [{ message }],
+ },
+ {
+ code: `
+ const configs = { n: [100] };
+ const bench = common.createBenchmark(main, configs);
+ function main({ count }) { bench.end(count); }
+ `,
+ errors: [{ message }],
+ },
+ ],
+});
diff --git a/tools/eslint-rules/benchmark-n-config.js b/tools/eslint-rules/benchmark-n-config.js
new file mode 100644
index 00000000000..81a4fb30d96
--- /dev/null
+++ b/tools/eslint-rules/benchmark-n-config.js
@@ -0,0 +1,158 @@
+'use strict';
+
+// Requires benchmarks that declare an `n` iteration count in their configs
+// to actually read that value in the benchmark main function.
+//
+// This prevents regressions like nodejs/node#59872, where a config key was
+// renamed to `n` while main() kept reading the old key, silently producing
+// a near-zero result.
+
+function walk(node, visit) {
+ if (node === null || typeof node !== 'object') return;
+ if (Array.isArray(node)) {
+ for (const item of node) walk(item, visit);
+ return;
+ }
+ if (typeof node.type !== 'string') return;
+ visit(node);
+ for (const key of Object.keys(node)) {
+ if (key === 'parent' || key === 'loc' || key === 'range') continue;
+ walk(node[key], visit);
+ }
+}
+
+// Collects the property names read by an ObjectPattern: `{ num: n }` reads
+// `num`, `{ n }` reads `n`.
+function objectPatternNames(pattern, out) {
+ for (const prop of pattern.properties) {
+ if (prop.type !== 'Property' || prop.computed) continue;
+ if (prop.key.type === 'Identifier') out.add(prop.key.name);
+ else if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') {
+ out.add(prop.key.value);
+ }
+ if (prop.value && prop.value.type === 'ObjectPattern') {
+ objectPatternNames(prop.value, out);
+ }
+ }
+}
+
+// Returns false when the object literal is not fully static.
+function objectExpressionKeys(node, out) {
+ for (const prop of node.properties) {
+ if (prop.type === 'SpreadElement') return false;
+ if (prop.type !== 'Property' || prop.computed) continue;
+ if (prop.key.type === 'Identifier') out.add(prop.key.name);
+ else if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') {
+ out.add(prop.key.value);
+ }
+ }
+ return true;
+}
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'require benchmark main to read `n` when configs declare it',
+ },
+ schema: [],
+ },
+
+ create(context) {
+ const { sourceCode } = context;
+
+ let state = null;
+
+ return {
+ CallExpression(node) {
+ if (state !== null) return;
+ const callee = node.callee;
+ if (callee.type !== 'MemberExpression' || callee.computed) return;
+ if (callee.property.name !== 'createBenchmark') return;
+ if (node.arguments.length < 2) return;
+ state = {
+ mainArg: node.arguments[0],
+ configArg: node.arguments[1],
+ };
+ },
+
+ 'Program:exit'() {
+ if (state === null) return;
+ const { mainArg, configArg } = state;
+
+ // Resolve config keys.
+ const configKeys = new Set();
+ if (configArg.type === 'ObjectExpression') {
+ if (!objectExpressionKeys(configArg, configKeys)) return;
+ } else if (configArg.type === 'Identifier') {
+ let resolved = false;
+ walk(sourceCode.ast, (n) => {
+ if (resolved) return;
+ if (n.type === 'VariableDeclarator' && n.id.type === 'Identifier' &&
+ n.id.name === configArg.name && n.init &&
+ n.init.type === 'ObjectExpression') {
+ resolved = objectExpressionKeys(n.init, configKeys);
+ }
+ });
+ if (!resolved) return;
+ } else {
+ return;
+ }
+ if (!configKeys.has('n')) return;
+
+ // Resolve the main function node.
+ let fn = null;
+ if (mainArg.type === 'Identifier') {
+ walk(sourceCode.ast, (n) => {
+ if (fn !== null) return;
+ if (n.type === 'FunctionDeclaration' && n.id && n.id.name === mainArg.name) {
+ fn = n;
+ }
+ if (fn === null && n.type === 'VariableDeclarator' && n.id.type === 'Identifier' &&
+ n.id.name === mainArg.name && n.init &&
+ (n.init.type === 'FunctionExpression' ||
+ n.init.type === 'ArrowFunctionExpression')) {
+ fn = n.init;
+ }
+ });
+ } else if (mainArg.type === 'FunctionExpression' ||
+ mainArg.type === 'ArrowFunctionExpression') {
+ fn = mainArg;
+ }
+ if (fn === null) return;
+
+ // Collect every name through which `n` could be read in main().
+ const paramNames = new Set();
+ const readNames = new Set();
+ for (const param of fn.params) {
+ if (param.type === 'ObjectPattern') {
+ objectPatternNames(param, readNames);
+ } else if (param.type === 'Identifier') {
+ paramNames.add(param.name);
+ }
+ }
+ walk(fn.body, (n) => {
+ // `const { a, b } = conf;` inside the body.
+ if (n.type === 'VariableDeclarator' && n.id.type === 'ObjectPattern' &&
+ n.init && n.init.type === 'Identifier' && paramNames.has(n.init.name)) {
+ objectPatternNames(n.id, readNames);
+ }
+ // `conf.n` member access.
+ if (n.type === 'MemberExpression' && !n.computed &&
+ n.object.type === 'Identifier' && paramNames.has(n.object.name) &&
+ n.property.type === 'Identifier') {
+ readNames.add(n.property.name);
+ }
+ });
+
+ if (!readNames.has('n')) {
+ context.report({
+ node: fn.id ?? fn,
+ message: '`n` is configured but not read by the benchmark main ' +
+ 'function (destructure `{ n }` or read `conf.n`)',
+ });
+ }
+ },
+ };
+ },
+};