diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 588dcf3..e2dd491 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -1,20 +1,29 @@ /** - * `project` — this repository's changes to the Oxlint categories in - * `vite.config.ts`. Its rules of its own are `no-disable-comments` and - * `no-asset-imports`. + * `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, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core"; +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. */ +/** 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. */ @@ -38,7 +47,7 @@ const DIRECTIVE_PATTERN: RegExp = /^\s*(?:eslint|oxlint)-(?:disable|enable)/u; const RULE_NAME: string = "no-disable-comments"; /** - * `no-disable-comments` — rejects every `eslint-disable*`, `eslint-enable*`, + * `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. @@ -68,6 +77,501 @@ const noDisableCommentsRule: ProjectRule = { }, }; +/** 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 = new Set(["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 = new Set([ + "parent", + "range", + "loc", + "start", + "end", +]); + +/** Node types whose member lists mean the same in any order. */ +const UNORDERED_TYPES: ReadonlySet = new Set([ + "TSInterfaceBody", + "TSTypeLiteral", + "TSUnionType", + "TSIntersectionType", +]); + +/** Type annotations too simple for an alias of their own (`no-useless-type`). */ +const SIMPLE_TYPES: ReadonlySet = new Set([ + "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( + 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(nodes: T[], keyOf: (node: T) => string): T[][] { + const groups: Map = 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; @@ -104,7 +608,7 @@ function isAssetSource(source: string): boolean { } /** - * `no-asset-imports` — rejects importing anything but a script module: a + * `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`), @@ -203,6 +707,17 @@ const rules: DummyRuleMap = { // 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", @@ -314,6 +829,16 @@ export const projectOverrides: OxlintOverride[] = [ 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" }, + }, ]; /** @@ -325,6 +850,16 @@ const plugin = { 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 } }, };