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>
This commit is contained in:
Murat Kirazkaya authored and GitHub committed 2026-10-19 15:51:08 +00:00
1 parent bb3f9176ca
commit 8175dc3c05
4 files changed
+241 -1

No files matched your search

@@ -19,7 +19,6 @@ const bench = common.createBenchmark(main, {
path: '/',
connections: 500,
duration: 5,
n: [1e6],
});
function buildCurrentResource(getServe) {
+1
View File
@@ -9,6 +9,7 @@ export default [
},
},
rules: {
'node-core/benchmark-n-config': 'error',
'prefer-arrow-callback': 'error',
},
},
@@ -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 }],
},
],
});
+158
View File
@@ -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`)',
});
}
},
};
},
};