///
/**
* A lint rule that keeps the diagnostics a `*.bench.ts` file prints on a stream
* that survives a `deno bench --json` run.
*
* The Benchmarks workflow sends stdout to `bench-results/results.json` and tees
* stderr to `bench-results/diagnostics.log`. Two things follow, and this rule
* enforces both:
*
* - A `console` method that writes to stdout puts non-JSON text into
* `results.json`, which invalidates the artifact for every benchmark in the
* run, not just the file that printed it. Only `error`, `warn`, `trace`,
* and `assert` write to stderr, so those four are the whole of what a
* benchmark file may call outside a body.
* - The JSON reporter captures whatever a benchmark body writes through
* `console`, on either stream, and drops it. A diagnostic written that way
* reaches neither the artifact nor the workflow log. A write straight to
* `Deno.stderr` inside a body passes through.
*
* A `console` call counts as a body diagnostic when it sits inside a
* `Deno.bench(...)` argument, and also when it sits in a function of the same
* file that a benchmark body calls, however many hops away. That second form is
* how the rule reads a file whose bodies print through a shared reporting
* helper. A helper in another module is beyond what one file's syntax tree
* shows; write those with `Deno.stderr` too.
*
* See docs/development/BENCHMARKS.md.
*/
/**
* The `console` methods that write to stderr. Everything else on `console`
* either writes to stdout or writes nothing, and naming the short list this way
* round is what makes the rule safe: a method nobody here has heard of is
* rejected rather than allowed through.
*/
const STDERR_METHODS: ReadonlySet = new Set([
"assert",
"error",
"trace",
"warn",
]);
const BODY_MESSAGE =
"A `console` call that runs in a benchmark body is captured by the `deno " +
"bench --json` reporter and never reaches stderr. Write the diagnostic with " +
"`Deno.stderr.writeSync(...)` instead. See docs/development/BENCHMARKS.md.";
const STDOUT_MESSAGE =
"A benchmark file may use only the `console` methods that write to stderr: " +
"`error`, `warn`, `trace`, `assert`. Any other one risks a line on stdout, " +
"which carries the `deno bench --json` report, where one stray line " +
"invalidates the results artifact for every benchmark in the run. Use " +
"`console.error`, or `Deno.stderr.writeSync(...)`. See " +
"docs/development/BENCHMARKS.md.";
const FUNCTION_TYPES: ReadonlySet = new Set([
"ArrowFunctionExpression",
"FunctionDeclaration",
"FunctionExpression",
]);
/** The shape this rule reads off a node, on top of the type tag. */
interface BenchNode {
readonly type: string;
readonly range: readonly [number, number];
readonly id?: { readonly type: string; readonly name?: string };
readonly init?: BenchNode | null;
readonly callee: {
readonly type: string;
readonly name?: string;
readonly computed?: boolean;
readonly object?: { readonly type: string; readonly name?: string };
readonly property?: { readonly type: string; readonly name?: string };
};
}
/**
* Returns the method name when `node` is a call on the `console` global, and
* undefined otherwise. A local binding named `console` would read the same way;
* no benchmark file shadows the global.
*/
function consoleMethod(node: BenchNode): string | undefined {
const { callee } = node;
if (callee.type !== "MemberExpression" || callee.computed) return undefined;
if (callee.object?.type !== "Identifier") return undefined;
if (callee.object.name !== "console") return undefined;
if (callee.property?.type !== "Identifier") return undefined;
return callee.property.name;
}
/** Returns true when `node` is a `Deno.bench(...)` call. */
function isBenchCall(node: BenchNode): boolean {
const { callee } = node;
return callee.type === "MemberExpression" && !callee.computed &&
callee.object?.type === "Identifier" && callee.object.name === "Deno" &&
callee.property?.type === "Identifier" && callee.property.name === "bench";
}
/** The name a plain `name(...)` call names, if the call has that form. */
function calledName(node: BenchNode): string | undefined {
const { callee } = node;
return callee.type === "Identifier" ? callee.name : undefined;
}
/** One function body, plus the module body, which the rule treats as one. */
interface Scope {
/** Names this body calls as plain `name(...)` calls. */
readonly calls: Set;
/** The `console` calls written directly in this body. */
readonly consoleCalls: { node: Deno.lint.Node; method: string }[];
/** True when this function was written inside a `Deno.bench(...)` call. */
readonly inBench: boolean;
}
export default {
name: "cf-bench",
rules: {
"no-lost-diagnostics": {
create(context) {
if (!context.filename.endsWith(".bench.ts")) return {};
const moduleScope: Scope = {
calls: new Set(),
consoleCalls: [],
inBench: false,
};
const scopes: Scope[] = [moduleScope];
const stack: Scope[] = [moduleScope];
// Functions reachable by name from a benchmark body. A function
// expression bound to a variable takes that variable's name, which the
// declarator records before the traversal descends into the function.
const byName = new Map();
const pendingNames = new Map();
// Counts the `Deno.bench(...)` calls the traversal is currently inside.
let benchDepth = 0;
const rangeKey = (node: BenchNode) => node.range.join(":");
return {
VariableDeclarator(node) {
const declarator = node as unknown as BenchNode;
const init = declarator.init;
if (!init || !FUNCTION_TYPES.has(init.type)) return;
if (declarator.id?.type !== "Identifier") return;
const name = declarator.id.name;
if (name !== undefined) pendingNames.set(rangeKey(init), name);
},
ArrowFunctionExpression: enterFunction,
FunctionDeclaration: enterFunction,
FunctionExpression: enterFunction,
"ArrowFunctionExpression:exit": exitFunction,
"FunctionDeclaration:exit": exitFunction,
"FunctionExpression:exit": exitFunction,
CallExpression(node) {
const call = node as unknown as BenchNode;
if (isBenchCall(call)) {
benchDepth += 1;
return;
}
const scope = stack[stack.length - 1];
const name = calledName(call);
if (name !== undefined) scope.calls.add(name);
const method = consoleMethod(call);
if (method === undefined) return;
if (benchDepth > 0) {
// Written inside the `Deno.bench(...)` call itself, whether or
// not a nested function encloses it.
context.report({ node, message: BODY_MESSAGE });
return;
}
scope.consoleCalls.push({ node, method });
},
"CallExpression:exit"(node) {
if (isBenchCall(node as unknown as BenchNode)) benchDepth -= 1;
},
"Program:exit"() {
// Walk out from the bodies, through the names they call, to every
// function of this file that a body can reach.
const reached = new Set();
const queue = scopes.filter((scope) => scope.inBench);
while (queue.length > 0) {
const scope = queue.pop()!;
for (const name of scope.calls) {
const target = byName.get(name);
if (target === undefined || reached.has(target)) continue;
reached.add(target);
queue.push(target);
}
}
for (const scope of scopes) {
for (const { node, method } of scope.consoleCalls) {
if (reached.has(scope)) {
context.report({ node, message: BODY_MESSAGE });
} else if (!STDERR_METHODS.has(method)) {
context.report({ node, message: STDOUT_MESSAGE });
}
}
}
},
};
function enterFunction(node: Deno.lint.Node) {
const fn = node as unknown as BenchNode;
const scope: Scope = {
calls: new Set(),
consoleCalls: [],
inBench: benchDepth > 0,
};
scopes.push(scope);
stack.push(scope);
const name = fn.id?.type === "Identifier"
? fn.id.name
: pendingNames.get(rangeKey(fn));
if (name !== undefined) byName.set(name, scope);
}
function exitFunction() {
stack.pop();
}
},
},
},
} satisfies Deno.lint.Plugin;