Files
metabase/frontend/lint/oxlint/config.mjs
2026-10-05 22:41:31 +13:00

231 lines
7.4 KiB
JavaScript

import { createRequire } from "node:module";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import micromatch from "micromatch";
import policy from "../config.mjs";
import { boundarySettings, boundaryOptions } from "../module-boundaries.mjs";
import defaults from "./rule-defaults.json" with { type: "json" };
import ruleMap from "./rule-map.json" with { type: "json" };
export const root = path.resolve(import.meta.dirname, "../../..");
function merge(left, right) {
const result = { ...left };
for (const [key, value] of Object.entries(right)) {
result[key] =
value && typeof value === "object" && !Array.isArray(value)
? merge(result[key] ?? {}, value)
: value;
}
return result;
}
function options(name, value) {
const [severity, ...supplied] = Array.isArray(value) ? value : [value];
if (severity === "off" || severity === 0) return "off";
// Without an options object, native no-unused-vars ignores names matching /^_/.
// ESLint's no-unused-vars with no options reports those names.
const result = [
...(defaults[name] ?? (name === "no-unused-vars" ? [{}] : [])),
];
supplied.forEach((value, index) => {
result[index] =
value && typeof value === "object" && !Array.isArray(value)
? merge(result[index] ?? {}, value)
: value;
});
return [severity, ...result];
}
// Oxlint does not apply per-file settings overrides.
const settingsEntries = policy
.filter(
(entry) =>
entry.settings ||
entry.languageOptions?.parserOptions ||
entry.languageOptions?.sourceType,
)
.map((entry) => ({
matches: entry.files
? micromatch.matcher(entry.files, { dot: true })
: () => true,
sourceType: entry.languageOptions?.sourceType,
settings: entry.settings ?? {},
parserOptions: entry.languageOptions?.parserOptions ?? {},
}));
// Files matching the same scopes share one settings object,
// because the resolver adapter keys its WeakMaps by settings identity.
const combinations = new Map();
let lastFile;
let lastSettings;
export function settingsForFile(filename) {
if (filename === lastFile) return lastSettings;
const relative = path.relative(root, filename).replaceAll("\\", "/");
const matched = settingsEntries.flatMap((entry, index) =>
entry.matches(relative) ? [index] : [],
);
const key = matched.join(",");
if (!combinations.has(key)) {
combinations.set(
key,
matched.reduce(
(result, index) => ({
sourceType: settingsEntries[index].sourceType ?? result.sourceType,
settings: merge(result.settings, settingsEntries[index].settings),
parserOptions: merge(
result.parserOptions,
settingsEntries[index].parserOptions,
),
}),
{ settings: {}, parserOptions: {} },
),
);
}
lastFile = filename;
lastSettings = combinations.get(key);
return lastSettings;
}
const nativePlugins = ["typescript", "react", "import", "jest"];
const jsNamespaces = [
...new Set(
Object.values(ruleMap)
.filter((name) => name.includes("/"))
.map((name) => name.slice(0, name.indexOf("/"))),
),
].filter((name) => !nativePlugins.includes(name));
export const jsRules = Object.fromEntries(
jsNamespaces
.filter((name) => name !== "no-only-tests")
.map((namespace) => [
namespace,
[
...new Set(
Object.values(ruleMap)
.filter((name) => name.startsWith(`${namespace}/`))
.map((name) => name.slice(namespace.length + 1)),
),
],
]),
);
const require = createRequire(import.meta.url);
function hasCssModulesPlugin() {
if (!(process.env.CI || process.env.LINT_CSS_MODULES === "true"))
return false;
try {
require.resolve("eslint-plugin-postcss-modules");
return true;
} catch {
return false;
}
}
function glob(pattern) {
// globset's brace alternatives are equivalent to these simple extglobs.
const translated = pattern.replace(
/@\(([^)]+)\)/g,
(_, alternatives) => `{${alternatives.replaceAll("|", ",")}}`,
);
// Unlike ESLint flat config, oxlint matches a glob without a slash at any depth.
return translated.includes("/") ? translated : `./${translated}`;
}
export function createConfig() {
const overrides = [];
const ignorePatterns = [];
const configuredExtensions = new Set();
for (const entry of policy) {
for (const [name, value] of Object.entries(entry.settings ?? {})) {
if (
name.startsWith("boundaries/") &&
(!Object.hasOwn(boundarySettings, name) ||
!isDeepStrictEqual(value, boundarySettings[name]))
) {
throw new Error(`Unsupported boundary checker setting: ${name}`);
}
}
if (entry.ignores && !entry.files) {
ignorePatterns.push(...entry.ignores);
continue;
}
const rules = {};
for (const [name, value] of Object.entries(entry.rules ?? {})) {
// Native unused-vars accounts for JSX references itself.
if (name === "react/jsx-uses-vars") continue;
const mapped = ruleMap[name];
const normalized = options(name, value);
if (
name === "boundaries/element-types" &&
normalized !== "off" &&
!isDeepStrictEqual(normalized.slice(1), [boundaryOptions])
) {
throw new Error(
"The boundary checker requires the shared enforced policy",
);
}
if (!mapped) {
if (normalized === "off") continue;
throw new Error(
`Map the new lint rule to oxlint or a JS plugin: ${name}`,
);
}
// A TypeScript extension can share its native rule with the disabled ESLint base rule.
const extension = `@typescript-eslint/${name}`;
if (normalized === "off" && ruleMap[extension] === mapped) {
if (Object.hasOwn(entry.rules, extension)) continue;
// An earlier policy entry enabled the TypeScript extension.
// Turning the shared native rule off here disables it in every file both entries match.
if (configuredExtensions.has(extension)) {
throw new Error(
`Disabling ${name} after ${extension} requires an explicit ${extension} setting in the same policy entry`,
);
}
}
if (name.startsWith("@typescript-eslint/") && normalized !== "off") {
configuredExtensions.add(name);
}
rules[mapped] = normalized;
}
const globals = Object.fromEntries(
Object.entries(entry.languageOptions?.globals ?? {}).map(
([name, value]) => [
name,
value === false ? "readonly" : value === true ? "writable" : value,
],
),
);
overrides.push({
files: (entry.files ?? ["**/*"]).map(glob),
...(entry.ignores ? { excludeFiles: entry.ignores.map(glob) } : {}),
...(Object.keys(globals).length ? { globals } : {}),
rules,
});
}
const namespaces = [...jsNamespaces];
if (hasCssModulesPlugin()) {
jsRules["postcss-modules"] = ["no-undef-class"];
namespaces.push("postcss-modules");
overrides.push({
files: ["**/*.{js,jsx,ts,tsx}"],
rules: { "postcss-modules/no-undef-class": "error" },
});
}
return {
categories: { correctness: "off" },
plugins: nativePlugins,
settings: { react: { version: "18.2.0" } },
ignorePatterns,
overrides,
jsPlugins: namespaces.map((name) => ({
name,
specifier: path.join(import.meta.dirname, "plugins", `${name}.mjs`),
})),
};
}