mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
refactor(lint): use typescript for Oxlint plugins (#3170)
* chore(lint): type local Oxlint plugins * fix(lint): track local plugins in Knip * type resolved
This commit is contained in:
@@ -31,6 +31,8 @@ export default {
|
||||
".": {
|
||||
entry: [
|
||||
"scripts/*.{js,ts,mjs,mts}",
|
||||
// Loaded by Oxlint from the string paths in vite.config.ts.
|
||||
"oxlint-plugins/*.ts",
|
||||
"tests/**/*.test.ts",
|
||||
"tests/helpers.ts",
|
||||
// Filesystem route entries in the standalone Vite/Worker fixture.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Oxlint JS plugin: prefer-import-alias.
|
||||
* Oxlint plugin: prefer-import-alias.
|
||||
*
|
||||
* Reads `compilerOptions.paths` from the closest enclosing tsconfig.json
|
||||
* (following `extends` chains) and reports any relative import whose resolved
|
||||
@@ -15,15 +15,45 @@ import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
import { definePlugin, defineRule, type Context, type ESTree } from "@oxlint/plugins";
|
||||
|
||||
const STRIP_EXT_RE = /\.(?:m?js|c?js|tsx?|jsx)$/;
|
||||
const aliasCache = new Map();
|
||||
|
||||
type Alias = {
|
||||
keyPrefix: string;
|
||||
keySuffix: string;
|
||||
targetPrefix: string;
|
||||
targetSuffix: string;
|
||||
wildcard: boolean;
|
||||
};
|
||||
|
||||
type ResolvedTsconfigPaths = {
|
||||
paths: Record<string, unknown>;
|
||||
baseDir: string;
|
||||
};
|
||||
|
||||
type TsconfigJson = {
|
||||
extends?: unknown;
|
||||
compilerOptions?: {
|
||||
baseUrl?: unknown;
|
||||
paths?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type ImportNode =
|
||||
| ESTree.ImportDeclaration
|
||||
| ESTree.ExportNamedDeclaration
|
||||
| ESTree.ExportAllDeclaration
|
||||
| ESTree.ImportExpression;
|
||||
|
||||
const aliasCache = new Map<string, Alias[]>();
|
||||
|
||||
/**
|
||||
* Strip JSONC line and block comments while respecting string literals.
|
||||
* Walks the input character-by-character to avoid eating `//` or `/*` inside
|
||||
* strings.
|
||||
*/
|
||||
function stripJsonc(raw) {
|
||||
function stripJsonc(raw: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
const len = raw.length;
|
||||
@@ -62,7 +92,7 @@ function stripJsonc(raw) {
|
||||
}
|
||||
|
||||
/** Walk up from `dir` looking for the closest `tsconfig.json`. */
|
||||
function findTsconfig(dir) {
|
||||
function findTsconfig(dir: string): string | null {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const candidate = path.join(dir, "tsconfig.json");
|
||||
@@ -77,9 +107,9 @@ function findTsconfig(dir) {
|
||||
* Resolve a tsconfig's `extends` reference. Supports relative paths, bare
|
||||
* specifiers (resolved via require.resolve), and the implicit `.json` suffix.
|
||||
*/
|
||||
function resolveExtends(extendsValue, fromDir) {
|
||||
function resolveExtends(extendsValue: string, fromDir: string): string | null {
|
||||
if (typeof extendsValue !== "string") return null;
|
||||
let resolved;
|
||||
let resolved: string;
|
||||
if (extendsValue.startsWith(".")) {
|
||||
resolved = path.resolve(fromDir, extendsValue);
|
||||
} else {
|
||||
@@ -100,24 +130,35 @@ function resolveExtends(extendsValue, fromDir) {
|
||||
* Walk a tsconfig (and its `extends` chain) to find the first one with
|
||||
* `compilerOptions.paths` set. Returns `{ paths, baseDir }` or null.
|
||||
*/
|
||||
function loadTsconfigWithExtends(tsconfigPath, visited = new Set()) {
|
||||
function loadTsconfigWithExtends(
|
||||
tsconfigPath: string,
|
||||
visited = new Set<string>(),
|
||||
): ResolvedTsconfigPaths | null {
|
||||
if (visited.has(tsconfigPath)) return null;
|
||||
visited.add(tsconfigPath);
|
||||
let cfg;
|
||||
let cfg: TsconfigJson;
|
||||
try {
|
||||
cfg = JSON.parse(stripJsonc(fs.readFileSync(tsconfigPath, "utf-8")));
|
||||
cfg = JSON.parse(stripJsonc(fs.readFileSync(tsconfigPath, "utf-8"))) as TsconfigJson;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const dir = path.dirname(tsconfigPath);
|
||||
const compilerOptions = cfg?.compilerOptions ?? {};
|
||||
if (compilerOptions.paths) {
|
||||
const baseDir = compilerOptions.baseUrl ? path.resolve(dir, compilerOptions.baseUrl) : dir;
|
||||
return { paths: compilerOptions.paths, baseDir };
|
||||
if (
|
||||
compilerOptions.paths !== null &&
|
||||
typeof compilerOptions.paths === "object" &&
|
||||
!Array.isArray(compilerOptions.paths)
|
||||
) {
|
||||
const baseDir =
|
||||
typeof compilerOptions.baseUrl === "string"
|
||||
? path.resolve(dir, compilerOptions.baseUrl)
|
||||
: dir;
|
||||
return { paths: compilerOptions.paths as Record<string, unknown>, baseDir };
|
||||
}
|
||||
if (cfg.extends) {
|
||||
const list = Array.isArray(cfg.extends) ? cfg.extends : [cfg.extends];
|
||||
const list: unknown[] = Array.isArray(cfg.extends) ? cfg.extends : [cfg.extends];
|
||||
for (const entry of list) {
|
||||
if (typeof entry !== "string") continue;
|
||||
const next = resolveExtends(entry, dir);
|
||||
if (!next) continue;
|
||||
const result = loadTsconfigWithExtends(next, visited);
|
||||
@@ -128,11 +169,11 @@ function loadTsconfigWithExtends(tsconfigPath, visited = new Set()) {
|
||||
}
|
||||
|
||||
/** Build the alias table for a tsconfig. */
|
||||
function buildAliases(tsconfigPath) {
|
||||
function buildAliases(tsconfigPath: string): Alias[] {
|
||||
const resolved = loadTsconfigWithExtends(tsconfigPath);
|
||||
if (!resolved) return [];
|
||||
const { paths, baseDir } = resolved;
|
||||
const aliases = [];
|
||||
const aliases: Alias[] = [];
|
||||
for (const [key, targets] of Object.entries(paths)) {
|
||||
if (!Array.isArray(targets)) continue;
|
||||
for (const target of targets) {
|
||||
@@ -155,7 +196,7 @@ function buildAliases(tsconfigPath) {
|
||||
}
|
||||
|
||||
/** Load aliases for the closest tsconfig to a given source file. */
|
||||
function loadAliasesForFile(filename) {
|
||||
function loadAliasesForFile(filename: string): Alias[] {
|
||||
const tsconfigPath = findTsconfig(path.dirname(filename)) ?? findTsconfig(process.cwd());
|
||||
if (!tsconfigPath) return [];
|
||||
let aliases = aliasCache.get(tsconfigPath);
|
||||
@@ -175,7 +216,7 @@ function loadAliasesForFile(filename) {
|
||||
* do not provide it, resolving relative filenames against the lint context's
|
||||
* cwd rather than the JS plugin process's cwd.
|
||||
*/
|
||||
function getPhysicalFilename(context) {
|
||||
function getPhysicalFilename(context: Context): string | null {
|
||||
const filename = context.physicalFilename ?? context.filename;
|
||||
if (
|
||||
typeof filename !== "string" ||
|
||||
@@ -194,7 +235,11 @@ function getPhysicalFilename(context) {
|
||||
* Reverse-map an absolute import path to a tsconfig-defined bare specifier.
|
||||
* Returns null if no alias exposes this file.
|
||||
*/
|
||||
function tryReverseAlias(absoluteImport, importerDir, aliases) {
|
||||
function tryReverseAlias(
|
||||
absoluteImport: string,
|
||||
importerDir: string,
|
||||
aliases: readonly Alias[],
|
||||
): string | null {
|
||||
const stripped = absoluteImport.replace(STRIP_EXT_RE, "");
|
||||
for (const a of aliases) {
|
||||
const targetPrefix = a.targetPrefix.replace(STRIP_EXT_RE, "");
|
||||
@@ -229,7 +274,7 @@ function tryReverseAlias(absoluteImport, importerDir, aliases) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rule = {
|
||||
const rule = defineRule({
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
@@ -239,9 +284,9 @@ const rule = {
|
||||
fixable: "code",
|
||||
},
|
||||
createOnce(context) {
|
||||
function check(node) {
|
||||
function check(node: ImportNode): void {
|
||||
const source = node.source;
|
||||
if (!source || typeof source.value !== "string") return;
|
||||
if (!source || source.type !== "Literal" || typeof source.value !== "string") return;
|
||||
const importPath = source.value;
|
||||
if (!importPath.startsWith(".")) return;
|
||||
const filename = getPhysicalFilename(context);
|
||||
@@ -269,9 +314,9 @@ const rule = {
|
||||
ImportExpression: check,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default {
|
||||
export default definePlugin({
|
||||
meta: { name: "vinext-local" },
|
||||
rules: { "prefer-import-alias": rule },
|
||||
};
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Oxlint JS plugin: prefer-shared-utils.
|
||||
* Oxlint plugin: prefer-shared-utils.
|
||||
*
|
||||
* Reports local redefinitions of shared helpers. The protected helper list is
|
||||
* derived from the actual helper modules at lint startup, so adding a new
|
||||
@@ -13,6 +13,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { definePlugin, defineRule, type Context, type Node } from "@oxlint/plugins";
|
||||
|
||||
const VINEXT_SOURCE_SEGMENT = "/packages/vinext/src/";
|
||||
const VINEXT_SOURCE_ROOT = "packages/vinext/src";
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -42,17 +44,17 @@ const EXPORT_VARIABLE_RE =
|
||||
/^export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\b[^=]*=\s*(?:async\s*)?(?:function\b|\([^)]*\)(?:\s*:\s*[^=]+?)?\s*=>|[A-Za-z_$][\w$]*\s*(?:=>|;))/gm;
|
||||
const EXPORT_NAMED_RE = /^export\s*\{([^}]+)\}\s*(?:from\s*["'][^"']+["'])?\s*;?/gm;
|
||||
|
||||
function normalizeFilename(filename) {
|
||||
function normalizeFilename(filename: string) {
|
||||
return filename.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
||||
}
|
||||
|
||||
function readExportedFunctionNames(absPath) {
|
||||
function readExportedFunctionNames(absPath: string): string[] {
|
||||
const source = fs.readFileSync(absPath, "utf-8");
|
||||
const exportNames = new Set();
|
||||
const exportNames = new Set<string>();
|
||||
for (const match of source.matchAll(EXPORT_FUNCTION_RE)) {
|
||||
exportNames.add(match[1]);
|
||||
}
|
||||
@@ -71,7 +73,7 @@ function readExportedFunctionNames(absPath) {
|
||||
return Array.from(exportNames);
|
||||
}
|
||||
|
||||
function listUtilsModules(srcRootAbs) {
|
||||
function listUtilsModules(srcRootAbs: string): string[] {
|
||||
const utilsDir = path.join(srcRootAbs, "utils");
|
||||
const entries = fs.readdirSync(utilsDir, { withFileTypes: true });
|
||||
return entries
|
||||
@@ -106,11 +108,11 @@ const SHARED_UTILITY_DECLARATION =
|
||||
"g",
|
||||
);
|
||||
|
||||
function maskRange(source, start, end) {
|
||||
function maskRange(source: string, start: number, end: number) {
|
||||
return `${source.slice(0, start)}${" ".repeat(end - start)}${source.slice(end)}`;
|
||||
}
|
||||
|
||||
function maskCommentsAndQuotedStrings(source, options = { scanTemplateBodies: true }) {
|
||||
function maskCommentsAndQuotedStrings(source: string, options = { scanTemplateBodies: true }) {
|
||||
let masked = source;
|
||||
let i = 0;
|
||||
while (i < masked.length) {
|
||||
@@ -176,20 +178,20 @@ function maskCommentsAndQuotedStrings(source, options = { scanTemplateBodies: tr
|
||||
return masked;
|
||||
}
|
||||
|
||||
function isCanonicalDefinitionFile(filename, modulePath) {
|
||||
function isCanonicalDefinitionFile(filename: string, modulePath: string) {
|
||||
return filename.endsWith(`/packages/vinext/src/${modulePath}`);
|
||||
}
|
||||
|
||||
function isVinextSourceFile(filename) {
|
||||
function isVinextSourceFile(filename: string) {
|
||||
return filename.includes(VINEXT_SOURCE_SEGMENT);
|
||||
}
|
||||
|
||||
function isLintedProjectFile(filename) {
|
||||
function isLintedProjectFile(filename: string) {
|
||||
if (isVinextSourceFile(filename)) return true;
|
||||
return filename.startsWith(`${normalizeFilename(REPO_ROOT)}/tests/`);
|
||||
}
|
||||
|
||||
function reportIfSharedUtility(context, filename, node, name) {
|
||||
function reportIfSharedUtility(context: Context, filename: string, node: Node, name: string) {
|
||||
const utility = SHARED_UTILS.get(name);
|
||||
if (!utility || isCanonicalDefinitionFile(filename, utility.modulePath)) return;
|
||||
|
||||
@@ -199,7 +201,7 @@ function reportIfSharedUtility(context, filename, node, name) {
|
||||
});
|
||||
}
|
||||
|
||||
const rule = {
|
||||
const rule = defineRule({
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
@@ -225,9 +227,9 @@ const rule = {
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default {
|
||||
export default definePlugin({
|
||||
meta: { name: "vinext-utils" },
|
||||
rules: { "prefer-shared-utils": rule },
|
||||
};
|
||||
});
|
||||
@@ -30,6 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "2.31.0",
|
||||
"@mswjs/interceptors": "catalog:",
|
||||
"@oxlint/plugins": "1.81.0",
|
||||
"@playwright/test": "catalog:",
|
||||
"@sentry/nextjs": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
|
||||
Generated
+9
@@ -269,6 +269,9 @@ importers:
|
||||
'@mswjs/interceptors':
|
||||
specifier: 'catalog:'
|
||||
version: 0.41.9
|
||||
'@oxlint/plugins':
|
||||
specifier: 1.81.0
|
||||
version: 1.81.0
|
||||
'@playwright/test':
|
||||
specifier: 'catalog:'
|
||||
version: 1.60.0
|
||||
@@ -3821,6 +3824,10 @@ packages:
|
||||
resolution: {integrity: sha512-OhgMQeMmZA0dcFcX4/priaJZWdFECxiClgq6mRX6aatZEcV9PbKC3P3/v8U1hVjviT1i5U+vR8lAtBV6m4FXAA==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
'@oxlint/plugins@1.81.0':
|
||||
resolution: {integrity: sha512-HhD8kd3r6XpelZkhxhRty/po8V6yK1VV63Eq4kSsOS2IoHUywG3DV2EX+z/ZHw46aLI74Y6aA604NRiAqLKW9w==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.6':
|
||||
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -10046,6 +10053,8 @@ snapshots:
|
||||
|
||||
'@oxlint/plugins@1.73.0': {}
|
||||
|
||||
'@oxlint/plugins@1.81.0': {}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.6':
|
||||
optional: true
|
||||
|
||||
|
||||
+2
-2
@@ -50,8 +50,8 @@ export default defineConfig({
|
||||
},
|
||||
plugins: ["typescript", "unicorn", "import", "react"],
|
||||
jsPlugins: [
|
||||
"./oxlint-plugins/prefer-import-alias.js",
|
||||
"./oxlint-plugins/prefer-shared-utils.js",
|
||||
"./oxlint-plugins/prefer-import-alias.ts",
|
||||
"./oxlint-plugins/prefer-shared-utils.ts",
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
|
||||
Reference in New Issue
Block a user