mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-31 01:37:24 +00:00
e44c9a2070
* ci: move custom oxlint rules to typescript and alternative API * ci: add to test sharding
144 lines
5.1 KiB
TypeScript
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' });
|
|
},
|
|
};
|
|
},
|
|
});
|