063eb09750
Co-authored-by: Danny Kim <temeddix@gmail.com> Co-committed-by: Danny Kim <temeddix@gmail.com>
960 lines
30 KiB
TypeScript
960 lines
30 KiB
TypeScript
/**
|
|
* `project` - this repository's changes to the Oxlint categories in
|
|
* `vite.config.ts`. Its rules of its own are `no-disable-comments`,
|
|
* `no-asset-imports`, `prefer-ascii`, and ports of the hugoalh `recommended`
|
|
* rules Oxlint lacks, as in eslint-plugin-import-structure's own lint.
|
|
*
|
|
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import type {
|
|
LanguageOptions,
|
|
Position,
|
|
RuleDefinition,
|
|
RuleTextEdit,
|
|
RuleVisitor,
|
|
TextSourceCode,
|
|
} from "@eslint/core";
|
|
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types";
|
|
import type { DummyRuleMap, OxlintOverride } from "vite-plus/lint";
|
|
|
|
/** The source code both hosts hand a rule, with the comment list and index lookup. */
|
|
interface ProjectSourceCode extends TextSourceCode {
|
|
getAllComments: () => TSESTree.Comment[];
|
|
getLocFromIndex: (index: number) => Position;
|
|
}
|
|
|
|
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */
|
|
interface ProjectRuleTypes {
|
|
LangOptions: LanguageOptions;
|
|
Code: ProjectSourceCode;
|
|
RuleOptions: [];
|
|
Visitor: RuleVisitor;
|
|
Node: TSESTree.Node;
|
|
MessageIds: string;
|
|
ExtRuleDocs: unknown;
|
|
}
|
|
|
|
/** A repo-local rule, in ESLint's standard `create` form. */
|
|
type ProjectRule = RuleDefinition<ProjectRuleTypes>;
|
|
|
|
/** A comment that turns lint rules off or back on, in ESLint or Oxlint form. */
|
|
const DIRECTIVE_PATTERN: RegExp = /^\s*(?:eslint|oxlint)-(?:disable|enable)/u;
|
|
|
|
/** This rule's own name, which a directive may not use to silence it. */
|
|
const RULE_NAME: string = "no-disable-comments";
|
|
|
|
/**
|
|
* `no-disable-comments` - rejects every `eslint-disable*`, `eslint-enable*`,
|
|
* `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has
|
|
* no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them
|
|
* itself: when a rule fires, fix the source.
|
|
*
|
|
* A directive can silence the report on itself, so two built-in checks close
|
|
* the gaps: `unicorn/no-abusive-eslint-disable` fails one that names no rule,
|
|
* and this rule skips one that names it, which then silences nothing and fails
|
|
* `reportUnusedDisableDirectives` in `vite.config.ts`.
|
|
*/
|
|
const noDisableCommentsRule: ProjectRule = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: { description: "Disallow lint disable and enable comments" },
|
|
schema: [],
|
|
messages: {
|
|
directive: "Lint disable comments are not allowed; fix the source.",
|
|
},
|
|
},
|
|
create(context) {
|
|
return {
|
|
Program(_node: TSESTree.Program): void {
|
|
for (const comment of context.sourceCode.getAllComments()) {
|
|
if (
|
|
DIRECTIVE_PATTERN.test(comment.value) &&
|
|
!comment.value.includes(RULE_NAME)
|
|
) {
|
|
context.report({ loc: comment.loc, messageId: "directive" });
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
/** The highest code point `prefer-ascii` lets through. */
|
|
const MAX_ASCII_CODE_POINT: number = 127;
|
|
|
|
/**
|
|
* `prefer-ascii` - every character of the source is ASCII. A file is exempted
|
|
* only through an entry in `projectOverrides`.
|
|
*/
|
|
const preferAsciiRule: ProjectRule = {
|
|
meta: {
|
|
type: "suggestion",
|
|
docs: { description: "Require the source to be ASCII" },
|
|
schema: [],
|
|
messages: {
|
|
nonAscii:
|
|
"Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.",
|
|
},
|
|
},
|
|
create(context) {
|
|
return {
|
|
Program(_node: TSESTree.Program): void {
|
|
const { sourceCode } = context;
|
|
let index: number = 0;
|
|
for (const character of sourceCode.text) {
|
|
const codePoint: number = character.codePointAt(0) ?? 0;
|
|
if (codePoint > MAX_ASCII_CODE_POINT) {
|
|
context.report({
|
|
loc: {
|
|
start: sourceCode.getLocFromIndex(index),
|
|
end: sourceCode.getLocFromIndex(index + character.length),
|
|
},
|
|
messageId: "nonAscii",
|
|
data: {
|
|
character,
|
|
codePoint: codePoint
|
|
.toString(16)
|
|
.toUpperCase()
|
|
.padStart(4, "0"),
|
|
},
|
|
});
|
|
}
|
|
index += character.length;
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
/** `no-duplicate-typeofs` - `typeof typeof x` is always `"string"`. */
|
|
const noDuplicateTypeofsRule: ProjectRule = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: { description: "Disallow `typeof` applied twice in a row" },
|
|
schema: [],
|
|
messages: {
|
|
doubled:
|
|
'Multiple `typeof`s always return `"string"`, possibly not intended.',
|
|
},
|
|
},
|
|
create(context) {
|
|
return {
|
|
UnaryExpression(node: TSESTree.UnaryExpression): void {
|
|
if (
|
|
node.operator === "typeof" &&
|
|
node.argument.type === AST_NODE_TYPES.UnaryExpression &&
|
|
node.argument.operator === "typeof"
|
|
) {
|
|
context.report({ node, messageId: "doubled" });
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
/** `no-empty-yield` - a `yield` with no expression, likely missing one. */
|
|
const noEmptyYieldRule: ProjectRule = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: { description: "Disallow `yield` without an expression" },
|
|
schema: [],
|
|
messages: {
|
|
empty: "Empty `yield` is not allowed, possibly missing the expression.",
|
|
},
|
|
},
|
|
create(context) {
|
|
return {
|
|
YieldExpression(node: TSESTree.YieldExpression): void {
|
|
if (node.argument === null) {
|
|
context.report({ node, messageId: "empty" });
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
/** The globals `no-nan` sees through, as in `globalThis.Number.NaN`. */
|
|
const GLOBAL_NAMES: ReadonlySet<string> = new Set<string>([
|
|
"globalThis",
|
|
"self",
|
|
"window",
|
|
]);
|
|
|
|
/** The name a member expression's property spells, computed or not. */
|
|
function propertyName(node: TSESTree.MemberExpression): string | null {
|
|
if (node.property.type === AST_NODE_TYPES.Identifier && !node.computed) {
|
|
return node.property.name;
|
|
}
|
|
if (
|
|
node.property.type === AST_NODE_TYPES.Literal &&
|
|
typeof node.property.value === "string"
|
|
) {
|
|
return node.property.value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Whether `node` is a global object, such as `globalThis` or `globalThis.self`. */
|
|
function isGlobalChain(node: TSESTree.Expression): boolean {
|
|
if (node.type === AST_NODE_TYPES.Identifier) {
|
|
return GLOBAL_NAMES.has(node.name);
|
|
}
|
|
return (
|
|
node.type === AST_NODE_TYPES.MemberExpression &&
|
|
GLOBAL_NAMES.has(propertyName(node) ?? "") &&
|
|
isGlobalChain(node.object)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether a member expression reads the global `NaN`: `Number.NaN`,
|
|
* `globalThis.NaN`, or `globalThis.Number.NaN`.
|
|
*/
|
|
function isNanMember(node: TSESTree.MemberExpression): boolean {
|
|
if (propertyName(node) !== "NaN") {
|
|
return false;
|
|
}
|
|
const { object } = node;
|
|
if (object.type === AST_NODE_TYPES.Identifier && object.name === "Number") {
|
|
return true;
|
|
}
|
|
return (
|
|
isGlobalChain(object) ||
|
|
(object.type === AST_NODE_TYPES.MemberExpression &&
|
|
propertyName(object) === "Number" &&
|
|
isGlobalChain(object.object))
|
|
);
|
|
}
|
|
|
|
/** `no-nan` - a `NaN` value is usually an error; use `null` for "no number". */
|
|
const noNanRule: ProjectRule = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: { description: "Disallow `NaN` as a value" },
|
|
schema: [],
|
|
messages: {
|
|
nan: "Using `NaN` as a value is usually an error and not intended.",
|
|
},
|
|
},
|
|
create(context) {
|
|
return {
|
|
Identifier(node: TSESTree.Identifier): void {
|
|
if (
|
|
node.name === "NaN" &&
|
|
node.parent.type !== AST_NODE_TYPES.MemberExpression
|
|
) {
|
|
context.report({ node, messageId: "nan" });
|
|
}
|
|
},
|
|
MemberExpression(node: TSESTree.MemberExpression): void {
|
|
if (isNanMember(node)) {
|
|
context.report({ node, messageId: "nan" });
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
/** `no-unsafe-number` - a number literal outside the safe integer range. */
|
|
const noUnsafeNumberRule: ProjectRule = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description: "Disallow number literals outside the safe integer range",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
unsafe:
|
|
"Number literals with values equal to 2^53 or greater are too large to be represented accurately.",
|
|
},
|
|
},
|
|
create(context) {
|
|
return {
|
|
Literal(node: TSESTree.Literal): void {
|
|
if (
|
|
typeof node.value === "number" &&
|
|
(node.value < Number.MIN_SAFE_INTEGER ||
|
|
node.value > Number.MAX_SAFE_INTEGER)
|
|
) {
|
|
context.report({ node, messageId: "unsafe" });
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
/** A loop statement, whose last `continue` `no-useless-continue` checks. */
|
|
type LoopStatement =
|
|
| TSESTree.DoWhileStatement
|
|
| TSESTree.ForInStatement
|
|
| TSESTree.ForOfStatement
|
|
| TSESTree.ForStatement
|
|
| TSESTree.WhileStatement;
|
|
|
|
/** A loop's trailing unlabeled `continue`, and whether removing it is a safe fix. */
|
|
interface TrailingContinue {
|
|
statement: TSESTree.ContinueStatement;
|
|
removable: boolean;
|
|
}
|
|
|
|
/**
|
|
* The unlabeled `continue` a loop ends with, and whether removing it is a
|
|
* safe fix: it is in a block, rather than the loop's whole body.
|
|
*/
|
|
function trailingContinue(node: LoopStatement): TrailingContinue | null {
|
|
if (node.body.type === AST_NODE_TYPES.BlockStatement) {
|
|
const last = node.body.body.at(-1);
|
|
return last?.type === AST_NODE_TYPES.ContinueStatement &&
|
|
last.label === null
|
|
? { statement: last, removable: true }
|
|
: null;
|
|
}
|
|
if (
|
|
node.body.type === AST_NODE_TYPES.ContinueStatement &&
|
|
node.body.label === null
|
|
) {
|
|
return { statement: node.body, removable: false };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* `no-useless-continue` - an unlabeled `continue` that ends a loop body. A
|
|
* trailing `continue;` in a block is removed by the fix; a loop whose whole
|
|
* body is `continue;` is only reported.
|
|
*/
|
|
const noUselessContinueRule: ProjectRule = {
|
|
meta: {
|
|
type: "suggestion",
|
|
docs: {
|
|
description: "Disallow an unlabeled `continue` at the end of a loop",
|
|
},
|
|
fixable: "code",
|
|
schema: [],
|
|
messages: {
|
|
useless:
|
|
"The statement `continue` at the end of the loop and without a label is useless.",
|
|
},
|
|
},
|
|
create(context) {
|
|
function check(node: LoopStatement): void {
|
|
const found: TrailingContinue | null = trailingContinue(node);
|
|
if (found === null) {
|
|
return;
|
|
}
|
|
if (found.removable) {
|
|
context.report({
|
|
node: found.statement,
|
|
messageId: "useless",
|
|
fix: (fixer): RuleTextEdit => fixer.remove(found.statement),
|
|
});
|
|
} else {
|
|
context.report({ node: found.statement, messageId: "useless" });
|
|
}
|
|
}
|
|
return {
|
|
DoWhileStatement: check,
|
|
ForInStatement: check,
|
|
ForOfStatement: check,
|
|
ForStatement: check,
|
|
WhileStatement: check,
|
|
};
|
|
},
|
|
};
|
|
|
|
/** Node keys that say where a node is, not what it is, in ESLint and Oxlint. */
|
|
const POSITION_KEYS: ReadonlySet<string> = new Set<string>([
|
|
"parent",
|
|
"range",
|
|
"loc",
|
|
"start",
|
|
"end",
|
|
]);
|
|
|
|
/** Node types whose member lists mean the same in any order. */
|
|
const UNORDERED_TYPES: ReadonlySet<string> = new Set<string>([
|
|
"TSInterfaceBody",
|
|
"TSTypeLiteral",
|
|
"TSUnionType",
|
|
"TSIntersectionType",
|
|
]);
|
|
|
|
/** Type annotations too simple for an alias of their own (`no-useless-type`). */
|
|
const SIMPLE_TYPES: ReadonlySet<string> = new Set<string>([
|
|
"TSAnyKeyword",
|
|
"TSBigIntKeyword",
|
|
"TSBooleanKeyword",
|
|
"TSIntrinsicKeyword",
|
|
"TSNeverKeyword",
|
|
"TSNullKeyword",
|
|
"TSNumberKeyword",
|
|
"TSObjectKeyword",
|
|
"TSStringKeyword",
|
|
"TSSymbolKeyword",
|
|
"TSUndefinedKeyword",
|
|
"TSUnknownKeyword",
|
|
"TSVoidKeyword",
|
|
]);
|
|
|
|
/**
|
|
* A node's shape as text, without positions, so two declarations compare
|
|
* alike wherever they sit. Members of a type literal, interface body,
|
|
* union, or intersection are sorted, since their order means nothing.
|
|
*/
|
|
function serialize(value: unknown): string {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map((item: unknown) => serialize(item)).join(",")}]`;
|
|
}
|
|
if (typeof value !== "object" || value === null) {
|
|
return JSON.stringify(value) ?? "undefined";
|
|
}
|
|
const unordered: boolean =
|
|
"type" in value && UNORDERED_TYPES.has(String(value.type));
|
|
const parts: string[] = Object.entries(value)
|
|
.filter(([key]: [string, unknown]) => !POSITION_KEYS.has(key))
|
|
.map(([key, child]: [string, unknown]) => {
|
|
const text: string =
|
|
unordered && Array.isArray(child)
|
|
? `[${child
|
|
.map((item: unknown) => serialize(item))
|
|
.toSorted()
|
|
.join(",")}]`
|
|
: serialize(child);
|
|
return `${key}:${text}`;
|
|
});
|
|
return `{${parts.join(",")}}`;
|
|
}
|
|
|
|
/** The top-level declarations in a block, exported or not, that `matches`. */
|
|
function declarationsOf<T extends TSESTree.Node>(
|
|
statements: TSESTree.Node[],
|
|
matches: (node: TSESTree.Node) => node is T,
|
|
): T[] {
|
|
const found: T[] = [];
|
|
for (const statement of statements) {
|
|
const declaration: TSESTree.Node | null =
|
|
statement.type === AST_NODE_TYPES.ExportNamedDeclaration
|
|
? statement.declaration
|
|
: statement;
|
|
if (declaration !== null && matches(declaration)) {
|
|
found.push(declaration);
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/** Whether a node is an interface declaration. */
|
|
function isInterface(
|
|
node: TSESTree.Node,
|
|
): node is TSESTree.TSInterfaceDeclaration {
|
|
return node.type === AST_NODE_TYPES.TSInterfaceDeclaration;
|
|
}
|
|
|
|
/** Whether a node is a type alias declaration. */
|
|
function isTypeAlias(
|
|
node: TSESTree.Node,
|
|
): node is TSESTree.TSTypeAliasDeclaration {
|
|
return node.type === AST_NODE_TYPES.TSTypeAliasDeclaration;
|
|
}
|
|
|
|
/** Groups nodes by a key and returns the groups with more than one node. */
|
|
function duplicateGroups<T>(nodes: T[], keyOf: (node: T) => string): T[][] {
|
|
const groups: Map<string, T[]> = Map.groupBy(nodes, keyOf);
|
|
return [...groups.values()].filter((group: T[]) => group.length > 1);
|
|
}
|
|
|
|
/** What an interface declares, its `extends` list included. */
|
|
function interfaceShape(node: TSESTree.TSInterfaceDeclaration): string {
|
|
return `${serialize(node.extends)}&${serialize(node.body)}`;
|
|
}
|
|
|
|
/** An interface's name. */
|
|
function interfaceName(node: TSESTree.TSInterfaceDeclaration): string {
|
|
return node.id.name;
|
|
}
|
|
|
|
/** What a type alias stands for. */
|
|
function typeAliasShape(node: TSESTree.TSTypeAliasDeclaration): string {
|
|
return serialize(node.typeAnnotation);
|
|
}
|
|
|
|
/**
|
|
* `no-duplicate-interfaces` - two interfaces in one block with the same
|
|
* name, which merge silently, or with the same shape, which could be one.
|
|
*/
|
|
const noDuplicateInterfacesRule: ProjectRule = {
|
|
meta: {
|
|
type: "suggestion",
|
|
docs: {
|
|
description:
|
|
"Disallow interfaces with the same name or shape in one block",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
sameShape:
|
|
"Found multiple interfaces with the same shape, possibly mergeable.",
|
|
sameName:
|
|
"Found multiple interfaces named '{{name}}', possibly not intended.",
|
|
},
|
|
},
|
|
create(context) {
|
|
function check(statements: TSESTree.Node[]): void {
|
|
const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf(
|
|
statements,
|
|
isInterface,
|
|
);
|
|
for (const node of duplicateGroups(nodes, interfaceShape).flat()) {
|
|
context.report({ node, messageId: "sameShape" });
|
|
}
|
|
for (const node of duplicateGroups(nodes, interfaceName).flat()) {
|
|
context.report({
|
|
node,
|
|
messageId: "sameName",
|
|
data: { name: node.id.name },
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
BlockStatement(node: TSESTree.BlockStatement): void {
|
|
check(node.body);
|
|
},
|
|
Program(node: TSESTree.Program): void {
|
|
check(node.body);
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
/** `no-duplicate-types` - two type aliases in one block with the same type. */
|
|
const noDuplicateTypesRule: ProjectRule = {
|
|
meta: {
|
|
type: "suggestion",
|
|
docs: {
|
|
description: "Disallow type aliases with the same type in one block",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
sameShape:
|
|
"Found multiple types with the same shape, possibly mergeable.",
|
|
},
|
|
},
|
|
create(context) {
|
|
function check(statements: TSESTree.Node[]): void {
|
|
const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf(
|
|
statements,
|
|
isTypeAlias,
|
|
);
|
|
for (const node of duplicateGroups(nodes, typeAliasShape).flat()) {
|
|
context.report({ node, messageId: "sameShape" });
|
|
}
|
|
}
|
|
return {
|
|
BlockStatement(node: TSESTree.BlockStatement): void {
|
|
check(node.body);
|
|
},
|
|
Program(node: TSESTree.Program): void {
|
|
check(node.body);
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
/** A class member that can carry the `private` keyword. */
|
|
type AccessibilityCarrier =
|
|
| TSESTree.AccessorProperty
|
|
| TSESTree.MethodDefinition
|
|
| TSESTree.PropertyDefinition
|
|
| TSESTree.TSAbstractAccessorProperty
|
|
| TSESTree.TSAbstractMethodDefinition
|
|
| TSESTree.TSAbstractPropertyDefinition
|
|
| TSESTree.TSParameterProperty;
|
|
|
|
/**
|
|
* `no-modifier-private` - class members are made private with `#`, never the
|
|
* `private` keyword, which only TypeScript enforces. A `private constructor`
|
|
* has no `#` form and stays allowed.
|
|
*/
|
|
const noModifierPrivateRule: ProjectRule = {
|
|
meta: {
|
|
type: "suggestion",
|
|
docs: { description: "Disallow the private modifier" },
|
|
schema: [],
|
|
messages: {
|
|
private: "Use a # private name instead of the private modifier.",
|
|
},
|
|
},
|
|
create(context) {
|
|
function check(node: AccessibilityCarrier): void {
|
|
const isConstructor: boolean =
|
|
node.type === AST_NODE_TYPES.MethodDefinition &&
|
|
node.kind === "constructor";
|
|
if (node.accessibility === "private" && !isConstructor) {
|
|
context.report({ node, messageId: "private" });
|
|
}
|
|
}
|
|
return {
|
|
AccessorProperty: check,
|
|
MethodDefinition: check,
|
|
PropertyDefinition: check,
|
|
TSAbstractAccessorProperty: check,
|
|
TSAbstractMethodDefinition: check,
|
|
TSAbstractPropertyDefinition: check,
|
|
TSParameterProperty: check,
|
|
};
|
|
},
|
|
};
|
|
|
|
/** Whether a type is too simple to need an alias: a keyword, literal, or bare name. */
|
|
function isSimpleType(node: TSESTree.TypeNode): boolean {
|
|
if (SIMPLE_TYPES.has(node.type)) {
|
|
return true;
|
|
}
|
|
if (node.type === AST_NODE_TYPES.TSLiteralType) {
|
|
return node.literal.type === AST_NODE_TYPES.Literal;
|
|
}
|
|
return (
|
|
node.type === AST_NODE_TYPES.TSTypeReference &&
|
|
node.typeName.type === AST_NODE_TYPES.Identifier &&
|
|
node.typeArguments === undefined
|
|
);
|
|
}
|
|
|
|
/** `no-useless-type` - a type alias for a keyword, literal, or bare type name. */
|
|
const noUselessTypeRule: ProjectRule = {
|
|
meta: {
|
|
type: "suggestion",
|
|
docs: { description: "Disallow type aliases too simple to need a name" },
|
|
schema: [],
|
|
messages: { simple: "The type is too simple to need an alias." },
|
|
},
|
|
create(context) {
|
|
return {
|
|
TSTypeAliasDeclaration(node: TSESTree.TSTypeAliasDeclaration): void {
|
|
if (isSimpleType(node.typeAnnotation)) {
|
|
context.report({ node, messageId: "simple" });
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
/** A module specifier read as a path: relative, absolute, or a `#` alias. */
|
|
const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
|
|
|
|
/**
|
|
* Script module extensions an import may name: our `.ts` files and `.js`,
|
|
* `.mts`, or `.mjs` package subpaths. `.cts`, `.cjs`, `.tsx`, and `.jsx` are
|
|
* not among them.
|
|
*/
|
|
const SCRIPT_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
".ts",
|
|
".js",
|
|
".mts",
|
|
".mjs",
|
|
]);
|
|
|
|
/**
|
|
* Whether `source` names an asset rather than a script module: a query string
|
|
* such as `?raw`, or a final path segment whose extension, from its last dot,
|
|
* is not `.ts`, `.js`, `.mts`, or `.mjs`. Bare package names are left alone,
|
|
* dots and all, as are extensionless paths.
|
|
*/
|
|
function isAssetSource(source: string): boolean {
|
|
if (source.includes("?")) {
|
|
return true;
|
|
}
|
|
const segments: string[] = source.split("/");
|
|
if (!PATH_SPECIFIER_PATTERN.test(source)) {
|
|
const nameLength: number = source.startsWith("@") ? 2 : 1;
|
|
if (segments.length <= nameLength) {
|
|
return false;
|
|
}
|
|
}
|
|
const lastSegment: string = segments.at(-1) ?? "";
|
|
if (
|
|
lastSegment === "." ||
|
|
lastSegment === ".." ||
|
|
!lastSegment.includes(".")
|
|
) {
|
|
return false;
|
|
}
|
|
const extension: string = lastSegment.slice(lastSegment.lastIndexOf("."));
|
|
return !SCRIPT_EXTENSIONS.has(extension);
|
|
}
|
|
|
|
/**
|
|
* `no-asset-imports` - rejects importing anything but a script module: a
|
|
* source path, static or a dynamic `import()` with a string literal, ending in
|
|
* an extension other than `.ts`, `.js`, `.mts`, or `.mjs` (`.css`, `.json`,
|
|
* `.svg`, `.jsx`, `.tsx`, `.cjs`) or carrying a query string (`?raw`, `?url`),
|
|
* and any import or export with import attributes. `.js`, `.mts`, and `.mjs`
|
|
* cover package subpaths such as `lit/decorators.js` or
|
|
* `pdfjs-dist/legacy/build/pdf.mjs`; our own files are still `.ts` only. Bare
|
|
* package names, `#` aliases, and `node:` builtins
|
|
* without an extension pass; package subpaths are checked like paths.
|
|
*/
|
|
const noAssetImportsRule: ProjectRule = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"Disallow importing assets instead of .ts, .js, .mts, or .mjs modules",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
extension: "Only .ts, .js, .mts and .mjs modules may be imported.",
|
|
attributes: "Import attributes are not allowed.",
|
|
},
|
|
},
|
|
create(context) {
|
|
function checkSource(source: TSESTree.StringLiteral): void {
|
|
if (isAssetSource(source.value)) {
|
|
context.report({ node: source, messageId: "extension" });
|
|
}
|
|
}
|
|
function checkDeclaration(
|
|
node:
|
|
| TSESTree.ImportDeclaration
|
|
| TSESTree.ExportNamedDeclaration
|
|
| TSESTree.ExportAllDeclaration,
|
|
): void {
|
|
if (node.source === null) {
|
|
return;
|
|
}
|
|
checkSource(node.source);
|
|
if (node.attributes.length > 0) {
|
|
context.report({ node, messageId: "attributes" });
|
|
}
|
|
}
|
|
return {
|
|
ImportDeclaration: checkDeclaration,
|
|
ExportNamedDeclaration: checkDeclaration,
|
|
ExportAllDeclaration: checkDeclaration,
|
|
ImportExpression(node: TSESTree.ImportExpression): void {
|
|
if (
|
|
node.source.type === AST_NODE_TYPES.Literal &&
|
|
typeof node.source.value === "string"
|
|
) {
|
|
checkSource(node.source);
|
|
}
|
|
if (node.options !== null) {
|
|
context.report({ node, messageId: "attributes" });
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* This repository's changes to the Oxlint categories in `vite.config.ts`,
|
|
* which turn every non-nursery category on: its own rules, the category
|
|
* rules it turns off, and options that differ from a rule's defaults.
|
|
*/
|
|
const rules: DummyRuleMap = {
|
|
// The explicit-types template's `camelcase` stand-in: no `_` inside a name
|
|
// once leading and trailing ones are trimmed, unless it has no lowercase
|
|
// ASCII letter (UPPER_SNAKE).
|
|
"id-match": [
|
|
"error",
|
|
"^_*[^_]*_*$|^[^a-z]*$",
|
|
{
|
|
properties: false,
|
|
classFields: false,
|
|
onlyDeclarations: false,
|
|
ignoreDestructuring: true,
|
|
},
|
|
],
|
|
// On Node, these come from an explicit `node:` import instead of the global scope.
|
|
"no-restricted-globals": [
|
|
"error",
|
|
"window",
|
|
"process",
|
|
"Buffer",
|
|
"global",
|
|
"setImmediate",
|
|
"clearImmediate",
|
|
],
|
|
// Single capital type parameters, which explicit-types requires, may be one letter.
|
|
"id-length": ["error", { exceptionPatterns: ["^[A-Z]$"] }],
|
|
// Option tweaks.
|
|
"one-var": ["error", "never"],
|
|
"no-use-before-define": ["error", { functions: false }],
|
|
"func-style": ["error", "declaration", { allowArrowFunctions: true }],
|
|
// Each limit is the smallest value the current code passes.
|
|
complexity: ["error", 26],
|
|
"max-depth": ["error", 6],
|
|
"max-lines": [
|
|
"error",
|
|
{ max: 2048, skipBlankLines: false, skipComments: false },
|
|
],
|
|
"max-lines-per-function": ["error", 124],
|
|
"max-params": ["error", 4],
|
|
"max-statements": ["error", 32],
|
|
"import/max-dependencies": ["error", { max: 13 }],
|
|
"unicorn/max-nested-calls": ["error", { max: 4 }],
|
|
// No lint suppression and only `.ts` imports, as in the plugin template.
|
|
"project/no-disable-comments": "error",
|
|
"project/no-asset-imports": "error",
|
|
// Ports of the hugoalh `recommended` rules Oxlint lacks, and ASCII-only source.
|
|
"project/prefer-ascii": "error",
|
|
"project/no-duplicate-typeofs": "error",
|
|
"project/no-empty-yield": "error",
|
|
"project/no-nan": "error",
|
|
"project/no-unsafe-number": "error",
|
|
"project/no-useless-continue": "error",
|
|
"project/no-duplicate-interfaces": "error",
|
|
"project/no-duplicate-types": "error",
|
|
"project/no-modifier-private": "error",
|
|
"project/no-useless-type": "error",
|
|
// Off, collides with a repo or plugin rule, Oxfmt, or another category rule:
|
|
// Collides with `#private`: members are public unless marked so.
|
|
"typescript/explicit-member-accessibility": "off",
|
|
// Collides with `typescript/prefer-readonly-parameter-types` being off.
|
|
"typescript/prefer-readonly": "off",
|
|
// Collides with Node's and the web's mutable types (`Response`, `Stats`).
|
|
"typescript/prefer-readonly-parameter-types": "off",
|
|
// Collides with explicit-types `no-utility-types`, which bans `Record`.
|
|
"typescript/consistent-indexed-object-style": "off",
|
|
// Collides with explicitly annotated declarations.
|
|
"typescript/no-inferrable-types": "off",
|
|
// Collides with import-structure `enforce-import-order`.
|
|
"sort-imports": "off",
|
|
// Collides with import-structure `enforce-mod-file` barrels.
|
|
"oxc/no-barrel-file": "off",
|
|
// Collides with exporting each declaration where it stands.
|
|
"import/group-exports": "off",
|
|
// Collides with exporting each declaration where it stands.
|
|
"import/exports-last": "off",
|
|
// Collides with `import/no-default-export`.
|
|
"import/prefer-default-export": "off",
|
|
// Collides with `unicorn/prefer-ternary`.
|
|
"no-ternary": "off",
|
|
// Collides with `require-await` and `promise-function-async`.
|
|
"oxc/no-async-await": "off",
|
|
// Collides with `typescript/prefer-optional-chain`.
|
|
"oxc/no-optional-chaining": "off",
|
|
// Collides with file mode checks such as `mode & 0o077`.
|
|
"no-bitwise": "off",
|
|
// Collides with explicit-types `no-optional-null`, which makes `undefined` the absent value.
|
|
"no-undefined": "off",
|
|
// Collides with `typescript/no-floating-promises`, which `void` satisfies.
|
|
"no-void": "off",
|
|
// Collides with JSON and Node APIs, which return `null`.
|
|
"unicorn/no-null": "off",
|
|
// Collides with `import/no-default-export`.
|
|
"import/no-named-export": "off",
|
|
// Collides with `typescript/consistent-type-imports`.
|
|
"import/consistent-type-specifier-style": "off",
|
|
// Collides with Oxfmt, which lowercases hex digits back.
|
|
"unicorn/number-literal-case": "off",
|
|
// Node code, everywhere: this action runs on Node.
|
|
// Node built-ins are the action's runtime.
|
|
"import/no-nodejs-modules": "off",
|
|
// Command-line entry points set their exit status.
|
|
"unicorn/no-process-exit": "off",
|
|
// The action reports to the job log through the console.
|
|
"no-console": "off",
|
|
// Sequential awaits are intentional; workarounds cost more than they save.
|
|
"no-await-in-loop": "off",
|
|
// Not type-aware; TypeScript's `noImplicitReturns` covers it.
|
|
"consistent-return": "off",
|
|
// Misfires on complete switches, like `consistent-return`; TypeScript covers it.
|
|
"typescript/consistent-return": "off",
|
|
// TypeScript's type checking covers exhaustive switches.
|
|
"typescript/switch-exhaustiveness-check": "off",
|
|
// TypeScript checks completeness; a forced `default:` hides missing cases.
|
|
"default-case": "off",
|
|
// Off, too much churn:
|
|
// Literal numbers read better than a constant per use.
|
|
"no-magic-numbers": "off",
|
|
// Keys follow meaning, not the alphabet.
|
|
"sort-keys": "off",
|
|
// Comments may start with code or lowercase identifiers.
|
|
"capitalized-comments": "off",
|
|
// explicit-types `require-return-type` covers return types.
|
|
"typescript/explicit-function-return-type": "off",
|
|
// explicit-types `require-return-type` covers return types.
|
|
"typescript/explicit-module-boundary-types": "off",
|
|
// Empty callbacks are legitimate no-ops.
|
|
"no-empty-function": "off",
|
|
// Methods may not need `this`.
|
|
"class-methods-use-this": "off",
|
|
// Braces only where a case declares.
|
|
"unicorn/switch-case-braces": "off",
|
|
// Plain property reads are fine.
|
|
"prefer-destructuring": "off",
|
|
// Object rest and spread are idiomatic.
|
|
"oxc/no-rest-spread-properties": "off",
|
|
// Short numbers need no separators.
|
|
"unicorn/numeric-separators-style": "off",
|
|
// `continue` keeps loop bodies flat.
|
|
"no-continue": "off",
|
|
};
|
|
|
|
/** Node code (`vite.config.ts` and `scripts/`): the first `projectOverrides` entry. */
|
|
const nodeRules: DummyRuleMap = {
|
|
// Vite and the Oxlint plugin host read a default export.
|
|
"import/no-default-export": "off",
|
|
};
|
|
|
|
/**
|
|
* The whole `overrides` array of `vite.config.ts`: the Node code entry first,
|
|
* then each per-file exception, each with a comment saying why. Later entries
|
|
* win over earlier ones, and all of them over `rules`.
|
|
*/
|
|
export const projectOverrides: OxlintOverride[] = [
|
|
{
|
|
files: ["vite.config.ts", "scripts/**"],
|
|
rules: nodeRules,
|
|
},
|
|
// exitCode must return the promise it marks handled; an async wrapper's
|
|
// promise would reject unhandled before callers await it.
|
|
{
|
|
files: ["src/processes/child.ts"],
|
|
rules: { "typescript/promise-function-async": "off" },
|
|
},
|
|
// Posted Gitea text shows emoji verdict marks and a middle dot.
|
|
{
|
|
files: [
|
|
"src/post/post.ts",
|
|
"src/post/post.test.ts",
|
|
"src/profiles/profiles.ts",
|
|
"src/profiles/profiles.test.ts",
|
|
],
|
|
rules: { "project/prefer-ascii": "off" },
|
|
},
|
|
// The workflow test checks literal Gitea Actions `${{ }}` expressions.
|
|
{
|
|
files: ["tests/workflow.test.ts"],
|
|
rules: { "no-template-curly-in-string": "off" },
|
|
},
|
|
];
|
|
|
|
/**
|
|
* The repo-local plugin, namespace `project`: its own rules, enabled as
|
|
* `project/<rule>`, and `configs.project`, the rule list above.
|
|
*/
|
|
const plugin = {
|
|
meta: { name: "project" },
|
|
rules: {
|
|
"no-disable-comments": noDisableCommentsRule,
|
|
"no-asset-imports": noAssetImportsRule,
|
|
"prefer-ascii": preferAsciiRule,
|
|
"no-duplicate-typeofs": noDuplicateTypeofsRule,
|
|
"no-empty-yield": noEmptyYieldRule,
|
|
"no-nan": noNanRule,
|
|
"no-unsafe-number": noUnsafeNumberRule,
|
|
"no-useless-continue": noUselessContinueRule,
|
|
"no-duplicate-interfaces": noDuplicateInterfacesRule,
|
|
"no-duplicate-types": noDuplicateTypesRule,
|
|
"no-modifier-private": noModifierPrivateRule,
|
|
"no-useless-type": noUselessTypeRule,
|
|
},
|
|
configs: { project: { rules } },
|
|
};
|
|
|
|
export default plugin;
|