lint.config.ts
import { fileURLToPath } from "node:url";
import { antipattern, correctness } from "@effect/tsgo/oxlint-presets";
import solidV2Strict from "eslint-plugin-solid/configs/v2-strict";
import { defineConfig } from "oxlint";
import type { OxlintConfig } from "oxlint";
// bench/ holds untyped Node scripts that drive staging by hand; they aren't app code.
// tools/oxlint/anti-slop is vendored upstream source (github.com/dmmulroy/anti-slop), except
// tools/oxlint/anti-slop/tranzfer, which is ours.
const generatedIgnores = [
"**/file-routes.d.ts",
"docs/design/**",
"**/styled-system/**",
"bench/**",
"tools/oxlint/anti-slop/*.ts",
"tools/oxlint/anti-slop/{effect,rules,shared,vendor}/**",
];
// Absolute, because apps/web/vite.config.ts loads this file too and a
// relative specifier would resolve against apps/web.
const antiSlopPath = (entry: string) =>
fileURLToPath(new URL(`./tools/oxlint/anti-slop/${entry}`, import.meta.url));
// The core rules we kept from the old Ultracite preset. Oxlint's default
// correctness rules still run on top of these.
const baseRules: OxlintConfig["rules"] = {
eqeqeq: "error",
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"import/no-cycle": "error",
"import/no-duplicates": "error",
"no-var": "error",
"prefer-const": "error",
"sort-keys": "error",
"typescript/await-thenable": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-exports": "error",
"typescript/consistent-type-imports": "error",
"typescript/no-base-to-string": "error",
"typescript/no-deprecated": "error",
"typescript/no-explicit-any": "error",
"typescript/no-floating-promises": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-misused-promises": "error",
"typescript/no-misused-spread": "error",
"typescript/no-non-null-assertion": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unsafe-argument": "error",
"typescript/no-unsafe-assignment": "error",
"typescript/no-unsafe-call": "error",
"typescript/no-unsafe-member-access": "error",
"typescript/no-unsafe-return": "error",
"typescript/no-unsafe-type-assertion": "error",
"typescript/only-throw-error": "error",
"typescript/restrict-template-expressions": "error",
"typescript/switch-exhaustiveness-check": "error",
};
// Every rule dmmulroy/anti-slop ships, as its README lists them.
const antiSlopRules: OxlintConfig["rules"] = {
"anti-slop-effect/no-manual-effect-error-tag": "error",
"anti-slop-effect/no-manual-tag-comparison": "error",
"anti-slop-effect/no-manual-tagged-construction": "error",
"anti-slop-effect/no-service-constructor-imports": "error",
"anti-slop-effect/prefer-effect-match": "error",
"anti-slop/no-array-filter-map": "error",
"anti-slop/no-chained-type-assertions": "error",
"anti-slop/no-conditional-empty-object-spread": "error",
"anti-slop/no-known-value-widening": "error",
"anti-slop/no-module-mocking": "error",
"anti-slop/no-object-parameters": "error",
"anti-slop/no-reduce-accumulator-copy": "error",
"anti-slop/no-reflect-apply": "error",
"anti-slop/no-reflect-get": "error",
"anti-slop/no-runtime-typeof": "error",
"anti-slop/no-shape-in-symbol-names": "error",
"anti-slop/no-unknown-parameters": "error",
"anti-slop/no-unknown-returns": "error",
"anti-slop/no-unknown-type-aliases": "error",
"anti-slop/no-unsafe-dictionary-type": "error",
"anti-slop/no-widen-then-assert": "error",
"anti-slop/require-safety-comment-for-type-assertion": "error",
"oxc/no-accumulating-spread": "error",
};
// Rules we wrote, in tools/oxlint/anti-slop/tranzfer.
const tranzferRules: OxlintConfig["rules"] = {
"tranzfer/no-debug-markers-in-tests": "error",
"tranzfer/no-import-then-reexport": "error",
};
const effectErrorRules = {
"effecttsgo/floating-effect": "error",
"effecttsgo/floating-effect-in-vitest": "error",
"effecttsgo/global-error-in-effect-catch": "error",
"effecttsgo/missing-effect-context": "error",
"effecttsgo/missing-effect-error": "error",
"effecttsgo/missing-layer-context": "error",
"effecttsgo/missing-star-in-yield-effect-gen": "error",
"effecttsgo/outdated-api": "error",
"effecttsgo/return-effect-in-gen": "error",
"effecttsgo/run-effect-inside-effect": "error",
"effecttsgo/try-catch-in-effect-gen": "error",
"effecttsgo/unknown-in-effect-catch": "error",
} as const;
const vitePlusPlugin = {
name: "vite-plus",
specifier: "vite-plus/oxlint-plugin",
};
export const lintConfig = (
paths: {
api: string[];
components: string[];
web: string[];
},
opts?: { root?: boolean },
): OxlintConfig => {
const effectOverride =
paths.api.length === 0
? []
: [
{
files: paths.api,
rules: {
...antipattern.rules,
...correctness.rules,
...effectErrorRules,
},
},
];
const config: OxlintConfig = defineConfig({
ignorePatterns: generatedIgnores,
jsPlugins: [
vitePlusPlugin,
{ name: "anti-slop", specifier: antiSlopPath("index.ts") },
{ name: "anti-slop-effect", specifier: antiSlopPath("effect/index.ts") },
{ name: "tranzfer", specifier: antiSlopPath("tranzfer/index.ts") },
],
overrides: [
{
files: paths.web,
jsPlugins: ["eslint-plugin-solid"],
rules: {
...solidV2Strict.rules,
"solid/reactivity": "error",
// String styles carry CSS variables. The object autofix does not
// type-check against those props.
"solid/style-prop": "off",
},
},
{
files: paths.web,
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
message:
"The web app has one API client, WebLayer in src/api/client.ts, and it runs in the browser only (docs/SOLID-EFFECT-BINDING.md).",
name: "effect/rpc/RpcClient",
},
],
},
],
},
},
{
// The one place the web app builds its API client.
files: ["**/src/api/client.ts"],
rules: { "no-restricted-imports": "off" },
},
{
files: ["**/src/guides/Article.tsx"],
rules: {
// Its HTML is our own Markdown from content/, compiled at build time.
"solid/no-innerhtml": "off",
},
},
{
files: paths.components,
rules: {
"func-style": ["error", "declaration", { allowArrowFunctions: true }],
"unicorn/filename-case": ["error", { cases: { kebabCase: true, pascalCase: true } }],
},
},
{
files: ["**/*.{test,spec}.{ts,tsx}"],
rules: { "vitest/no-focused-tests": "error" },
},
{
// The rule's own fixtures hold the markers it looks for.
files: ["tools/oxlint/anti-slop/tranzfer/rules/no-debug-markers-in-tests.test.ts"],
rules: { "tranzfer/no-debug-markers-in-tests": "off" },
},
...effectOverride,
{
files: ["apps/api/src/**", "packages/db/src/**"],
rules: {
"tranzfer/no-count-subquery-string": "error",
"tranzfer/no-table-names-in-sql": "error",
"tranzfer/one-clock-expression": "error",
},
},
{
// These files are where layers get provided, once per process.
files: ["apps/api/src/index.ts", "apps/api/scripts/*.ts"],
rules: { "effecttsgo/strict-effect-provide": "off" },
},
{
// RuntimeContext.phantom is Layer.empty, a type-only stub with no scope to outlive.
files: [
"apps/api/src/infrastructure/auth.ts",
"apps/api/src/infrastructure/email.ts",
"apps/api/src/infrastructure/r2.ts",
],
rules: { "effecttsgo/strict-effect-provide": "off" },
},
{
// The test builds the Plans layer it exercises and provides it to the one effect under test.
files: ["apps/api/test/deliveries.test.ts"],
rules: { "effecttsgo/strict-effect-provide": "off" },
},
{
files: ["packages/db/src/schema.ts"],
rules: {
// Column order in drizzle table objects defines DDL column order;
// keep the better-auth table layout.
"sort-keys": "off",
},
},
],
plugins: ["eslint", "typescript", "unicorn", "oxc", "import", "effecttsgo", "vitest"],
rules: {
...baseRules,
...antiSlopRules,
...tranzferRules,
"vite-plus/prefer-vite-plus-imports": "error",
// The vitest plugin is on for no-focused-tests alone. Its default rules
// misread @effect/vitest's layer() blocks.
"vitest/no-standalone-expect": "off",
"vitest/require-mock-type-parameters": "off",
},
settings: solidV2Strict.settings,
});
if (opts?.root === true) {
config.options = { denyWarnings: true, typeAware: true, typeCheck: true };
}
return config;
};
export const rootLint = lintConfig(
{
api: ["apps/api/**", "apps/web/src/**", "e2e/**", "packages/contracts/**", "packages/db/**"],
components: ["apps/web/**/*.tsx"],
web: ["apps/web/**"],
},
{ root: true },
);
export const webLint = lintConfig({
api: [],
components: ["**/*.tsx"],
web: ["**/*.{js,jsx,ts,tsx}"],
});