Files
renovate/tools/lint/rules/codeblock-in-spec-fixtures.ts
Sebastian Poxhofer e44c9a2070 ci(oxlint): move custom oxlint rules to typescript and alternative API (#45337)
* ci: move custom oxlint rules to typescript and alternative API

* ci: add  to test sharding
2026-08-19 05:37:56 +00:00

144 lines
5.1 KiB
TypeScript

import type { ESTree } from '@oxlint/plugins';
import { defineRule } from '@oxlint/plugins';
/**
* Require the `codeBlock` (or `stripIndent`) template helper from
* `common-tags` for multi-line string fixtures in test files.
*
* Untagged multi-line template literals force fixtures to be written with
* zero indentation, which breaks the visual nesting of the surrounding
* code. The `codeBlock` helper strips the common leading indentation (and
* trims the surrounding newlines), so fixtures can be indented naturally.
*
* Only template literals that span multiple *source* lines are flagged,
* i.e. a raw newline must occur in the literal text itself (not merely
* inside a `${...}` expression). Single-line templates that produce
* multi-line values via `\n` escape sequences are left alone: they do not
* hurt the surrounding indentation, and tagging them is not value-
* preserving in general (`codeBlock` re-indents or inlines multi-line
* substitution values, and the escaped lines take part in the helpers'
* indentation stripping).
*
* Shapes that cannot round-trip through `codeBlock`/`stripIndent` are NOT
* flagged either, because tagging them would change the produced string:
*
* - Values with leading or trailing whitespace (e.g. fixtures that start or
* end with `\n`): both helpers `.trim()` the end result, so the
* whitespace would be lost.
* - As a consequence, values where every content line is indented are never
* flagged: a trimmed value always starts with a non-whitespace character,
* i.e. its first line has zero indentation. Values whose shortest line
* indentation is non-zero cannot be produced by the helpers (they always
* strip the shortest indentation).
*
* Interpolated `${...}` expressions are treated as opaque single-line,
* non-whitespace placeholders when computing the value.
*
* Also skipped:
*
* - Tagged templates of any kind (`codeBlock`, `html`, `it.each`, ...).
* - `describe`/`it`/`test`/`suite`/`bench` titles (including `.each`/`.only`
* style member and chained calls).
* - Inline snapshot arguments (`toMatchInlineSnapshot` and friends), which
* are managed by vitest.
*/
const TITLE_FUNCTIONS = new Set(['describe', 'it', 'test', 'suite', 'bench']);
const INLINE_SNAPSHOT_MATCHERS = new Set([
'toMatchInlineSnapshot',
'toThrowErrorMatchingInlineSnapshot',
]);
/**
* Walk a callee to its leftmost identifier by unwrapping
* CallExpression.callee and MemberExpression.object chains, so that
* `it.each(cases)(title, fn)` resolves to `it`.
*/
function getLeftmostIdentifier(node: ESTree.Expression): string | null {
if (node.type === 'Identifier') {
return node.name;
}
if (node.type === 'CallExpression') {
return getLeftmostIdentifier(node.callee);
}
if (node.type === 'MemberExpression') {
return getLeftmostIdentifier(node.object);
}
return null;
}
/**
* Compute the value the template literal produces, with every substitution
* replaced by an opaque single-line, non-whitespace placeholder.
*/
function getCookedValue(node: ESTree.TemplateLiteral): string | null {
const parts: string[] = [];
for (const quasi of node.quasis) {
// `cooked` is only undefined for invalid escape sequences, which are a
// syntax error in untagged templates, but guard anyway.
if (typeof quasi.value.cooked !== 'string') {
return null;
}
parts.push(quasi.value.cooked);
}
return parts.join('x');
}
export default defineRule({
meta: {
type: 'suggestion',
messages: {
useCodeBlock:
'Use the codeBlock (or stripIndent) template helper from common-tags for multi-line fixtures.',
},
},
createOnce(context) {
return {
TemplateLiteral(node) {
const parent = node.parent;
// tagged templates (codeBlock`...`, it.each`...`, ...) are fine
if (parent.type === 'TaggedTemplateExpression') {
return;
}
if (parent.type === 'CallExpression') {
// describe/it/test titles
if (
parent.arguments[0] === node &&
TITLE_FUNCTIONS.has(getLeftmostIdentifier(parent.callee) ?? '')
) {
return;
}
// inline snapshots are managed by vitest
if (
parent.callee.type === 'MemberExpression' &&
parent.callee.property.type === 'Identifier' &&
INLINE_SNAPSHOT_MATCHERS.has(parent.callee.property.name) &&
parent.arguments.includes(node)
) {
return;
}
}
// only flag literals spanning multiple source lines; multi-line
// values built from `\n` escapes are fine (see rule docs above)
if (!node.quasis.some((quasi) => quasi.value.raw.includes('\n'))) {
return;
}
const value = getCookedValue(node);
if (value === null || !value.includes('\n')) {
return;
}
// Only flag values which `codeBlock`/`stripIndent` can reproduce
// exactly; see the rule documentation above.
if (value !== value.trim()) {
return;
}
context.report({ node, messageId: 'useCodeBlock' });
},
};
},
});