/** * `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`. * * 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`, * `.mts`, or `.mjs` package subpaths. `.cts`, `.cjs`, `.tsx`, and `.jsx` are * not among them. */ const SCRIPT_EXTENSIONS: ReadonlySet = 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 }], "unicorn/import-style": ["error", { styles: { "node:path": { named: 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", 96], "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", // 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 `require-await`, which fails an `async` wrapper with no `await`. "typescript/promise-function-async": "off", // Collides with explicit-types `no-undefined-type`: a `let` that `try` // assigns would need `T | undefined`. "init-declarations": "off", // Collides with `no-await-in-loop`: sequential steps chain through `reduce`. "unicorn/no-array-reduce": "off", // Collides with literal Gitea Actions `${{ }}` expressions in workflow strings. "no-template-curly-in-string": "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", // PENDING USER: union members are values in profiles.json, the Gitea API, // and posted comments; renaming them changes behavior. "explicit-types/upper-snake-string-unions": "off", // PENDING USER: a fitting `name` needs a constructor and changes logged // error names. "unicorn/custom-error-definition": "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/`), spread into `overrides`. */ const nodeRules: DummyRuleMap = { // Vite and the Oxlint plugin host read a default export. "import/no-default-export": "off", // Node code: tooling imports Node's built-ins. "import/no-nodejs-modules": "off", // Node code: a CLI exits with a status. "unicorn/no-process-exit": "off", // Node code: a CLI reports through the console. "no-console": "off", }; /** * The repo-local plugin, namespace `project`: its own rules, enabled as * `project/`, `configs.project`, the rule list above, and * `configs.node`, the Node code offs. */ const plugin = { meta: { name: "project" }, rules: { "no-disable-comments": noDisableCommentsRule, "no-asset-imports": noAssetImportsRule, }, configs: { project: { rules }, node: { rules: nodeRules } }, }; export default plugin;