/** * `project` — lint rules this repository holds its own source to, on top of * the Oxlint categories in `vite.config.ts`. Its rules of its own are `no-disable-comments` and `no-asset-imports`. * * Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry. * * @module */ import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core"; import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types"; import type { DummyRuleMap } from "vite-plus/lint"; /** The source code both hosts hand a rule, with the comment list. */ interface ProjectSourceCode extends TextSourceCode { getAllComments(): TSESTree.Comment[]; } /** 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; /** 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" }); } } }, }; }, }; /** 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` package subpaths. */ const SCRIPT_EXTENSIONS: readonly string[] = [".ts", ".js"]; /** * Whether `source` names an asset rather than a script module: a query string * such as `?raw`, or a final path segment with an extension other than `.ts` * or `.js`. 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; } return !SCRIPT_EXTENSIONS.some((extension: string): boolean => lastSegment.endsWith(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` or `.js` (`.css`, `.json`, `.svg`, `.tsx`) or * carrying a query string (`?raw`, `?url`), and any import or export with * import attributes. `.js` covers package subpaths such as * `lit/decorators.js`. 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 or .js modules" }, schema: [], messages: { extension: "Only .ts and .js 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 own rule list, spread by `vite.config.ts`: rules that * sit outside the Oxlint categories, plus the template's extras. Built-in Oxlint rule ids sit here too, so * `vite.config.ts` lists no rule by name. */ 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 }, ], // Rules outside the categories. "no-array-constructor": "error", "no-case-declarations": "error", "no-empty": "error", "no-fallthrough": "error", "no-inner-declarations": "error", "no-prototype-builtins": "error", "no-redeclare": "error", "no-regex-spaces": "error", "no-var": "error", "prefer-const": "error", "require-await": "error", "require-yield": "error", "typescript/adjacent-overload-signatures": "error", "typescript/ban-ts-comment": "error", "typescript/no-empty-interface": "error", "typescript/no-empty-object-type": "error", "typescript/no-explicit-any": "error", "typescript/no-namespace": "error", "typescript/no-non-null-asserted-optional-chain": "error", "typescript/no-unsafe-function-type": "error", "typescript/no-wrapper-object-types": "error", "typescript/prefer-as-const": "error", "typescript/prefer-namespace-keyword": "error", "typescript/triple-slash-reference": "error", // On Node, these come from an explicit `node:` import instead of the global scope. "no-restricted-globals": [ "error", "window", "process", "Buffer", "global", "setImmediate", "clearImmediate", ], // The explicit-types template's extras. "import/no-cycle": "error", // `../` is banned in every file; anything above a file needs a `#` alias. "import/no-relative-parent-imports": "error", "typescript/consistent-type-imports": "error", "typescript/no-import-type-side-effects": "error", "typescript/no-non-null-assertion": "error", // No disable comments at all. "project/no-disable-comments": "error", "project/no-asset-imports": "error", "unicorn/no-abusive-eslint-disable": "error", // Pedantic, but Node's and the web's own types (`Response`, `Stats`, // `RequestInit`) are mutable, so no parameter of theirs can pass; the // explicit-types template and the app repos turn it off too. "typescript/prefer-readonly-parameter-types": "off", }; /** * The repo-local plugin, namespace `project`: its own rules, enabled as * `project/`, and `configs.project`, the rule list above. */ const plugin = { meta: { name: "project" }, rules: { "no-disable-comments": noDisableCommentsRule, "no-asset-imports": noAssetImportsRule, }, configs: { project: { rules } }, }; export default plugin;