Replace ESLint and dpdm with Oxlint (#7330)

# Description of Changes
Smaller scope than #6689 to try and get this finished.

Replace ESLint and dpdm with Oxlint, a TS linter written in Rust so its
performance is dramatically better than the existing tools we use.

## Speed improvement
- Current ESLint run: 13.76s
- Current dpdm run: 3.59s
- Total time: 17.35s
- New Oxlint run: 0.90s

So Oxlint is about a 20x speed improvement.

## Differences
When I last tried to do this, we could recreate our rules identically
with Oxlint, but that's not true any more. Oxlint has no current
equivalent for ESLint's `no-restricted-syntax` rule, which we were using
to ban usages of `<button>` and stuff in specific components to try and
encourage them to use our shared UI. This is a very recent addition to
our linting config, and personally I'm willing to drop it for now at
least. We can still ban specific imports in files, so the files which we
were trying to enforce shared UI will still ban directly importing
Mantine, so that'll probably be most of the cases still caught, but I
think there are other ways we can encourage using the shared UI beyond
just using the linter for it.

I did try building a custom TS rule for it and it only slowed it down a
tiny bit (it took 1.1s) but it had to be built on an unreleased alpha
API which just sounds like a maintenance headache we don't need to deal
with for a rule that we don't really need.
This commit is contained in:
James Brunton
2026-08-07 16:02:11 +00:00
committed by GitHub
parent 408f9ef148
commit 37a48aa7a7
34 changed files with 840 additions and 1649 deletions
-8
View File
@@ -73,14 +73,6 @@ updates:
- "react-dom"
- "@types/react"
- "@types/react-dom"
typescript-eslint:
patterns:
- "@typescript-eslint/*"
- "typescript-eslint"
eslint:
patterns:
- "eslint"
- "@eslint/*"
vite:
patterns:
- "vite"
+5 -14
View File
@@ -260,8 +260,7 @@ tasks:
desc: "Run linting"
deps: [install]
cmds:
- task: lint:eslint
- task: lint:dpdm
- task: lint:oxlint
- task: lint:colors
- task: lint:css
@@ -290,25 +289,17 @@ tasks:
cmds:
- node editor/scripts/lint/theme-lint.mjs contrast
lint:eslint:
desc: "Run ESLint linting"
lint:oxlint:
desc: "Run oxlint linting"
deps: [install]
cmds:
- npx eslint --max-warnings=0
lint:dpdm:
desc: "Run circular import linting"
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers the whole editor tree, including the portal layer.
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
- npx oxlint --config oxlint.config.ts --max-warnings=0
lint:fix:
desc: "Auto-fix lint issues"
deps: [install]
cmds:
- npx eslint --fix
- npx oxlint --config oxlint.config.ts --fix
format:
desc: "Auto-fix code formatting"
+1 -1
View File
@@ -19,6 +19,6 @@
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
"oxc.oxc-vscode", // Oxc (oxlint) extension for JavaScript/TypeScript linting
]
}
+1 -1
View File
@@ -192,7 +192,7 @@ What goes where:
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (all enforced by the linter). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
+1 -1
View File
@@ -158,7 +158,7 @@ Stirling-PDF/
│ │ │ └── locales/ # Internationalization files (JSON)
│ │ └── vite.config.ts # Vite configuration
│ ├── package.json # Shared workspace dependencies
│ └── eslint.config.mjs # Shared lint config
│ └── oxlint.config.ts # Shared lint config
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files
+1 -1
View File
@@ -1,7 +1,7 @@
import { beforeAll } from "vitest";
import { setProjectAnnotations } from "@storybook/react-vite";
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
// eslint-disable-next-line no-restricted-imports -- Storybook-only: the sibling preview config has no @-alias.
// oxlint-disable-next-line no-restricted-imports -- Storybook-only: the sibling preview config has no @-alias.
import * as projectAnnotations from "./preview";
// Include addon-a11y's annotations so its axe checks run under Vitest, not only
+2 -2
View File
@@ -6,7 +6,7 @@ All frontend commands are run from the repository root using [Task](https://task
- `task frontend:build` — production build
- `task frontend:test` — run tests
- `task frontend:test:watch` — run tests in watch mode
- `task frontend:lint` — run ESLint + cycle detection
- `task frontend:lint` — run linting
- `task frontend:typecheck` — run TypeScript type checking
- `task frontend:check` — run typecheck + lint + test
- `task frontend:install` — install npm dependencies
@@ -18,7 +18,7 @@ For desktop app development, see the [Tauri](#tauri) section below.
`frontend/` is a workspace containing one or more apps. Today it holds the
PDF editor under `frontend/editor/`; new apps (the developer portal, etc.)
will sit alongside it as siblings. Shared tooling — `package.json`, `node_modules`,
`.storybook/`, ESLint, Prettier — lives at `frontend/` so every app installs
`.storybook/`, oxlint, Prettier — lives at `frontend/` so every app installs
once and lints with the same config.
## Environment Variables
+1 -1
View File
@@ -13,7 +13,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// tsx/node16 can't resolve the @portal alias here, so import by relative .ts path.
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import {
buildManifest,
type CategoryMap,
+1 -1
View File
@@ -8,5 +8,5 @@
// sync-portal-docs.mts imports the shared transform by its .ts path (run via tsx).
"allowImportingTsExtensions": true
},
"include": ["./**/*.ts", "./**/*.mts"]
"include": ["./**/*.ts", "./**/*.mts", "../../oxlint.config.ts"]
}
@@ -32,9 +32,9 @@ import {
prepaidSnapshotFromWallet,
} from "@app/components/shared/config/configSections/usageMeters";
// Relative (not @app/*) so the co-located CSS + sibling component resolve directly.
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import "./Payg.css";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import SpendCapControl from "./SpendCapControl";
import { useTranslation } from "react-i18next";
import type { Wallet } from "@app/hooks/useWallet";
@@ -33,13 +33,13 @@ import LockIcon from "@mui/icons-material/LockOutlined";
import { useTranslation } from "react-i18next";
import { useRenderCount } from "@app/hooks/useRenderCount";
import { useWallet } from "@app/hooks/useWallet";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import "./Payg.css";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import "./PaygFree.css";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import UpgradeModal from "./UpgradeModal";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import { DocHelp } from "./Payg";
import {
FreeMeterPanel,
@@ -12,7 +12,7 @@ import {
DEFAULT_CAP_PRESETS,
SpendCapControl as SharedSpendCapControl,
} from "@app/billing";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import "./SpendCapControl.css";
export { DEFAULT_CAP_PRESETS };
@@ -25,9 +25,9 @@ import ArrowBackIcon from "@mui/icons-material/ArrowBackRounded";
import ShieldIcon from "@mui/icons-material/ShieldOutlined";
import CheckCircleIcon from "@mui/icons-material/CheckCircleRounded";
import { useTranslation } from "react-i18next";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import "./UpgradeModal.css";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import SpendCapControl from "./SpendCapControl";
/**
@@ -4,7 +4,7 @@
* The cloud/ layer is the SHARED hosted experience consumed by BOTH the saas
* (web) and desktop (Tauri) leaves, so it must stay platform-portable: it can't
* read {@code import.meta.env}, {@code window.location} or web storage directly
* (the cloud ESLint guardrail enforces this). The PAYG dev-preview route
* (the cloud oxlint guardrail enforces this). The PAYG dev-preview route
* ({@code /dev/payg-preview}) is a saas-only local-design affordance that
* synthesises a wallet from {@code localStorage} when the real backend isn't
* mounted — all three of those banned reads. {@link useWallet} reaches that
@@ -1,6 +1,6 @@
import React from "react";
import { addCollection, Icon } from "@iconify/react";
import iconSet from "../../../assets/material-symbols-icons.json"; // eslint-disable-line no-restricted-imports -- Outside app paths
import iconSet from "../../../assets/material-symbols-icons.json"; // oxlint-disable-line no-restricted-imports -- Outside app paths
// Load icons synchronously at import time - guaranteed to be ready on first render
let iconsLoaded = false;
@@ -12,7 +12,7 @@ import {
} from "@mantine/core";
import { isAxiosError } from "axios";
import apiClient from "@app/services/apiClient";
import frontendLicenses from "../../../../../assets/3rdPartyLicenses.json"; // eslint-disable-line no-restricted-imports -- asset lives outside @app alias root
import frontendLicenses from "../../../../../assets/3rdPartyLicenses.json"; // oxlint-disable-line no-restricted-imports -- asset lives outside @app alias root
interface Dependency {
moduleName?: string;
@@ -4,7 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { getToolOgImage } from "@app/data/ogImage";
// Build tooling (plain ESM, node:fs only) - import the helpers for coverage.
// eslint-disable-next-line no-restricted-imports -- build script lives outside the @app alias root
// oxlint-disable-next-line no-restricted-imports -- build script lives outside the @app alias root
import {
buildOgTags,
injectOg,
@@ -665,7 +665,7 @@ export const useCompareOperation = (): CompareOperationHook => {
if (workerRef.current) {
try {
workerRef.current.terminate();
// eslint-disable-next-line no-empty
// oxlint-disable-next-line no-empty
} catch {}
workerRef.current = null;
}
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- Axios-compatible API requires matching axios's `any` signatures */
/* oxlint-disable typescript/no-explicit-any -- Axios-compatible API requires matching axios's `any` signatures */
import { fetch } from "@tauri-apps/plugin-http";
import {
shouldUseFastLocalTransport,
+1 -1
View File
@@ -5,7 +5,7 @@
import "@app/utils/patchDomForTranslators";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "../vite-env.d.ts"; // eslint-disable-line no-restricted-imports -- Outside app paths
import "../vite-env.d.ts"; // oxlint-disable-line no-restricted-imports -- Outside app paths
import "@app/styles/index.css"; // Import global styles
import React from "react";
import ReactDOM from "react-dom/client";
@@ -31,7 +31,7 @@ vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
// The SaaS usersBackend lives under src/saas; the portal vitest project resolves
// @app to proprietary (there's no @saas alias here), so the SaaS impl can only be
// exercised by importing it directly by path.
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import { usersBackend } from "../../saas/portal/usersBackend";
const server = setupServer(...teamSaasHandlers);
@@ -19,7 +19,7 @@ import type { Team } from "@portal/api/teams";
// Prove the gating against the real flavor capability files. The portal vitest
// project resolves @app to proprietary and has no @saas alias, so the SaaS set is
// reached by path; the self-hosted set uses the @proprietary alias.
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import { usersCapabilities as saasCaps } from "../../../saas/portal/usersCapabilities";
import { usersCapabilities as selfHostedCaps } from "@proprietary/portal/usersCapabilities";
@@ -37,10 +37,12 @@ vi.mock("@app/auth/supabase/supabaseClient", () => ({
vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
vi.mock("@app/portal/usersCapabilities", async () => ({
// oxlint-disable-next-line no-restricted-imports -- resolve the real SaaS module past the mocked @app alias
usersCapabilities: (await import("../../saas/portal/usersCapabilities"))
.usersCapabilities,
}));
vi.mock("@app/portal/usersBackend", async () => ({
// oxlint-disable-next-line no-restricted-imports -- resolve the real SaaS module past the mocked @app alias
usersBackend: (await import("../../saas/portal/usersBackend")).usersBackend,
}));
@@ -45,10 +45,12 @@ vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
// Force the SaaS flavor: the portal vitest project resolves @app to proprietary,
// so redirect the two flavor seams to their real SaaS implementations.
vi.mock("@app/portal/usersCapabilities", async () => ({
// oxlint-disable-next-line no-restricted-imports -- resolve the real SaaS module past the mocked @app alias
usersCapabilities: (await import("../../saas/portal/usersCapabilities"))
.usersCapabilities,
}));
vi.mock("@app/portal/usersBackend", async () => ({
// oxlint-disable-next-line no-restricted-imports -- resolve the real SaaS module past the mocked @app alias
usersBackend: (await import("../../saas/portal/usersBackend")).usersBackend,
}));
@@ -35,6 +35,7 @@ vi.mock("@app/auth/supabase/supabaseClient", () => ({
// The portal test project's @app points at proprietary; resolve the flavor seam
// to the real SaaS backend (same approach as Users.saas.test).
vi.mock("@app/portal/usersBackend", async () => ({
// oxlint-disable-next-line no-restricted-imports -- resolve the real SaaS module past the mocked @app alias
usersBackend: (await import("../../saas/portal/usersBackend")).usersBackend,
}));
@@ -1,10 +1,10 @@
import React, { useState } from "react";
import { Anchor, Group, Stack, Text, Paper, Skeleton } from "@mantine/core";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import ApiKeySection from "./apiKeys/ApiKeySection";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import RefreshModal from "./apiKeys/RefreshModal";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import useApiKey from "./apiKeys/hooks/useApiKey";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -282,7 +282,7 @@ const LATIN_LETTER = /[a-z]/gi;
const WORD = /[\p{L}']+/gu;
// ASCII whitespace plus the no-break spaces pdf.js extraction commonly emits.
// eslint-disable-next-line no-control-regex -- vertical tab is intentional ASCII whitespace
// oxlint-disable-next-line no-control-regex -- vertical tab is intentional ASCII whitespace
const WHITESPACE = /[\t\n\x0B\f\r \u00A0\u2007\u202F]+/g;
// Structural signal patterns. Boolean-presence ones stay non-global (safe .test()),
@@ -52,7 +52,7 @@ vi.mock("@app/services/userService", () => ({
}));
// Imported after the mocks so the provider picks them up.
const { AuthProvider, useAuth } = await import("./UseSession");
const { AuthProvider, useAuth } = await import("@app/auth/UseSession");
/** Surfaces `loading` so a test can assert on it rather than on the container. */
function LoadingProbe() {
+1
View File
@@ -98,6 +98,7 @@ function prerenderOgPlugin(isSaas: boolean): PluginOption {
name: "prerender-og",
apply: "build" as const,
async closeBundle() {
// oxlint-disable-next-line no-restricted-imports -- vite config runs before path aliases resolve, so a relative import is required here
const { prerenderOg } = await import("./scripts/og-prerender.mjs");
const ogBase = (
process.env.VITE_OG_BASE_URL ||
-327
View File
@@ -1,327 +0,0 @@
// @ts-check
import eslint from "@eslint/js";
import globals from "globals";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
const srcGlobs = [
// The portal layers live under editor/src/portal (base) and
// editor/src/portal-saas (saas override), so editor/src/** covers them.
"editor/src/**/*.{js,mjs,jsx,ts,tsx}",
];
const nodeGlobs = [
"scripts/**/*.{js,ts,mjs,mts}",
"editor/scripts/**/*.{js,ts,mjs,mts}",
// Covers editor/vite.config.ts and editor/vitest.config.ts.
"editor/*.config.{js,ts,mjs}",
"*.config.{js,ts,mjs}",
".storybook/*.{js,ts,mjs,mts,tsx}",
];
const baseRestrictedImportPatterns = [
{
regex: "^\\.",
message:
"Use a workspace alias (@app/* for editor, @portal/* for portal) instead of relative imports.",
},
{
regex: "^src/",
message: "Use a workspace alias instead of absolute src/ imports.",
},
];
// Button/SegmentedControl/Chip must come from the shared DS (@app/ui), not Mantine.
// If no variant fits, extend @app/ui — that layer (editor/src/core/ui) is exempt below.
const mantineComponentImportRestrictions = [
{
selector:
"ImportDeclaration[source.value='@mantine/core'] > ImportSpecifier[imported.name=/^(Button|ActionIcon|UnstyledButton|CloseButton|FileButton)$/]",
message:
'Use the shared Button (@app/ui/Button) instead of the Mantine button family. variant=primary|secondary|tertiary, accent=default|neutral|brand|ai|premium|danger|success|warning; an icon-only button is `<Button leftSection={…} aria-label="…" />`. If no variant fits, extend the shared Button rather than importing Mantine.',
},
{
selector:
"ImportDeclaration[source.value='@mantine/core'] > ImportSpecifier[imported.name='SegmentedControl']",
message:
"Use the shared SegmentedControl (@app/ui/SegmentedControl) instead of Mantine's.",
},
{
selector:
"ImportDeclaration[source.value='@mantine/core'] > ImportSpecifier[imported.name=/^(Chip|Pill)$/]",
message:
"Use the shared Chip (@app/ui/Chip) instead of Mantine's Chip/Pill.",
},
];
// Raw <button> should be a shared Button too — but bespoke CSS-styled controls
// (tabs, nav rows, preset chips) can be exempted from this selector alone.
const rawButtonSyntaxRestriction = {
selector: "JSXOpeningElement[name.name='button']",
message:
"Use the shared Button (@app/ui/Button) instead of a raw <button> element. If no variant fits, extend the shared Button.",
};
const sharedComponentSyntaxRestrictions = [
...mantineComponentImportRestrictions,
rawButtonSyntaxRestriction,
];
export default defineConfig(
{
// Everything that contains 3rd party code that we don't want to lint
ignores: [
"dist",
"dist-portal",
"node_modules",
"playwright-report",
"storybook-static",
"test-results",
"editor/dist",
"editor/public",
"editor/src-tauri",
"editor/playwright-report",
"editor/test-results",
],
},
eslint.configs.recommended,
tseslint.configs.recommended,
{
rules: {
"no-restricted-imports": [
"error",
{
patterns: baseRestrictedImportPatterns,
},
],
"@typescript-eslint/no-empty-object-type": [
"error",
{
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
allowInterfaces: "with-single-extends",
},
],
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "all", // All function args must be used (or explicitly ignored)
argsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
caughtErrors: "all", // Caught errors must be used (or explicitly ignored)
caughtErrorsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
destructuredArrayIgnorePattern: "^_", // Allow unused variables beginning with an underscore
varsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
ignoreRestSiblings: true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
},
],
},
},
// Desktop-only packages must not be imported from core or proprietary code.
// Use the stub/shadow pattern instead: define a stub in editor/src/core/ and override in editor/src/desktop/.
{
files: srcGlobs,
ignores: ["editor/src/desktop/**"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
...baseRestrictedImportPatterns,
{
regex: "^@tauri-apps/",
message:
"Tauri APIs are desktop-only. Review frontend/editor/DeveloperGuide.md for structure advice.",
},
],
},
],
},
},
// The cloud/ layer is the SHARED hosted/SaaS experience consumed by BOTH the
// saas and desktop leaves, so it must stay platform-portable. It must not
// reach platform-specific things directly (Supabase, Tauri, raw fetch,
// window.location, web storage, or import.meta.env.VITE_*) — those arrive via
// @app/* seams (services/apiClient, auth/session, platform/openExternal, ...)
// that each leaf provides for its own platform.
{
files: ["editor/src/cloud/**/*.{js,mjs,jsx,ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
...baseRestrictedImportPatterns,
{
regex: "^@supabase/",
message:
"cloud/ must stay platform-portable. Reach Supabase via an @app/* seam (e.g. @app/auth/supabase, @app/auth/session) provided per-platform in saas/ and desktop/.",
},
{
regex: "^@tauri-apps/",
message:
"cloud/ must stay platform-portable. Tauri APIs are desktop-only — reach native features via an @app/* seam (e.g. @app/platform/openExternal).",
},
],
},
],
"no-restricted-globals": [
"error",
{
name: "fetch",
message:
"cloud/ must not call raw fetch — use @app/services/apiClient so each platform supplies its own transport.",
},
{
name: "localStorage",
message:
"cloud/ must not touch localStorage — use an @app/* storage seam so desktop/web can differ.",
},
{
name: "sessionStorage",
message:
"cloud/ must not touch sessionStorage — use an @app/* storage seam so desktop/web can differ.",
},
],
"no-restricted-syntax": [
"error",
...sharedComponentSyntaxRestrictions,
{
selector:
"MemberExpression[object.name='window'][property.name='location']",
message:
"cloud/ must not touch window.location — use an @app/* seam (e.g. @app/platform/openExternal) so desktop/web can differ.",
},
{
selector:
"MemberExpression[property.name='env'][object.type='MetaProperty'][object.meta.name='import'][object.property.name='meta']",
message:
"cloud/ must not read import.meta.env — use @app/constants/app / @app/platform seams so config is supplied per-platform.",
},
],
},
},
// app code must use shared DS Button/SegmentedControl/Chip; cloud/ covered above.
{
files: ["editor/src/**/*.{js,mjs,jsx,ts,tsx}"],
ignores: [
"editor/src/cloud/**/*.{js,mjs,jsx,ts,tsx}", // covered by cloud/ block above
"editor/src/core/ui/**/*.{js,mjs,jsx,ts,tsx}", // the shared DS itself — wraps Mantine/raw elements
"**/*.stories.{js,mjs,jsx,ts,tsx}", // stories may demo Mantine directly
"**/*.test.{js,mjs,jsx,ts,tsx}", // tests may use raw elements as fixtures
"editor/src/prototypes/**/*.{js,mjs,jsx,ts,tsx}", // not shipped
],
rules: {
"no-restricted-syntax": ["error", ...sharedComponentSyntaxRestrictions],
},
},
// Intentional exceptions: ARIA tablist tabs and sub-26px segmented header —
// semantically not buttons; shared Button sizing can't represent them.
// Do NOT add ordinary buttons here.
{
files: [
"editor/src/core/components/shared/FileSelectorPicker.tsx",
"editor/src/core/components/filesPage/FileManagerView.tsx",
"editor/src/core/pages/HomePage.tsx",
],
rules: {
"no-restricted-syntax": "off",
},
},
// TEMPORARY: the procurement feature was merged in from main and still uses
// bespoke CSS-styled raw <button>s. Exempt ONLY the raw-<button> rule here —
// the Mantine import bans stay in force so this feature can't regress to
// Mantine's Button/Chip/SegmentedControl — and migrate these to the shared
// Button in a follow-up PR. Do NOT add other folders to this block.
{
files: [
"editor/src/portal/components/procurement/**/*.{js,mjs,jsx,ts,tsx}",
],
rules: {
"no-restricted-syntax": ["error", ...mantineComponentImportRestrictions],
},
},
// TEMPORARY: the portal user-management / integrations surface predates the
// button consolidation and uses bespoke CSS-styled raw <button>s (kebab
// triggers, inline text-link actions) that the shared Button can't represent
// without heavy overrides. Exempt ONLY the raw-<button> rule — the Mantine
// import bans stay in force — and migrate these in a follow-up PR.
{
files: ["editor/src/portal/components/users/UsersDirectory.tsx"],
rules: {
"no-restricted-syntax": ["error", ...mantineComponentImportRestrictions],
},
},
// TEMPORARY (same rationale as procurement above): the portal home hero +
// install modal reuse the same bespoke CSS-styled raw <button> controls as the
// procurement deal hero — status/invite/icon buttons, full-width checklist and
// install-option rows, and link-style guide actions that the shared Button
// can't represent. Exempt ONLY the raw-<button> rule; the Mantine import bans
// stay. Migrate these alongside the procurement buttons.
{
files: ["editor/src/portal/components/DownloadEditorModal.tsx"],
rules: {
"no-restricted-syntax": ["error", ...mantineComponentImportRestrictions],
},
},
// TEMPORARY (same rationale as procurement above): the connection/operation catalogues render
// bespoke preset tiles (brand mark + two-line text), and the integrations
// page adds the same tiles as full-width expandable rows plus filter chips.
// Raw-<button> rule only; migrate later.
{
files: [
"editor/src/portal/components/sources/ConnectionTypePicker.tsx",
"editor/src/portal/components/sources/SourceModal.tsx",
"editor/src/portal/components/policies/PolicyExternalApiConfig.tsx",
"editor/src/portal/views/Integrations.tsx",
],
rules: {
"no-restricted-syntax": ["error", ...mantineComponentImportRestrictions],
},
},
// Stricter rules that not all sub-folders are conformant to yet.
{
files: srcGlobs,
ignores: [
"editor/src/core/components/annotation/**/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/pageEditor/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/shared/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/shared/config/configSections/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/tools/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/tools/addStamp/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/tools/automate/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/tools/certSign/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/tools/pdfTextEditor/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/viewer/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/contexts/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/contexts/file/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/contexts/viewer/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/hooks/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/hooks/tools/shared/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/services/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/tools/annotate/useAnnotationSelection.ts",
"editor/src/core/types/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/utils/*.{js,mjs,jsx,ts,tsx}",
],
rules: {
"@typescript-eslint/no-explicit-any": "error",
},
},
// Config for browser scripts
{
files: srcGlobs,
languageOptions: {
globals: {
...globals.browser,
},
},
},
// Config for node scripts
{
files: nodeGlobs,
languageOptions: {
globals: {
...globals.node,
},
},
},
);
+423
View File
@@ -0,0 +1,423 @@
import { defineConfig, type OxlintGlobals } from "oxlint";
// Glob for all editor app source, and the two layers that need their own
// import scope. `no-restricted-imports` is repeated per scope on purpose:
// oxlint REPLACES (does not merge) a rule across matching overrides, so each
// scope must restate the full set of bans that apply to it.
const APP_SOURCE = "editor/src/**/*.{js,mjs,jsx,ts,tsx}";
const DESKTOP_SOURCE = "editor/src/desktop/**/*.{js,mjs,jsx,ts,tsx}";
const CLOUD_SOURCE = "editor/src/cloud/**/*.{js,mjs,jsx,ts,tsx}";
// Shared import-ban building blocks -----------------------------------------
const aliasOverRelative = {
regex: "^\\.",
message:
"Use a workspace alias (@app/* for editor, @portal/* for portal) instead of relative imports.",
};
const aliasOverSrc = {
regex: "^src/",
message: "Use a workspace alias instead of absolute src/ imports.",
};
const noTauriOutsideDesktop = {
regex: "^@tauri-apps/",
message:
"Tauri APIs are desktop-only. Review frontend/editor/DeveloperGuide.md for structure advice.",
};
const cloudNoTauri = {
regex: "^@tauri-apps/",
message:
"cloud/ must stay platform-portable. Tauri APIs are desktop-only — reach native features via an @app/* seam (e.g. @app/platform/openExternal).",
};
const cloudNoSupabase = {
regex: "^@supabase/",
message:
"cloud/ must stay platform-portable. Reach Supabase via an @app/* seam (e.g. @app/auth/supabase, @app/auth/session) provided per-platform in saas/ and desktop/.",
};
// Shared-DS Button/SegmentedControl/Chip family must come from @app/ui, not
// Mantine. (The raw-<button> ban from the ESLint config used
// no-restricted-syntax, which oxlint does not implement, so it is dropped.)
const mantineDsPaths = [
{
name: "@mantine/core",
importNames: [
"Button",
"ActionIcon",
"UnstyledButton",
"CloseButton",
"FileButton",
],
message:
'Use the shared Button (@app/ui/Button) instead of the Mantine button family. variant=primary|secondary|tertiary, accent=default|neutral|brand|ai|premium|danger|success|warning; an icon-only button is `<Button leftSection={…} aria-label="…" />`. If no variant fits, extend the shared Button rather than importing Mantine.',
},
{
name: "@mantine/core",
importNames: ["SegmentedControl"],
message:
"Use the shared SegmentedControl (@app/ui/SegmentedControl) instead of Mantine's.",
},
{
name: "@mantine/core",
importNames: ["Chip", "Pill"],
message:
"Use the shared Chip (@app/ui/Chip) instead of Mantine's Chip/Pill.",
},
];
// Modern JS globals not yet in oxlint's builtin env.
const modernGlobals: OxlintGlobals = {
AsyncDisposableStack: "readonly",
DisposableStack: "readonly",
SuppressedError: "readonly",
};
// Folders not yet conformant to the stricter no-explicit-any rule
const noExplicitAnyExcludes = [
"editor/src/core/components/annotation/**/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/pageEditor/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/shared/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/shared/config/configSections/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/tools/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/tools/addStamp/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/tools/automate/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/tools/certSign/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/tools/pdfTextEditor/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/viewer/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/contexts/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/contexts/file/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/contexts/viewer/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/hooks/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/hooks/tools/shared/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/services/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/tools/annotate/useAnnotationSelection.ts",
"editor/src/core/types/*.{js,mjs,jsx,ts,tsx}",
"editor/src/core/utils/*.{js,mjs,jsx,ts,tsx}",
];
export default defineConfig({
plugins: ["typescript", "import"],
categories: {
correctness: "off",
},
env: {
builtin: true,
},
ignorePatterns: [
"dist",
"dist-portal",
"node_modules",
"playwright-report",
"storybook-static",
"test-results",
"editor/dist",
"editor/public",
"editor/src-tauri",
"editor/playwright-report",
"editor/test-results",
],
rules: {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unassigned-vars": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": [
"error",
{
args: "all",
argsIgnorePattern: "^_",
caughtErrors: "all",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"preserve-caught-error": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"no-restricted-imports": [
"error",
{
patterns: [aliasOverRelative, aliasOverSrc],
},
],
"typescript/ban-ts-comment": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": [
"error",
{
allowInterfaces: "with-single-extends",
},
],
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "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",
},
overrides: [
{
// TS files: core JS rules superseded by the TypeScript compiler are turned
// off, and the TS-appropriate replacements are enabled. Mirrors
// typescript-eslint's recommended flat config.
files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
rules: {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
},
},
{
// Browser globals for all editor app source.
files: [APP_SOURCE],
env: {
browser: true,
},
globals: modernGlobals,
},
{
// Node globals for build scripts, config files, and Storybook config.
files: [
"scripts/**/*.{js,ts,mjs,mts}",
"editor/scripts/**/*.{js,ts,mjs,mts}",
"editor/*.config.{js,ts,mjs}",
"*.config.{js,ts,mjs}",
".storybook/*.{js,ts,mjs,mts,tsx}",
],
globals: modernGlobals,
env: {
node: true,
},
},
{
// Editor app source (excluding desktop): ban relative/src imports, ban
// Tauri (desktop-only), and the shared-DS Mantine import ban.
files: [APP_SOURCE],
excludeFiles: [DESKTOP_SOURCE],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [aliasOverRelative, aliasOverSrc, noTauriOutsideDesktop],
paths: mantineDsPaths,
},
],
},
},
{
// Desktop source: same DS import ban, but Tauri is allowed here (this is
// the only layer that may reach @tauri-apps/* directly).
files: [DESKTOP_SOURCE],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [aliasOverRelative, aliasOverSrc],
paths: mantineDsPaths,
},
],
},
},
{
// The cloud/ layer is the SHARED hosted/SaaS experience consumed by BOTH
// the saas and desktop leaves, so it must stay platform-portable: no
// Supabase or Tauri directly, and no raw fetch/localStorage/sessionStorage
// - those arrive via @app/* seams that each leaf provides for its own
// platform. window.location and import.meta.env are banned below via the
// no-restricted-properties.
files: [CLOUD_SOURCE],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
aliasOverRelative,
aliasOverSrc,
cloudNoTauri,
cloudNoSupabase,
],
paths: mantineDsPaths,
},
],
"no-restricted-globals": [
"error",
{
name: "fetch",
message:
"cloud/ must not call raw fetch — use @app/services/apiClient so each platform supplies its own transport.",
},
{
name: "localStorage",
message:
"cloud/ must not touch localStorage — use an @app/* storage seam so desktop/web can differ.",
},
{
name: "sessionStorage",
message:
"cloud/ must not touch sessionStorage — use an @app/* storage seam so desktop/web can differ.",
},
],
"no-restricted-properties": [
"error",
{
object: "window",
property: "location",
message:
"cloud/ must not touch window.location - use an @app/* seam (e.g. @app/platform/openExternal) so desktop/web can differ.",
},
{
// Property-only: import.meta.env's object is a MetaProperty, which
// no-restricted-properties can't target, so this bans every `.env`
// read in cloud/ - which matches the intent (config comes via seams,
// never env). cloud/ has no other `.env` access today.
property: "env",
message:
"cloud/ must not read import.meta.env - config comes from @app/constants/app / @app/platform seams, not env.",
},
],
},
},
{
// Exempt from the shared-DS Mantine import ban (these layers may use
// Mantine directly): the shared DS itself wraps Mantine, stories/tests
// demo it, and prototypes are not shipped. Module-path bans still apply.
// The three named files are ARIA tablist/segmented controls that the
// ESLint config exempted from the (now-dropped) raw-<button> and Mantine
// rules. Comes after the scoped bans above so it wins for these files;
// desktop/cloud keep theirs.
files: [
"editor/src/core/ui/**/*.{js,mjs,jsx,ts,tsx}",
"editor/src/prototypes/**/*.{js,mjs,jsx,ts,tsx}",
"**/*.stories.{js,mjs,jsx,ts,tsx}",
"**/*.test.{js,mjs,jsx,ts,tsx}",
"editor/src/core/components/shared/FileSelectorPicker.tsx",
"editor/src/core/components/filesPage/FileManagerView.tsx",
"editor/src/core/pages/HomePage.tsx",
],
excludeFiles: [DESKTOP_SOURCE, CLOUD_SOURCE],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [aliasOverRelative, aliasOverSrc, noTauriOutsideDesktop],
},
],
},
},
{
// Desktop test/story files: like every other *.test/*.stories file they
// are exempt from the shared-DS Mantine import ban; being desktop they also
// keep the Tauri allowance.
files: [
"editor/src/desktop/**/*.test.{js,mjs,jsx,ts,tsx}",
"editor/src/desktop/**/*.stories.{js,mjs,jsx,ts,tsx}",
],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [aliasOverRelative, aliasOverSrc],
},
],
},
},
{
// Stricter no-explicit-any, enabled everywhere in the editor app EXCEPT
// the folders that are not yet conformant (migrated incrementally).
files: [APP_SOURCE],
excludeFiles: noExplicitAnyExcludes,
rules: {
"typescript/no-explicit-any": "error",
},
},
{
// Circular-import detection across the editor app source (the import
// plugin resolves @app/* and the other tsconfig path aliases). Replaces
// the previous dpdm pass.
files: ["editor/src/**/*.{ts,tsx}"],
rules: {
"import/no-cycle": "error",
},
},
],
});
+373 -1261
View File
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -108,7 +108,6 @@
]
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@iconify-json/material-symbols": "^1.2.83",
"@iconify/react": "^6.0.2",
"@iconify/utils": "^3.1.4",
@@ -132,22 +131,18 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.1.9",
"@types/use-sync-external-store": "^0.0.6",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"@typescript/native": "npm:typescript@^7.0.2",
"@vitejs/plugin-react-swc": "^4.1.0",
"@vitest/browser": "3.2.7",
"@vitest/coverage-v8": "3.2.7",
"dotenv": "^16.4.7",
"dpdm": "^3.14.0",
"eslint": "^10.8.0",
"fake-indexeddb": "^6.2.5",
"globals": "^17.7.0",
"jsdom": "^27.0.0",
"json-schema-to-typescript": "^15.0.4",
"license-checker": "^25.0.1",
"msw": "^2.14.6",
"msw-storybook-addon": "^2.0.7",
"oxlint": "^1.77.0",
"postcss": "^8.5.12",
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.18.0",
@@ -159,7 +154,6 @@
"stylelint": "^17.14.1",
"tsx": "^4.22.4",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "^8.65.0",
"vite": "^7.3.2",
"vite-plugin-compression2": "^2.5.3",
"vite-plugin-static-copy": "^3.1.4",
+1 -1
View File
@@ -3,5 +3,5 @@
// The project definition lives in .storybook/vitest.config.ts; this re-exports
// it from the conventional root location. The editor unit tests are separate
// (editor/vitest.config.ts, run with `vitest --root editor`).
// eslint-disable-next-line no-restricted-imports -- config re-export; no @-alias covers .storybook/
// oxlint-disable-next-line no-restricted-imports -- config re-export; no @-alias covers .storybook/
export { default } from "./.storybook/vitest.config";