diff --git a/.eslint/rules/enforce-tw.test.js b/.eslint/rules/enforce-tw.test.js
deleted file mode 100644
index b16eeb4893..0000000000
--- a/.eslint/rules/enforce-tw.test.js
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright 2025 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-const rule = require('./enforce-tw');
-const RuleTester = require('eslint').RuleTester;
-
-const message = 'Tailwind classes must be wrapped with tw()';
-
-// avoid triggering mocha's global leak detection
-require('@typescript-eslint/parser');
-
-const ruleTester = new RuleTester({
- parser: require.resolve('@typescript-eslint/parser'),
- parserOptions: {
- ecmaVersion: 2018,
- sourceType: 'module',
- ecmaFeatures: {
- jsx: true,
- },
- },
-});
-
-ruleTester.run('enforce-tw', rule, {
- valid: [
- { code: `classNames("foo")` },
- { code: `
`, errors: [{ message }] },
- { code: `classNames("foo", "flex")`, errors: [{ message }] },
- { code: `classNames(cond ? "foo" : "flex")`, errors: [{ message }] },
- { code: `classNames(cond ? "flex" : "foo")`, errors: [{ message }] },
- { code: `classNames(cond && "flex")`, errors: [{ message }] },
- { code: `classNames(cond || "flex")`, errors: [{ message }] },
- { code: `classNames(cond ?? "flex")`, errors: [{ message }] },
- { code: `classNames("foo" + "flex")`, errors: [{ message }] },
- { code: `classNames("flex" + "foo")`, errors: [{ message }] },
- ],
-});
diff --git a/.eslint/rules/file-suffix.test.js b/.eslint/rules/file-suffix.test.js
deleted file mode 100644
index c8c5658e3f..0000000000
--- a/.eslint/rules/file-suffix.test.js
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright 2025 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-const rule = require('./file-suffix.js');
-const RuleTester = require('eslint').RuleTester;
-
-// avoid triggering mocha's global leak detection
-require('@typescript-eslint/parser');
-
-const ruleTester = new RuleTester({
- parser: require.resolve('@typescript-eslint/parser'),
- parserOptions: {
- ecmaVersion: 2018,
- sourceType: 'module',
- },
-});
-
-ruleTester.run('file-suffix', rule, {
- valid: [
- // Allowed references
- ...[
- ['std', '', ['std']],
- ['dom', 'window.addEventListener();', ['std', 'dom']],
- ['node', 'require("node:fs");', ['std', 'node']],
- [
- 'preload',
- 'import { ipcRenderer } from "electron";',
- ['std', 'node', 'preload'],
- ],
- [
- 'main',
- 'import { autoUpdater } from "electron";',
- ['std', 'node', 'main'],
- ],
- ]
- .map(([fileSuffix, requiredLine, depSuffixes]) => {
- return depSuffixes.map(depSuffix => {
- return {
- name: `importing ${depSuffix} from ${fileSuffix}`,
- filename: `a.${fileSuffix}.ts`,
- code: `
- import { x } from './b.${depSuffix}.js';
- ${requiredLine}
- `,
- globals: {
- window: 'writable',
- require: 'readable',
- },
- };
- });
- })
- .flat(),
-
- {
- name: 'type import should have no effect',
- filename: 'a.std.ts',
- code: `import type { ReadonlyDeep } from './b.dom.js'`,
- },
- ],
- invalid: [
- // Disallowed references
- ...[
- ['std', ['dom', 'node', 'preload', 'main']],
- ['dom', ['node', 'preload', 'main']],
- ['node', ['preload', 'main']],
- ['preload', ['main']],
- ['main', ['dom', 'preload']],
- ]
- .map(([fileSuffix, depSuffixes]) => {
- return depSuffixes.map(depSuffix => {
- return {
- name: `importing ${depSuffix} from ${fileSuffix}`,
- filename: `a.${fileSuffix}.ts`,
- code: `import { x } from './b.${depSuffix}.js'`,
- errors: [
- {
- message: `Invalid suffix ${fileSuffix}, expected: ${depSuffix}`,
- type: 'Program',
- },
- ],
- };
- });
- })
- .flat(),
-
- ...['dom', 'node', 'preload', 'main'].map(suffix => {
- return {
- name: `no ${suffix} imports`,
- filename: `a.${suffix}.ts`,
- code: '',
- errors: [
- {
- message: `Invalid suffix ${suffix}, expected: std`,
- type: 'Program',
- },
- ],
- };
- }),
-
- // Invalid imports
- {
- name: 'preload in main',
- filename: 'a.main.ts',
- code: `
- import { autoUpdater } from 'electron';
- import './b.preload.js';
- `,
- errors: [
- {
- message: 'Invalid import/reference for suffix: main',
- type: 'ImportDeclaration',
- },
- ],
- },
- {
- name: 'main in preload',
- filename: 'a.preload.ts',
- code: `
- import { ipcRenderer } from 'electron';
- import './b.main.js';
- `,
- errors: [
- {
- message: 'Invalid suffix preload, expected: main',
- type: 'Program',
- },
- {
- message: 'Invalid import/reference for suffix: main',
- type: 'ImportSpecifier',
- },
- ],
- },
- ],
-});
diff --git a/.eslint/rules/type-alias-readonlydeep.js b/.eslint/rules/type-alias-readonlydeep.js
deleted file mode 100644
index 1e316eeee4..0000000000
--- a/.eslint/rules/type-alias-readonlydeep.js
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright 2023 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-function isReadOnlyDeep(node, scope) {
- if (node.type !== 'TSTypeReference') {
- return false;
- }
-
- let reference = scope.references.find(reference => {
- return reference.identifier === node.typeName;
- });
-
- let variable = reference.resolved;
- if (variable == null) {
- return false;
- }
-
- let defs = variable.defs;
- if (defs.length !== 1) {
- return false;
- }
-
- let [def] = defs;
-
- return (
- def.type === 'ImportBinding' &&
- def.parent.type === 'ImportDeclaration' &&
- def.parent.source.type === 'Literal' &&
- def.parent.source.value === 'type-fest'
- );
-}
-
-/** @type {import("eslint").Rule.RuleModule} */
-module.exports = {
- meta: {
- type: 'problem',
- hasSuggestions: false,
- fixable: false,
- schema: [],
- },
- create(context) {
- return {
- TSTypeAliasDeclaration(node) {
- let scope = context.getScope(node);
-
- if (isReadOnlyDeep(node.typeAnnotation, scope)) {
- return;
- }
-
- context.report({
- node: node.id,
- message:
- 'Type aliases must be wrapped with ReadonlyDeep from type-fest',
- });
- },
- };
- },
-};
diff --git a/.eslint/rules/type-alias-readonlydeep.test.js b/.eslint/rules/type-alias-readonlydeep.test.js
deleted file mode 100644
index 7f81d1b20a..0000000000
--- a/.eslint/rules/type-alias-readonlydeep.test.js
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright 2023 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-const rule = require('./type-alias-readonlydeep');
-const RuleTester = require('eslint').RuleTester;
-
-// avoid triggering mocha's global leak detection
-require('@typescript-eslint/parser');
-
-const ruleTester = new RuleTester({
- parser: require.resolve('@typescript-eslint/parser'),
- parserOptions: {
- ecmaVersion: 2018,
- sourceType: 'module',
- },
-});
-
-ruleTester.run('type-alias-readonlydeep', rule, {
- valid: [
- {
- code: `import type { ReadonlyDeep } from "type-fest"; type Foo = ReadonlyDeep<{}>`,
- },
- {
- code: `import { ReadonlyDeep } from "type-fest"; type Foo = ReadonlyDeep<{}>`,
- },
- ],
- invalid: [
- {
- code: `type Foo = {}`,
- errors: [
- {
- message:
- 'Type aliases must be wrapped with ReadonlyDeep from type-fest',
- type: 'Identifier',
- },
- ],
- },
- {
- code: `type Foo = Bar<{}>`,
- errors: [
- {
- message:
- 'Type aliases must be wrapped with ReadonlyDeep from type-fest',
- type: 'Identifier',
- },
- ],
- },
- {
- code: `type Foo = ReadonlyDeep<{}>`,
- errors: [
- {
- message:
- 'Type aliases must be wrapped with ReadonlyDeep from type-fest',
- type: 'Identifier',
- },
- ],
- },
- {
- code: `interface ReadonlyDeep
{}; type Foo = ReadonlyDeep<{}>`,
- errors: [
- {
- message:
- 'Type aliases must be wrapped with ReadonlyDeep from type-fest',
- type: 'Identifier',
- },
- ],
- },
- {
- code: `import type { ReadonlyDeep } from "foo"; type Foo = ReadonlyDeep<{}>`,
- errors: [
- {
- message:
- 'Type aliases must be wrapped with ReadonlyDeep from type-fest',
- type: 'Identifier',
- },
- ],
- },
- ],
-});
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index 9e82d28f31..0000000000
--- a/.eslintignore
+++ /dev/null
@@ -1,37 +0,0 @@
-components/**
-coverage/**
-dist/**
-release/**
-
-# Github workflows
-.github/**
-
-# Generated files
-js/curve/*
-js/components.js
-js/util_worker.js
-libtextsecure/components.js
-libtextsecure/test/test.js
-test/test.js
-ts/protobuf/compiled.std.d.ts
-storybook-static/**
-build/ICUMessageParams.d.ts
-
-# Third-party files
-js/Mp3LameEncoder.min.js
-js/WebAudioRecorderMp3.js
-js/calling-tools/**
-
-# TypeScript generated files
-build/**/*.js
-app/**/*.js
-ts/**/*.js
-
-.eslintrc.js
-webpack.config.ts
-preload.bundle.*
-preload.wrapper.*
-bundles/**
-
-# Sticker Creator has its own eslint config
-sticker-creator/**
diff --git a/.eslintrc.js b/.eslintrc.js
deleted file mode 100644
index 72746679a5..0000000000
--- a/.eslintrc.js
+++ /dev/null
@@ -1,485 +0,0 @@
-// Copyright 2018 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-// For reference: https://github.com/airbnb/javascript
-
-const rules = {
- 'comma-dangle': [
- 'error',
- {
- arrays: 'always-multiline',
- objects: 'always-multiline',
- imports: 'always-multiline',
- exports: 'always-multiline',
- functions: 'never',
- },
- ],
-
- // No omitting braces, keep on the same line
- 'brace-style': ['error', '1tbs', { allowSingleLine: false }],
- curly: ['error', 'all'],
-
- // Immer support
- 'no-param-reassign': [
- 'error',
- {
- props: true,
- ignorePropertyModificationsForRegex: ['^draft'],
- ignorePropertyModificationsFor: ['acc', 'ctx', 'context'],
- },
- ],
-
- // Always use === and !== except when directly comparing to null
- // (which only will equal null or undefined)
- eqeqeq: ['error', 'always', { null: 'never' }],
-
- // prevents us from accidentally checking in exclusive tests (`.only`):
- 'mocha/no-exclusive-tests': 'error',
-
- // encourage consistent use of `async` / `await` instead of `then`
- 'more/no-then': 'error',
-
- // it helps readability to put public API at top,
- 'no-use-before-define': 'off',
- '@typescript-eslint/no-use-before-define': 'off',
-
- // useful for unused or internal fields
- 'no-underscore-dangle': 'off',
-
- // Temp: We have because TypeScript's `allowUnreachableCode` option is on.
- 'no-unreachable': 'error',
-
- // though we have a logger, we still remap console to log to disk
- 'no-console': 'error',
-
- // consistently place operators at end of line except ternaries
- 'operator-linebreak': [
- 'error',
- 'after',
- { overrides: { '?': 'ignore', ':': 'ignore' } },
- ],
-
- quotes: [
- 'error',
- 'single',
- { avoidEscape: true, allowTemplateLiterals: false },
- ],
-
- 'no-continue': 'off',
- 'lines-between-class-members': 'off',
- 'class-methods-use-this': 'off',
-
- // Prettier overrides:
- 'arrow-parens': 'off',
- 'function-paren-newline': 'off',
- 'max-len': [
- 'error',
- {
- // Prettier generally limits line length to 80 but sometimes goes over.
- // The `max-len` plugin doesn’t let us omit `code` so we set it to a
- // high value as a buffer to let Prettier control the line length:
- code: 999,
- // We still want to limit comments as before:
- comments: 90,
- ignoreUrls: true,
- },
- ],
-
- 'react/jsx-props-no-spreading': 'off',
-
- // Updated to reflect future airbnb standard
- // Allows for declaring defaultProps inside a class
- 'react/static-property-placement': ['error', 'static public field'],
-
- // JIRA: DESKTOP-657
- 'react/sort-comp': 'off',
-
- // We don't have control over the media we're sharing, so can't require
- // captions.
- 'jsx-a11y/media-has-caption': 'off',
-
- // We prefer named exports
- 'import/prefer-default-export': 'off',
- 'import/enforce-node-protocol-usage': ['error', 'always'],
- 'import/extensions': [
- 'error',
- 'ignorePackages',
- {
- checkTypeImports: true,
- },
- ],
-
- // Prefer functional components with default params
- 'react/require-default-props': 'off',
-
- // Empty fragments are used in adapters between models and react views.
- 'react/jsx-no-useless-fragment': [
- 'error',
- {
- allowExpressions: true,
- },
- ],
-
- // Our code base has tons of arrow functions passed directly to components.
- 'react/jsx-no-bind': 'off',
-
- // Does not support forwardRef
- 'react/no-unused-prop-types': 'off',
-
- // Not useful for us as we have lots of complicated types.
- 'react/destructuring-assignment': 'off',
-
- 'react/function-component-definition': [
- 'error',
- {
- namedComponents: 'function-declaration',
- unnamedComponents: 'arrow-function',
- },
- ],
-
- 'react/display-name': 'error',
-
- 'react/jsx-pascal-case': ['error', { allowNamespace: true }],
-
- // Allow returning values from promise executors for brevity.
- 'no-promise-executor-return': 'off',
-
- // Redux ducks use this a lot
- 'default-param-last': 'off',
-
- 'jsx-a11y/label-has-associated-control': ['error', { assert: 'either' }],
-
- 'jsx-a11y/no-static-element-interactions': 'error',
-
- '@typescript-eslint/no-non-null-assertion': ['error'],
- '@typescript-eslint/no-empty-interface': ['error'],
- 'no-empty-function': 'off',
- '@typescript-eslint/no-empty-function': 'error',
-
- 'no-restricted-syntax': [
- 'error',
- {
- selector: 'TSInterfaceDeclaration',
- message:
- 'Prefer `type`. Interfaces are mutable and less powerful, so we prefer `type` for simplicity.',
- },
- // Defaults
- {
- selector: 'ForInStatement',
- message:
- 'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.',
- },
- {
- selector: 'LabeledStatement',
- message:
- 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.',
- },
- {
- selector: 'WithStatement',
- message:
- '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.',
- },
- ],
-
- 'react-hooks/exhaustive-deps': [
- 'error',
- {
- additionalHooks: '^(useSpring|useSprings)$',
- },
- ],
- 'local-rules/license-comments': 'error',
-};
-
-const typescriptRules = {
- ...rules,
-
- 'local-rules/file-suffix': 'error',
-
- // Override brace style to enable typescript-specific syntax
- 'brace-style': 'off',
- '@typescript-eslint/brace-style': [
- 'error',
- '1tbs',
- { allowSingleLine: false },
- ],
-
- '@typescript-eslint/array-type': ['error', { default: 'generic' }],
-
- 'no-restricted-imports': 'off',
- '@typescript-eslint/no-restricted-imports': [
- 'error',
- {
- paths: [
- {
- name: 'chai',
- importNames: ['expect', 'should', 'Should'],
- message: 'Please use assert',
- allowTypeImports: true,
- },
- ],
- },
- ],
-
- // Overrides recommended by typescript-eslint
- // https://github.com/typescript-eslint/typescript-eslint/releases/tag/v4.0.0
- '@typescript-eslint/no-redeclare': 'error',
- '@typescript-eslint/no-shadow': 'error',
- '@typescript-eslint/no-useless-constructor': ['error'],
- '@typescript-eslint/no-misused-promises': [
- 'error',
- {
- checksVoidReturn: false,
- },
- ],
-
- '@typescript-eslint/no-floating-promises': 'error',
- // We allow "void promise", but new call-sites should use `drop(promise)`.
- 'no-void': ['error', { allowAsStatement: true }],
-
- 'no-shadow': 'off',
- 'no-useless-constructor': 'off',
-
- // useful for unused parameters
- '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
-
- // Upgrade from a warning
- '@typescript-eslint/explicit-module-boundary-types': 'error',
-
- '@typescript-eslint/consistent-type-imports': 'error',
-
- // Future: Maybe switch to never and always use `satisfies`
- '@typescript-eslint/consistent-type-assertions': [
- 'error',
- {
- assertionStyle: 'as',
- // Future: Maybe switch to allow-as-parameter or never
- objectLiteralTypeAssertions: 'allow',
- },
- ],
-
- // Already enforced by TypeScript
- 'consistent-return': 'off',
-
- // TODO: DESKTOP-4655
- 'import/no-cycle': 'off',
- 'import/no-restricted-paths': [
- 'error',
- {
- zones: [
- {
- target: ['ts/util', 'ts/types'],
- from: ['ts/components/**', 'ts/axo/**/*.dom.*'],
- message: 'Importing components is forbidden from ts/{util,types}',
- },
- ],
- },
- ],
-
- 'local-rules/enforce-array-buffer': 'error',
-};
-
-const TAILWIND_REPLACEMENTS = [
- // inset
- { pattern: 'left-*', fix: 'start-*' },
- { pattern: 'right-*', fix: 'end-*' },
- // margin
- { pattern: 'ml-*', fix: 'ms-*' },
- { pattern: 'mr-*', fix: 'me-*' },
- // padding
- { pattern: 'pl-*', fix: 'ps-*' },
- { pattern: 'pr-*', fix: 'pe-*' },
- // border
- { pattern: 'border-l-*', fix: 'border-s-*' },
- { pattern: 'border-r-*', fix: 'border-e-*' },
- // border-radius
- { pattern: 'rounded-l', fix: 'rounded-s' },
- { pattern: 'rounded-r', fix: 'rounded-e' },
- { pattern: 'rounded-tl', fix: 'rounded-ss' },
- { pattern: 'rounded-tr', fix: 'rounded-se' },
- { pattern: 'rounded-bl', fix: 'rounded-es' },
- { pattern: 'rounded-br', fix: 'rounded-ee' },
- { pattern: 'rounded-l-*', fix: 'rounded-s-*' },
- { pattern: 'rounded-r-*', fix: 'rounded-e-*' },
- { pattern: 'rounded-tl-*', fix: 'rounded-ss-*' },
- { pattern: 'rounded-tr-*', fix: 'rounded-se-*' },
- { pattern: 'rounded-bl-*', fix: 'rounded-es-*' },
- { pattern: 'rounded-br-*', fix: 'rounded-ee-*' },
- // text-align
- { pattern: 'text-left', fix: 'text-start' },
- { pattern: 'text-right', fix: 'text-end' },
- // float
- { pattern: 'float-left', fix: 'float-start' },
- { pattern: 'float-right', fix: 'float-end' },
- // clear
- { pattern: 'clear-left', fix: 'clear-start' },
- { pattern: 'clear-right', fix: 'clear-end' },
-];
-
-module.exports = {
- root: true,
- settings: {
- react: {
- version: 'detect',
- },
- 'import/core-modules': ['electron'],
- },
-
- extends: ['airbnb-base', 'prettier'],
-
- plugins: ['mocha', 'more', 'local-rules'],
-
- overrides: [
- {
- files: [
- 'ts/**/*.ts',
- 'ts/**/*.tsx',
- 'app/**/*.ts',
- 'app/**/*.tsx',
- 'build/intl-linter/**/*.ts',
- ],
- parser: '@typescript-eslint/parser',
- parserOptions: {
- project: 'tsconfig.json',
- ecmaFeatures: {
- jsx: true,
- },
- ecmaVersion: 2018,
- sourceType: 'module',
- },
- plugins: ['@typescript-eslint'],
- extends: [
- 'eslint:recommended',
- 'plugin:@typescript-eslint/recommended',
- 'plugin:react/recommended',
- 'airbnb-typescript-prettier',
- ],
- rules: typescriptRules,
- },
- {
- files: [
- '**/*.stories.tsx',
- 'ts/build/**',
- 'ts/test-*/**',
- 'build/intl-linter/**/*.ts',
- ],
- rules: {
- ...typescriptRules,
- 'import/no-extraneous-dependencies': 'off',
- 'react/no-array-index-key': 'off',
- },
- },
- {
- files: ['ts/state/ducks/**/*.ts'],
- rules: {
- 'local-rules/type-alias-readonlydeep': 'error',
- },
- },
- {
- files: ['ts/**/*_test.*.{ts,tsx}'],
- rules: {
- 'func-names': 'off',
- },
- },
- {
- files: ['ts/**/*.tsx'],
- plugins: ['better-tailwindcss'],
- settings: {
- 'better-tailwindcss': {
- entryPoint: './stylesheets/tailwind-config.css',
- callees: ['tw'],
- attributes: [],
- variables: [],
- },
- },
- rules: {
- 'local-rules/enforce-tw': 'error',
-
- // stylistic: Enforce consistent line wrapping for tailwind classes. (recommended, autofix)
- 'better-tailwindcss/enforce-consistent-line-wrapping': 'off',
- // stylistic: Enforce a consistent order for tailwind classes. (recommended, autofix)
- 'better-tailwindcss/enforce-consistent-class-order': 'error',
- // stylistic: Enforce consistent variable syntax. (autofix)
- 'better-tailwindcss/enforce-consistent-variable-syntax': 'error',
- // stylistic: Enforce consistent position of the important modifier. (autofix)
- 'better-tailwindcss/enforce-consistent-important-position': 'error',
- // stylistic: Enforce shorthand class names. (autofix)
- 'better-tailwindcss/enforce-shorthand-classes': 'error',
- // stylistic: Remove duplicate classes. (autofix)
- 'better-tailwindcss/no-duplicate-classes': 'error',
- // stylistic: Remove deprecated classes. (autofix)
- 'better-tailwindcss/no-deprecated-classes': 'off',
- // stylistic: Disallow unnecessary whitespace in tailwind classes. (autofix)
- 'better-tailwindcss/no-unnecessary-whitespace': 'error',
- // correctness: Report classes not registered with tailwindcss. (recommended)
- 'better-tailwindcss/no-unregistered-classes': 'error',
- // correctness: Report classes that produce conflicting styles.
- 'better-tailwindcss/no-conflicting-classes': 'error',
- // correctness: Disallow restricted classes. (autofix)
- 'better-tailwindcss/no-restricted-classes': [
- 'error',
- {
- restrict: [
- {
- pattern: '\\[#[a-fA-F0-9]{3,8}?\\]', // ex: "text-[#fff]"
- message: 'No arbitrary hex values',
- },
- {
- pattern: '\\[rgba?\\(.*\\)\\]', // ex: "text-[rgb(255,255,255)]"
- message: 'No arbitrary rgb values',
- },
- {
- pattern: '\\[hsla?\\(.*\\)\\]', // ex: "text-[hsl(255,255,255)]"
- message: 'No arbitrary hsl values',
- },
- {
- pattern: '^.*!$', // ex: "p-4!"
- message: 'No !important modifiers',
- },
- {
- pattern: '^\\*+:.*', // ex: "*:mx-0",
- message: 'No child variants',
- },
- ...TAILWIND_REPLACEMENTS.map(item => {
- const pattern = item.pattern.replace('*', '(.*)');
- const fix = item.fix.replace('*', '$2');
- return {
- message: `Use logical property ${item.fix} instead of ${item.pattern}`,
- pattern: `^(.*:)?${pattern}$`,
- fix: `$1${fix}`,
- };
- }),
- ],
- },
- ],
- },
- },
- {
- files: ['ts/axo/**/*.{ts,tsx}'],
- rules: {
- // Rule doesn't understand TypeScript namespaces
- 'no-inner-declarations': 'off',
- '@typescript-eslint/no-namespace': 'off',
- '@typescript-eslint/no-redeclare': [
- 'error',
- {
- ignoreDeclarationMerge: true,
- },
- ],
- '@typescript-eslint/explicit-module-boundary-types': [
- 'error',
- {
- allowHigherOrderFunctions: false,
- },
- ],
- },
- },
- ],
-
- rules: {
- ...rules,
- 'import/no-unresolved': 'off',
- 'import/extensions': 'off',
- },
-
- reportUnusedDisableDirectives: true,
-};
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a32f787091..0f7e916dcf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,14 +41,12 @@ jobs:
# path: ${{ env.SCCACHE_PATH }}
# key: sccache-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'patches/**') }}
- - name: Restore cached .eslintcache and tsconfig.tsbuildinfo
+ - name: Restore cached tsconfig.tsbuildinfo
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
id: cache-lint
with:
- path: |
- .eslintcache
- tsconfig.tsbuildinfo
- key: lint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'patches/**', '.eslintrc.js', '.eslint/**', 'tsconfig.json') }}
+ path: tsconfig.tsbuildinfo
+ key: lint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'patches/**', 'tsconfig.json') }}
- name: Install Desktop node_modules
run: pnpm install
@@ -71,13 +69,11 @@ jobs:
- run: git diff --exit-code
- - name: Update cached .eslintcache and tsconfig.tsbuildinfo
+ - name: Update cached tsconfig.tsbuildinfo
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
if: github.ref == 'refs/heads/main'
with:
- path: |
- .eslintcache
- tsconfig.tsbuildinfo
+ path: tsconfig.tsbuildinfo
key: ${{ steps.cache-lint.outputs.cache-primary-key }}
macos:
diff --git a/.gitignore b/.gitignore
index 8c91a6f0cc..2ec2cae457 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,7 +17,6 @@ release/
/sql/
/start.sh
-.eslintcache
.stylelintcache
tsconfig.tsbuildinfo
.smartling-source.sh
diff --git a/.npmrc b/.npmrc
index e7f6774187..636b41348c 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,9 +1,16 @@
legacy-peer-deps=true
public-hoist-pattern[]=*eslint-*
-minimum-release-age=14400
+minimum-release-age=14400 # 10 days
minimum-release-age-exclude[]=@signalapp/*
minimum-release-age-exclude[]=@indutny/*
minimum-release-age-exclude[]=@types/*
minimum-release-age-exclude[]=electron
minimum-release-age-exclude[]=react
minimum-release-age-exclude[]=react-dom
+minimum-release-age-exclude[]=oxlint
+minimum-release-age-exclude[]=oxlint-tsgolint
+minimum-release-age-exclude[]=@oxlint/*
+minimum-release-age-exclude[]=@oxlint-tsgolint/*
+minimum-release-age-exclude[]=eslint
+minimum-release-age-exclude[]=@eslint/*
+minimum-release-age-exclude[]=@typescript-eslint/*
diff --git a/.oxlint/plugin.mjs b/.oxlint/plugin.mjs
new file mode 100644
index 0000000000..458db87457
--- /dev/null
+++ b/.oxlint/plugin.mjs
@@ -0,0 +1,37 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { enforceArrayBuffer } from './rules/enforceArrayBuffer.mjs';
+import { enforceFileSuffix } from './rules/enforceFileSuffix.mjs';
+import { enforceLicenseComments } from './rules/enforceLicenseComments.mjs';
+import { enforceTw } from './rules/enforceTw.mjs';
+import { enforceTypeAliasReadonlyDeep } from './rules/enforceTypeAliasReadonlyDeep.mjs';
+import { noDisabledTests } from './rules/noDisabledTests.mjs';
+import { noExtraneousDependencies } from './rules/noExtraneousDependencies.mjs';
+import { noFocusedTests } from './rules/noFocusedTests.mjs';
+import { noForIn } from './rules/noForIn.mjs';
+import { noRestrictedPaths } from './rules/noRestrictedPaths.mjs';
+import { noThen } from './rules/noThen.mjs';
+
+/** @type {import("@typescript-eslint/utils").TSESLint.Linter.Plugin} */
+const plugin = {
+ meta: {
+ name: 'signal-desktop',
+ version: '0.0.0',
+ },
+ rules: {
+ 'enforce-array-buffer': enforceArrayBuffer,
+ 'enforce-file-suffix': enforceFileSuffix,
+ 'enforce-license-comments': enforceLicenseComments,
+ 'enforce-tw': enforceTw,
+ 'enforce-type-alias-readonlydeep': enforceTypeAliasReadonlyDeep,
+ 'no-disabled-tests': noDisabledTests,
+ 'no-extraneous-dependencies': noExtraneousDependencies,
+ 'no-focused-tests': noFocusedTests,
+ 'no-for-in': noForIn,
+ 'no-restricted-paths': noRestrictedPaths,
+ 'no-then': noThen,
+ },
+};
+
+export default plugin;
diff --git a/.eslint/rules/enforce-array-buffer.js b/.oxlint/rules/enforceArrayBuffer.mjs
similarity index 62%
rename from .eslint/rules/enforce-array-buffer.js
rename to .oxlint/rules/enforceArrayBuffer.mjs
index d3a49db5f2..649e9cc44c 100644
--- a/.eslint/rules/enforce-array-buffer.js
+++ b/.oxlint/rules/enforceArrayBuffer.mjs
@@ -1,12 +1,18 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { ESLintUtils } from '@typescript-eslint/utils';
-/** @type {import("eslint").Rule.RuleModule} */
-module.exports = {
+export const enforceArrayBuffer = ESLintUtils.RuleCreator.withoutDocs({
+ name: 'enforce-array-buffer',
meta: {
type: 'problem',
- hasSuggestions: true,
- fixable: true,
+ fixable: 'code',
+ messages: {
+ shouldUseArrayBuffer: `Should be {{replacement}}`,
+ },
+ schema: [],
+ defaultOptions: [],
},
create(context) {
return {
@@ -24,13 +30,14 @@ module.exports = {
return;
}
- if (node.typeParameters != null) {
+ if (node.typeArguments != null) {
return;
}
context.report({
node,
- message: `Should be ${replacement}`,
+ messageId: 'shouldUseArrayBuffer',
+ data: { replacement },
fix(fixer) {
return [fixer.replaceTextRange(node.range, replacement)];
},
@@ -38,4 +45,4 @@ module.exports = {
},
};
},
-};
+});
diff --git a/.eslint/rules/enforce-array-buffer.test.js b/.oxlint/rules/enforceArrayBuffer.test.mjs
similarity index 66%
rename from .eslint/rules/enforce-array-buffer.test.js
rename to .oxlint/rules/enforceArrayBuffer.test.mjs
index adf8c7b0e1..58b675ef5c 100644
--- a/.eslint/rules/enforce-array-buffer.test.js
+++ b/.oxlint/rules/enforceArrayBuffer.test.mjs
@@ -1,31 +1,12 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { enforceArrayBuffer } from './enforceArrayBuffer.mjs';
+import { RuleTester } from '@typescript-eslint/rule-tester';
-const rule = require('./enforce-array-buffer');
-const RuleTester = require('eslint').RuleTester;
+const ruleTester = new RuleTester();
-// avoid triggering mocha's global leak detection
-require('@typescript-eslint/parser');
-
-const ruleTester = new RuleTester({
- parser: require.resolve('@typescript-eslint/parser'),
- parserOptions: {
- ecmaVersion: 2018,
- sourceType: 'module',
- },
-});
-
-const EXPECTED_ARRAY_ERROR = {
- message: 'Should be Uint8Array',
- type: 'TSTypeReference',
-};
-
-const EXPECTED_BUFFER_ERROR = {
- message: 'Should be Buffer',
- type: 'TSTypeReference',
-};
-
-ruleTester.run('enforce-array-buffer', rule, {
+ruleTester.run('enforce-array-buffer', enforceArrayBuffer, {
valid: [
{ code: 'type T = number;' },
{ code: 'type T = Uint16Array;' },
@@ -52,32 +33,32 @@ ruleTester.run('enforce-array-buffer', rule, {
{
code: `type T = Uint8Array`,
output: `type T = Uint8Array`,
- errors: [EXPECTED_ARRAY_ERROR],
+ errors: [{ messageId: 'shouldUseArrayBuffer' }],
},
{
code: `function f(): Uint8Array {}`,
output: `function f(): Uint8Array {}`,
- errors: [EXPECTED_ARRAY_ERROR],
+ errors: [{ messageId: 'shouldUseArrayBuffer' }],
},
{
code: `function f(p: Uint8Array) {}`,
output: `function f(p: Uint8Array) {}`,
- errors: [EXPECTED_ARRAY_ERROR],
+ errors: [{ messageId: 'shouldUseArrayBuffer' }],
},
{
code: `let v: Uint8Array;`,
output: `let v: Uint8Array;`,
- errors: [EXPECTED_ARRAY_ERROR],
+ errors: [{ messageId: 'shouldUseArrayBuffer' }],
},
{
code: `let v: { p: Uint8Array };`,
output: `let v: { p: Uint8Array };`,
- errors: [EXPECTED_ARRAY_ERROR],
+ errors: [{ messageId: 'shouldUseArrayBuffer' }],
},
{
code: `type T = Buffer`,
output: `type T = Buffer`,
- errors: [EXPECTED_BUFFER_ERROR],
+ errors: [{ messageId: 'shouldUseArrayBuffer' }],
},
],
});
diff --git a/.eslint/rules/file-suffix.js b/.oxlint/rules/enforceFileSuffix.mjs
similarity index 66%
rename from .eslint/rules/file-suffix.js
rename to .oxlint/rules/enforceFileSuffix.mjs
index 4c18df824d..aae3ec46eb 100644
--- a/.eslint/rules/file-suffix.js
+++ b/.oxlint/rules/enforceFileSuffix.mjs
@@ -1,5 +1,23 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { ESLintUtils } from '@typescript-eslint/utils';
+import { getReferenceType } from './utils/getReferenceType.mjs';
+import { isStringLiteral } from './utils/astUtils.mjs';
+import { assert } from './utils/assert.mjs';
+
+/**
+ * @typedef {import("@typescript-eslint/utils").TSESTree.Node} Node
+ * @typedef {import("@typescript-eslint/utils").TSESTree.ImportDeclaration} ImportDeclaration
+ * @typedef {import("@typescript-eslint/utils").TSESTree.ExportAllDeclaration} ExportAllDeclaration
+ * @typedef {import("@typescript-eslint/utils").TSESTree.ExportNamedDeclaration} ExportNamedDeclaration
+ * @typedef {import("@typescript-eslint/utils").TSESTree.ImportClause} ImportClause
+ * @typedef {import("@typescript-eslint/utils").TSESTree.ExportSpecifier} ExportSpecifier
+ */
+
+/**
+ * @typedef {'std' | 'node' | 'dom' | 'preload' | 'main'} Suffix
+ */
const ELECTRON_MAIN_MODULES = new Set([
'app',
@@ -225,14 +243,7 @@ const STD_PACKAGES = new Set([
'emoji-datasource-apple',
'emoji-regex',
'eslint',
- 'eslint-config-airbnb-typescript-prettier',
- 'eslint-config-prettier',
'eslint-plugin-better-tailwindcss',
- 'eslint-plugin-import',
- 'eslint-plugin-local-rules',
- 'eslint-plugin-mocha',
- 'eslint-plugin-more',
- 'eslint-plugin-react',
'filesize',
'firstline',
'form-data',
@@ -279,24 +290,56 @@ const STD_PACKAGES = new Set([
'zod',
]);
-/** @type {import("eslint").Rule.RuleModule} */
-module.exports = {
+export const enforceFileSuffix = ESLintUtils.RuleCreator.withoutDocs({
+ name: 'enforce-file-suffix',
meta: {
type: 'problem',
- hasSuggestions: false,
- fixable: false,
+ messages: {
+ missingFileSuffix: 'Missing file suffix in {{source}} import',
+ unrecognizedFileSuffix:
+ 'Unrecognized file suffix in {{source}}, expected: node/preload/main/std, found: {{depSuffix}}',
+ commonJsImportOfElectronNoAllowed:
+ 'CJS import of electron is not allowed',
+ uncategorizedElectronApi:
+ 'Uncategorized electron API: "{{name}}". ' +
+ 'Please update .oxlint/rules/file-suffix.js and add it to ' +
+ 'ELECTRON_MAIN_MODULES/ELECTRON_RENDERER_MODULES/' +
+ 'ELECTRON_SHARED_MODULES',
+ unsupportedNamespaceImportForElectron:
+ 'Unsupported namespace import specifier for electron',
+ unsupportedImportSpecifierForElectron:
+ 'Unsupported import specifier for electron',
+ uncategorizedDependency:
+ 'Uncategorized dependency "{{moduleName}}". ' +
+ 'Please update .oxlint/rules/file-suffix.js and add it to either ' +
+ 'of NODE_PACKAGES/DOM_PACKAGES/STD_PACKAGES',
+ missingFileSuffixMustBeOneOf:
+ 'Missing file suffix. Has to be one of: node/preload/main/std',
+ wrongFileSuffix:
+ 'Invalid suffix {{fileSuffix}}, expected: {{expectedSuffix}}',
+ invalidImportForSuffix:
+ 'Invalid import/reference for suffix: {{expectedSuffix}}',
+ invalidRequireCount: 'Invalid require() argument count',
+ },
schema: [],
+ defaultOptions: [],
},
create(context) {
const { filename, sourceCode } = context;
+ /** @type {string} */
let fileSuffix;
+ /** @type {Node[]} */
const nodeUses = [];
+ /** @type {Node[]} */
const domUses = [];
+ /** @type {Node[]} */
const preloadUses = [];
+ /** @type {Node[]} */
const mainUses = [];
+ /** @type Record */
const invalidUsesBySuffix = {
std: [nodeUses, domUses, preloadUses, mainUses],
node: [domUses, preloadUses, mainUses],
@@ -305,6 +348,10 @@ module.exports = {
main: [domUses, preloadUses],
};
+ /**
+ * @param {Node} node
+ * @param {string} source
+ */
function trackLocalDep(node, source) {
if (!source.endsWith('.js')) {
return;
@@ -314,7 +361,8 @@ module.exports = {
if (match == null) {
context.report({
node,
- message: `Missing file suffix in ${source} import`,
+ messageId: 'missingFileSuffix',
+ data: { source },
});
return;
}
@@ -333,13 +381,17 @@ module.exports = {
} else {
context.report({
node,
- message:
- `Unrecognized file suffix in ${source}, ` +
- `expected: node/preload/main/std, found: ${depSuffix}`,
+ messageId: 'unrecognizedFileSuffix',
+ data: { source, depSuffix },
});
}
}
+ /**
+ * @param {Node} node
+ * @param {string} source
+ * @param {Array | null} specifiers
+ */
function processUse(node, source, specifiers) {
if (source.startsWith('.')) {
trackLocalDep(node, source);
@@ -356,38 +408,43 @@ module.exports = {
if (source === 'electron' && specifiers == null) {
context.report({
node,
- message: 'CJS import of electron is not allowed',
+ messageId: 'commonJsImportOfElectronNoAllowed',
});
return;
} else if (source === 'electron') {
- for (const s of specifiers) {
- if (s.importKind === 'type') {
- continue;
- }
+ for (const s of specifiers ?? []) {
// We implicitly skip:
// they are used in scripts
if (s.type === 'ImportSpecifier') {
- if (ELECTRON_MAIN_MODULES.has(s.imported.name)) {
+ if (s.importKind === 'type') {
+ continue;
+ }
+ /** @type {string} */
+ let importName;
+ if (s.imported.type === 'Identifier') {
+ importName = s.imported.name;
+ } else {
+ importName = s.imported.value;
+ }
+
+ if (ELECTRON_MAIN_MODULES.has(importName)) {
mainUses.push(s);
- } else if (ELECTRON_RENDERER_MODULES.has(s.imported.name)) {
+ } else if (ELECTRON_RENDERER_MODULES.has(importName)) {
preloadUses.push(s);
- } else if (ELECTRON_SHARED_MODULES.has(s.imported.name)) {
+ } else if (ELECTRON_SHARED_MODULES.has(importName)) {
// no-op
} else {
context.report({
node: s,
- message:
- `Uncategorized electron API: "${s.imported.name}". ` +
- 'Please update .eslint/rules/file-suffix.js and add it to ' +
- 'ELECTRON_MAIN_MODULES/ELECTRON_RENDERER_MODULES/' +
- 'ELECTRON_SHARED_MODULES',
+ messageId: 'uncategorizedElectronApi',
+ data: { name: importName },
});
}
} else if (s.type === 'ImportNamespaceSpecifier') {
// import * as electron from 'electron';
context.report({
node: s,
- message: 'Unsupported namespace import specifier for electron',
+ messageId: 'unsupportedNamespaceImportForElectron',
});
nodeUses.push(s);
} else if (s.type === 'ImportDefaultSpecifier') {
@@ -396,14 +453,20 @@ module.exports = {
} else {
context.report({
node: s,
- message: 'Unsupported import specifier for electron',
+ messageId: 'unsupportedImportSpecifierForElectron',
});
}
}
return;
}
- const [, moduleName] = source.match(/^([^@\/]+|@[^\/]+\/[^\/]+)/);
+ const match = source.match(/^([^@/]+|@[^/]+\/[^/]+)/);
+ if (match == null) {
+ return;
+ }
+
+ const [, moduleName] = match;
+ assert(moduleName, 'Missing moduleName');
if (NODE_PACKAGES.has(moduleName)) {
nodeUses.push(node);
} else if (source === 'react-dom/server') {
@@ -416,33 +479,50 @@ module.exports = {
} else if (!STD_PACKAGES.has(moduleName)) {
context.report({
node,
- message:
- `Uncategorized dependency "${moduleName}". ` +
- 'Please update .eslint/rules/file-suffix.js and add it to either ' +
- 'of NODE_PACKAGES/DOM_PACKAGES/STD_PACKAGES',
+ messageId: 'uncategorizedDependency',
+ data: { moduleName },
});
}
}
+ /**
+ * @param {ImportDeclaration | ExportAllDeclaration | ExportNamedDeclaration} node
+ */
function processESMReference(node) {
- if (
- node.importKind === 'type' ||
- (node.specifiers?.length &&
- node.specifiers.every(x => x.importKind === 'type'))
- ) {
- return;
+ /** @type {Array | null} */
+ let specifiers;
+ if (node.type === 'ImportDeclaration') {
+ if (node.importKind === 'type') {
+ return;
+ }
+
+ if (node.specifiers.length > 0) {
+ const allTypes = node.specifiers.every(specifier => {
+ return (
+ specifier.type === 'ImportSpecifier' &&
+ specifier.importKind === 'type'
+ );
+ });
+
+ if (allTypes) {
+ return;
+ }
+ }
+
+ specifiers = node.specifiers;
+ } else if (node.type === 'ExportNamedDeclaration') {
+ specifiers = node.specifiers;
+ } else {
+ specifiers = null;
}
+
if (!node.source) {
return;
}
if (node.source.type !== 'Literal') {
return;
}
- const {
- specifiers,
- source: { value: source },
- } = node;
-
+ const source = node.source.value;
processUse(node, source, specifiers);
}
@@ -457,19 +537,21 @@ module.exports = {
if (match == null) {
context.report({
node: node,
- message:
- 'Missing file suffix. Has to be one of: node/preload/main/std',
+ messageId: 'missingFileSuffixMustBeOneOf',
});
return;
}
- fileSuffix = match[1];
+ const matchedSuffix = match[1];
+ assert(matchedSuffix, 'Missing matchedSuffix');
+ fileSuffix = matchedSuffix;
},
'Program:exit': node => {
if (fileSuffix == null) {
return;
}
+ /** @type {Suffix} */
let expectedSuffix;
if (mainUses.length > 0) {
expectedSuffix = 'main';
@@ -500,7 +582,8 @@ module.exports = {
if (fileSuffix !== expectedSuffix) {
context.report({
node,
- message: `Invalid suffix ${fileSuffix}, expected: ${expectedSuffix}`,
+ messageId: 'wrongFileSuffix',
+ data: { fileSuffix, expectedSuffix },
});
}
@@ -508,7 +591,8 @@ module.exports = {
for (const use of invalid) {
context.report({
node: use,
- message: `Invalid import/reference for suffix: ${expectedSuffix}`,
+ messageId: 'invalidImportForSuffix',
+ data: { expectedSuffix },
});
}
},
@@ -529,27 +613,29 @@ module.exports = {
return;
}
- const scope = sourceCode.getScope(node);
- const ref = scope.references.find(r => r.identifier === node.callee);
- if (ref.resolved.scope.type !== 'global') {
+ const refType = getReferenceType(sourceCode, node.callee);
+ if (refType !== 'global') {
return;
}
+
const { arguments: args } = node;
if (args.length !== 1) {
context.report({
node,
- message: 'Invalid require() argument count',
+ messageId: 'invalidRequireCount',
});
return;
}
const [arg] = args;
+ assert(arg, 'Missing arg');
+ /** @type {string} */
let source;
- if (arg.type === 'Literal') {
+ if (isStringLiteral(arg)) {
source = arg.value;
} else if (
arg.type === 'TSAsExpression' &&
- arg.expression.type === 'Literal'
+ isStringLiteral(arg.expression)
) {
source = arg.expression.value;
} else {
@@ -557,23 +643,22 @@ module.exports = {
return;
}
- processUse(node, source, undefined);
+ processUse(node, source, null);
},
Identifier(node) {
if (node.name !== 'window' && node.name !== 'document') {
return;
}
- const scope = sourceCode.getScope(node);
- const ref = scope.references.find(r => r.identifier === node);
- if (ref == null) {
+ const refType = getReferenceType(sourceCode, node);
+ if (refType == null) {
// Not part of expression
return;
}
- if (ref.resolved.scope.type !== 'global') {
+ if (refType !== 'global') {
return;
}
domUses.push(node);
},
};
},
-};
+});
diff --git a/.oxlint/rules/enforceFileSuffix.test.mjs b/.oxlint/rules/enforceFileSuffix.test.mjs
new file mode 100644
index 0000000000..e387bafe65
--- /dev/null
+++ b/.oxlint/rules/enforceFileSuffix.test.mjs
@@ -0,0 +1,139 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { enforceFileSuffix } from './enforceFileSuffix.mjs';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+
+const ruleTester = new RuleTester();
+
+const ALLOWED_REFERENCES = /* @type {const} */ [
+ {
+ fileSuffix: 'std',
+ requiredLine: '',
+ depSuffixes: ['std'],
+ },
+ {
+ fileSuffix: 'dom',
+ requiredLine: 'window.addEventListener();',
+ depSuffixes: ['std', 'dom'],
+ },
+ {
+ fileSuffix: 'node',
+ requiredLine: 'require("node:fs");',
+ depSuffixes: ['std', 'node'],
+ },
+ {
+ fileSuffix: 'preload',
+ requiredLine: 'import { ipcRenderer } from "electron";',
+ depSuffixes: ['std', 'node', 'preload'],
+ },
+ {
+ fileSuffix: 'main',
+ requiredLine: 'import { autoUpdater } from "electron";',
+ depSuffixes: ['std', 'node', 'main'],
+ },
+];
+
+const DISALLOWED_REFERENCES = /* @type {const} */ [
+ { fileSuffix: 'std', depSuffixes: ['dom', 'node', 'preload', 'main'] },
+ { fileSuffix: 'dom', depSuffixes: ['node', 'preload', 'main'] },
+ { fileSuffix: 'node', depSuffixes: ['preload', 'main'] },
+ { fileSuffix: 'preload', depSuffixes: ['main'] },
+ { fileSuffix: 'main', depSuffixes: ['dom', 'preload'] },
+];
+
+ruleTester.run('file-suffix', enforceFileSuffix, {
+ valid: [
+ ...ALLOWED_REFERENCES.map(({ fileSuffix, requiredLine, depSuffixes }) => {
+ return depSuffixes.map(depSuffix => {
+ /** @type {const} */
+ return {
+ name: `importing ${depSuffix} from ${fileSuffix}`,
+ filename: `a.${fileSuffix}.ts`,
+ code: `
+ import { x } from './b.${depSuffix}.js';
+ ${requiredLine}
+ `,
+ languageOptions: {
+ globals: {
+ window: 'writable',
+ require: 'readable',
+ },
+ },
+ };
+ });
+ }).flat(),
+ {
+ name: 'type import should have no effect',
+ filename: 'a.std.ts',
+ code: `import type { ReadonlyDeep } from './b.dom.js'`,
+ },
+ ],
+ invalid: [
+ ...DISALLOWED_REFERENCES.map(({ fileSuffix, depSuffixes }) => {
+ return depSuffixes.map(depSuffix => {
+ /** @type {const} */
+ return {
+ name: `importing ${depSuffix} from ${fileSuffix}`,
+ filename: `a.${fileSuffix}.ts`,
+ code: `import { x } from './b.${depSuffix}.js'`,
+ errors: [
+ {
+ messageId: 'wrongFileSuffix',
+ data: { fileSuffix, expectedSuffix: depSuffix },
+ },
+ ],
+ };
+ });
+ }).flat(),
+
+ ...['dom', 'node', 'preload', 'main'].map(fileSuffix => {
+ /** @type {const} */
+ return {
+ name: `no ${fileSuffix} imports`,
+ filename: `a.${fileSuffix}.ts`,
+ code: '',
+ errors: [
+ {
+ messageId: 'wrongFileSuffix',
+ data: { fileSuffix, expectedSuffix: 'std' },
+ },
+ ],
+ };
+ }),
+
+ // Invalid imports
+ {
+ name: 'preload in main',
+ filename: 'a.main.ts',
+ code: `
+ import { autoUpdater } from 'electron';
+ import './b.preload.js';
+ `,
+ errors: [
+ {
+ messageId: 'invalidImportForSuffix',
+ data: { expectedSuffix: 'main' },
+ },
+ ],
+ },
+ {
+ name: 'main in preload',
+ filename: 'a.preload.ts',
+ code: `
+ import { ipcRenderer } from 'electron';
+ import './b.main.js';
+ `,
+ errors: [
+ {
+ messageId: 'wrongFileSuffix',
+ data: { fileSuffix: 'preload', expectedSuffix: 'main' },
+ },
+ {
+ messageId: 'invalidImportForSuffix',
+ data: { expectedSuffix: 'main' },
+ },
+ ],
+ },
+ ],
+});
diff --git a/.eslint/rules/license-comments.js b/.oxlint/rules/enforceLicenseComments.mjs
similarity index 69%
rename from .eslint/rules/license-comments.js
rename to .oxlint/rules/enforceLicenseComments.mjs
index c5554a0414..e61ad55f42 100644
--- a/.eslint/rules/license-comments.js
+++ b/.oxlint/rules/enforceLicenseComments.mjs
@@ -1,24 +1,29 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { ESLintUtils } from '@typescript-eslint/utils';
+
const COMMENT_LINE_1_EXACT = /^ Copyright \d{4} Signal Messenger, LLC$/;
const COMMENT_LINE_2_EXACT = /^ SPDX-License-Identifier: AGPL-3.0-only$/;
const COMMENT_LINE_1_LOOSE = /Copyright (\d{4}) Signal Messenger, LLC/;
const COMMENT_LINE_2_LOOSE = /SPDX-License-Identifier: AGPL-3.0-only/;
-/** @type {import("eslint").Rule.RuleModule} */
-module.exports = {
+export const enforceLicenseComments = ESLintUtils.RuleCreator.withoutDocs({
meta: {
type: 'problem',
- hasSuggestions: false,
- fixable: true,
+ fixable: 'code',
+ messages: {
+ missingLicenseComment: 'Missing license comment',
+ },
schema: [],
+ defaultOptions: [],
},
create(context) {
return {
Program(node) {
- let comment1 = node.comments.at(0);
- let comment2 = node.comments.at(1);
+ const comment1 = node.comments?.at(0);
+ const comment2 = node.comments?.at(1);
if (
comment1?.type === 'Line' &&
@@ -31,15 +36,14 @@ module.exports = {
context.report({
node,
- message: 'Missing license comment',
-
+ messageId: 'missingLicenseComment',
fix(fixer) {
let year = null;
- let remove = [];
+ const remove = [];
- for (let comment of node.comments) {
- let match1 = comment.value.match(COMMENT_LINE_1_LOOSE);
- let match2 = comment.value.match(COMMENT_LINE_2_LOOSE);
+ for (const comment of node.comments ?? []) {
+ const match1 = comment.value.match(COMMENT_LINE_1_LOOSE);
+ const match2 = comment.value.match(COMMENT_LINE_2_LOOSE);
if (match1 != null) {
year = match1[1];
@@ -52,7 +56,7 @@ module.exports = {
year ??= new Date().getFullYear().toString();
- let insert =
+ const insert =
`// Copyright ${year} Signal Messenger, LLC\n` +
'// SPDX-License-Identifier: AGPL-3.0-only\n';
@@ -70,4 +74,4 @@ module.exports = {
},
};
},
-};
+});
diff --git a/.eslint/rules/enforce-tw.js b/.oxlint/rules/enforceTw.mjs
similarity index 75%
rename from .eslint/rules/enforce-tw.js
rename to .oxlint/rules/enforceTw.mjs
index 08f5d229a9..0e4281e1dc 100644
--- a/.eslint/rules/enforce-tw.js
+++ b/.oxlint/rules/enforceTw.mjs
@@ -1,18 +1,30 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-const { createSyncFn } = require('synckit');
+// @ts-check
+import { ESLintUtils } from '@typescript-eslint/utils';
+import { createSyncFn } from 'synckit';
-const worker = createSyncFn(require.resolve('./enforce-tw.worker.js'));
+/**
+ * @typedef {import("@typescript-eslint/utils").TSESTree.Node} Node
+ */
-/** @type {import("eslint").Rule.RuleModule} */
-module.exports = {
+const worker = createSyncFn(import.meta.resolve('./enforceTw.worker.mjs'));
+
+export const enforceTw = ESLintUtils.RuleCreator.withoutDocs({
+ name: 'enforce-tw',
meta: {
type: 'problem',
- hasSuggestions: true,
- fixable: true,
+ messages: {
+ needsTw: 'Tailwind classes must be wrapped with tw()',
+ },
schema: [],
+ defaultOptions: [],
},
create(context) {
+ /**
+ * @param {string} input
+ * @param {Node} node
+ */
function check(input, node) {
if (typeof input !== 'string') {
throw new Error(`Unexpected input ${input} for node type ${node.type}`);
@@ -35,11 +47,14 @@ module.exports = {
column: node.loc.start.column + index + length,
},
},
- message: 'Tailwind classes must be wrapped with tw()',
+ messageId: 'needsTw',
});
}
}
+ /**
+ * @param {Node} node
+ */
function traverse(node) {
if (node.type === 'Literal') {
if (typeof node.value === 'string') {
@@ -47,14 +62,16 @@ module.exports = {
}
// ignore other literals
} else if (node.type === 'TemplateLiteral') {
- for (let element of node.quasis) {
+ for (const element of node.quasis) {
traverse(element);
}
- for (let expression of node.expressions) {
+ for (const expression of node.expressions) {
traverse(expression);
}
} else if (node.type === 'TemplateElement') {
- check(node.value.cooked, node);
+ if (node.value.cooked != null) {
+ check(node.value.cooked, node);
+ }
} else if (node.type === 'JSXExpressionContainer') {
traverse(node.expression);
} else if (node.type === 'ConditionalExpression') {
@@ -74,7 +91,7 @@ module.exports = {
throw new Error(`Unexpected binary operator: ${node.operator}`);
}
} else if (node.type === 'ObjectExpression') {
- for (let prop of node.properties) {
+ for (const prop of node.properties) {
traverse(prop);
}
} else if (node.type === 'Property') {
@@ -93,8 +110,10 @@ module.exports = {
throw new Error(`Unexpected property key type: ${node.key.type}`);
}
} else if (node.type === 'ArrayExpression') {
- for (let element of node.elements) {
- traverse(element);
+ for (const element of node.elements) {
+ if (element != null) {
+ traverse(element);
+ }
}
} else if (node.type === 'Identifier') {
// ignore
@@ -111,15 +130,17 @@ module.exports = {
CallExpression(node) {
if (node.callee.type !== 'Identifier') return;
if (node.callee.name !== 'classNames') return;
- for (let arg of node.arguments) {
+ for (const arg of node.arguments) {
traverse(arg);
}
},
JSXAttribute(node) {
if (node.name.type !== 'JSXIdentifier') return;
if (node.name.name !== 'className') return;
- traverse(node.value);
+ if (node.value != null) {
+ traverse(node.value);
+ }
},
};
},
-};
+});
diff --git a/.oxlint/rules/enforceTw.test.mjs b/.oxlint/rules/enforceTw.test.mjs
new file mode 100644
index 0000000000..e9a4609929
--- /dev/null
+++ b/.oxlint/rules/enforceTw.test.mjs
@@ -0,0 +1,69 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { enforceTw } from './enforceTw.mjs';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ },
+});
+
+ruleTester.run('enforce-tw', enforceTw, {
+ valid: [
+ { code: `classNames("foo")` },
+ { code: `` },
+ { code: `tw("flex")` },
+ ],
+ invalid: [
+ {
+ code: `classNames("flex")`,
+ errors: [{ messageId: 'needsTw' }],
+ },
+ {
+ code: ``,
+ errors: [{ messageId: 'needsTw' }],
+ },
+ {
+ code: ``,
+ errors: [{ messageId: 'needsTw' }],
+ },
+ {
+ code: `classNames("foo", "flex")`,
+ errors: [{ messageId: 'needsTw' }],
+ },
+ {
+ code: `classNames(cond ? "foo" : "flex")`,
+ errors: [{ messageId: 'needsTw' }],
+ },
+ {
+ code: `classNames(cond ? "flex" : "foo")`,
+ errors: [{ messageId: 'needsTw' }],
+ },
+ {
+ code: `classNames(cond && "flex")`,
+ errors: [{ messageId: 'needsTw' }],
+ },
+ {
+ code: `classNames(cond || "flex")`,
+ errors: [{ messageId: 'needsTw' }],
+ },
+ {
+ code: `classNames(cond ?? "flex")`,
+ errors: [{ messageId: 'needsTw' }],
+ },
+ {
+ code: `classNames("foo" + "flex")`,
+ errors: [{ messageId: 'needsTw' }],
+ },
+ {
+ code: `classNames("flex" + "foo")`,
+ errors: [{ messageId: 'needsTw' }],
+ },
+ ],
+});
diff --git a/.eslint/rules/enforce-tw.worker.js b/.oxlint/rules/enforceTw.worker.mjs
similarity index 67%
rename from .eslint/rules/enforce-tw.worker.js
rename to .oxlint/rules/enforceTw.worker.mjs
index 348bb628f7..5fc97cf1d0 100644
--- a/.eslint/rules/enforce-tw.worker.js
+++ b/.oxlint/rules/enforceTw.worker.mjs
@@ -1,12 +1,13 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-const { runAsWorker } = require('synckit');
-const enhancedResolve = require('enhanced-resolve');
-const tailwind = require('tailwindcss');
-const path = require('node:path');
-const fs = require('node:fs');
+// @ts-check
+import { runAsWorker } from 'synckit';
+import enhancedResolve from 'enhanced-resolve';
+import * as tailwind from 'tailwindcss';
+import path from 'node:path';
+import fs from 'node:fs';
-const rootDir = path.join(__dirname, '../..');
+const rootDir = path.join(import.meta.dirname, '../..');
const tailwindCssPath = path.join(rootDir, 'stylesheets/tailwind-config.css');
async function loadDesignSystem() {
@@ -21,12 +22,13 @@ async function loadDesignSystem() {
tailwindCss,
{
base: path.dirname(tailwindCssPath),
- loadStylesheet(id, base) {
+ async loadStylesheet(id, base) {
const resolved = resolver(base, id);
if (!resolved) {
- return { base: '', content: '' };
+ return { path: '', base: '', content: '' };
}
return {
+ path: resolved,
base: path.dirname(resolved),
content: fs.readFileSync(resolved, 'utf-8'),
};
@@ -39,12 +41,17 @@ async function loadDesignSystem() {
let cachedDesignSystem = null;
-runAsWorker(async classNames => {
+/**
+ * @param {Array
} classNames
+ */
+async function worker(classNames) {
cachedDesignSystem ??= await loadDesignSystem();
const designSystem = cachedDesignSystem;
const css = designSystem.candidatesToCss(classNames);
const tailwindClassNames = classNames.filter((_, index) => {
- return css.at(index) !== null;
+ return css.at(index) != null;
});
return tailwindClassNames;
-});
+}
+
+runAsWorker(worker);
diff --git a/.oxlint/rules/enforceTypeAliasReadonlyDeep.mjs b/.oxlint/rules/enforceTypeAliasReadonlyDeep.mjs
new file mode 100644
index 0000000000..c4df3685ee
--- /dev/null
+++ b/.oxlint/rules/enforceTypeAliasReadonlyDeep.mjs
@@ -0,0 +1,75 @@
+// Copyright 2023 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { ESLintUtils } from '@typescript-eslint/utils';
+import { assert } from './utils/assert.mjs';
+
+/**
+ * @typedef {import("@typescript-eslint/utils").TSESTree.Node} Node
+ * @typedef {import("@typescript-eslint/utils").TSESLint.Scope.Scope} Scope
+ */
+
+/**
+ * @param {Node} node
+ * @param {Scope} scope
+ */
+function isReadOnlyDeep(node, scope) {
+ if (node.type !== 'TSTypeReference') {
+ return false;
+ }
+
+ const reference = scope.references.find(ref => {
+ return ref.identifier === node.typeName;
+ });
+
+ const variable = reference?.resolved;
+ if (variable == null) {
+ return false;
+ }
+
+ const defs = variable.defs;
+ if (defs.length !== 1) {
+ return false;
+ }
+
+ const [def] = defs;
+ assert(def, 'Missing def');
+
+ return (
+ def.type === 'ImportBinding' &&
+ def.parent.type === 'ImportDeclaration' &&
+ def.parent.source.type === 'Literal' &&
+ def.parent.source.value === 'type-fest'
+ );
+}
+
+export const enforceTypeAliasReadonlyDeep = ESLintUtils.RuleCreator.withoutDocs(
+ {
+ name: 'enforce-type-alias-readonlydeep',
+ meta: {
+ type: 'problem',
+ messages: {
+ needsReadonlyDeep:
+ 'Type aliases must be wrapped with ReadonlyDeep from type-fest',
+ },
+ schema: [],
+ defaultOptions: [],
+ },
+ create(context) {
+ return {
+ TSTypeAliasDeclaration(node) {
+ const scope = context.sourceCode.getScope(node);
+
+ if (isReadOnlyDeep(node.typeAnnotation, scope)) {
+ return;
+ }
+
+ context.report({
+ node: node.id,
+ messageId: 'needsReadonlyDeep',
+ });
+ },
+ };
+ },
+ }
+);
diff --git a/.oxlint/rules/enforceTypeAliasReadonlyDeep.test.mjs b/.oxlint/rules/enforceTypeAliasReadonlyDeep.test.mjs
new file mode 100644
index 0000000000..d44f77a602
--- /dev/null
+++ b/.oxlint/rules/enforceTypeAliasReadonlyDeep.test.mjs
@@ -0,0 +1,40 @@
+// Copyright 2023 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { enforceTypeAliasReadonlyDeep } from './enforceTypeAliasReadonlyDeep.mjs';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+
+const ruleTester = new RuleTester();
+
+ruleTester.run('type-alias-readonlydeep', enforceTypeAliasReadonlyDeep, {
+ valid: [
+ {
+ code: `import type { ReadonlyDeep } from "type-fest"; type Foo = ReadonlyDeep<{}>`,
+ },
+ {
+ code: `import { ReadonlyDeep } from "type-fest"; type Foo = ReadonlyDeep<{}>`,
+ },
+ ],
+ invalid: [
+ {
+ code: `type Foo = {}`,
+ errors: [{ messageId: 'needsReadonlyDeep' }],
+ },
+ {
+ code: `type Foo = Bar<{}>`,
+ errors: [{ messageId: 'needsReadonlyDeep' }],
+ },
+ {
+ code: `type Foo = ReadonlyDeep<{}>`,
+ errors: [{ messageId: 'needsReadonlyDeep' }],
+ },
+ {
+ code: `interface ReadonlyDeep {}; type Foo = ReadonlyDeep<{}>`,
+ errors: [{ messageId: 'needsReadonlyDeep' }],
+ },
+ {
+ code: `import type { ReadonlyDeep } from "foo"; type Foo = ReadonlyDeep<{}>`,
+ errors: [{ messageId: 'needsReadonlyDeep' }],
+ },
+ ],
+});
diff --git a/.oxlint/rules/fixtures/noExtraneousDependencies/package/package.json b/.oxlint/rules/fixtures/noExtraneousDependencies/package/package.json
new file mode 100644
index 0000000000..fa770e996e
--- /dev/null
+++ b/.oxlint/rules/fixtures/noExtraneousDependencies/package/package.json
@@ -0,0 +1,22 @@
+{
+ "dependencies": {
+ "prod-dep": "0.0.0",
+ "@scoped/prod-dep": "0.0.0"
+ },
+ "devDependencies": {
+ "dev-dep": "0.0.0",
+ "@scoped/dev-dep": "0.0.0"
+ },
+ "peerDependencies": {
+ "peer-dep": "0.0.0",
+ "@scoped/peer-dep": "0.0.0"
+ },
+ "optionalDependencies": {
+ "optional-dep": "0.0.0",
+ "@scoped/optional-dep": "0.0.0"
+ },
+ "bundledDependencies": [
+ "bundled-dep",
+ "@scoped/bundled-dep"
+ ]
+}
diff --git a/.oxlint/rules/fixtures/noRestrictedPaths/client/client.ts b/.oxlint/rules/fixtures/noRestrictedPaths/client/client.ts
new file mode 100644
index 0000000000..527fb0888d
--- /dev/null
+++ b/.oxlint/rules/fixtures/noRestrictedPaths/client/client.ts
@@ -0,0 +1,3 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+export {};
diff --git a/.oxlint/rules/fixtures/noRestrictedPaths/server/server.ts b/.oxlint/rules/fixtures/noRestrictedPaths/server/server.ts
new file mode 100644
index 0000000000..527fb0888d
--- /dev/null
+++ b/.oxlint/rules/fixtures/noRestrictedPaths/server/server.ts
@@ -0,0 +1,3 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+export {};
diff --git a/.oxlint/rules/fixtures/noRestrictedPaths/tsconfig.json b/.oxlint/rules/fixtures/noRestrictedPaths/tsconfig.json
new file mode 100644
index 0000000000..96ae7628c7
--- /dev/null
+++ b/.oxlint/rules/fixtures/noRestrictedPaths/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "include": ["./client/**", "./server/**"],
+ "compilerOptions": {}
+}
diff --git a/.oxlint/rules/noDisabledTests.mjs b/.oxlint/rules/noDisabledTests.mjs
new file mode 100644
index 0000000000..75251e10ac
--- /dev/null
+++ b/.oxlint/rules/noDisabledTests.mjs
@@ -0,0 +1,64 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { ESLintUtils } from '@typescript-eslint/utils';
+import { getReferenceType } from './utils/getReferenceType.mjs';
+import { isPropertyAccess } from './utils/astUtils.mjs';
+
+export const noDisabledTests = ESLintUtils.RuleCreator.withoutDocs({
+ name: 'no-disabled-tests',
+ meta: {
+ type: 'problem',
+ hasSuggestions: true,
+ messages: {
+ unexpectedDisabledTest: 'Unexpected disabled test',
+ removeSkip: 'Remove .skip()',
+ },
+ schema: [],
+ defaultOptions: [],
+ },
+ create(context) {
+ const { sourceCode } = context;
+
+ return {
+ MemberExpression(node) {
+ if (node.object.type !== 'Identifier') {
+ return;
+ }
+
+ let replacement;
+ if (node.object.name === 'describe') {
+ replacement = 'describe';
+ } else if (node.object.name === 'it') {
+ replacement = 'it';
+ } else if (node.object.name === 'test') {
+ replacement = 'test';
+ } else {
+ return;
+ }
+
+ if (!isPropertyAccess(node, 'skip')) {
+ return;
+ }
+
+ const refType = getReferenceType(sourceCode, node.object);
+ if (refType != null && refType !== 'global') {
+ return;
+ }
+
+ context.report({
+ node,
+ messageId: 'unexpectedDisabledTest',
+ suggest: [
+ {
+ messageId: 'removeSkip',
+ fix(fixer) {
+ return [fixer.replaceTextRange(node.range, replacement)];
+ },
+ },
+ ],
+ });
+ },
+ };
+ },
+});
diff --git a/.oxlint/rules/noDisabledTests.test.mjs b/.oxlint/rules/noDisabledTests.test.mjs
new file mode 100644
index 0000000000..345e498b43
--- /dev/null
+++ b/.oxlint/rules/noDisabledTests.test.mjs
@@ -0,0 +1,56 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { noDisabledTests } from './noDisabledTests.mjs';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+
+const ruleTester = new RuleTester();
+
+ruleTester.run('no-disabled-tests', noDisabledTests, {
+ valid: [
+ { code: 'describe(() => {});' },
+ { code: 'it(() => {});' },
+ { code: 'test(() => {});' },
+ { code: 'describe.only(() => {});' },
+ { code: 'it.only(() => {});' },
+ { code: 'test.only(() => {});' },
+ { code: 'let describe; describe.skip(() => {});' },
+ { code: 'x.describe.skip(() => {});' },
+ ],
+ invalid: [
+ {
+ code: `describe.skip(() => {});`,
+ suggestion: `describe(() => {});`,
+ },
+ {
+ code: `it.skip(() => {});`,
+ suggestion: `it(() => {});`,
+ },
+ {
+ code: `test.skip(() => {});`,
+ suggestion: `test(() => {});`,
+ },
+ {
+ code: `describe['skip'](() => {});`,
+ suggestion: `describe(() => {});`,
+ },
+ {
+ code: `it['skip'](() => {});`,
+ suggestion: `it(() => {});`,
+ },
+ {
+ code: `test['skip'](() => {});`,
+ suggestion: `test(() => {});`,
+ },
+ ].map(opts => {
+ return {
+ code: opts.code,
+ errors: [
+ {
+ messageId: 'unexpectedDisabledTest',
+ suggestions: [{ messageId: 'removeSkip', output: opts.suggestion }],
+ },
+ ],
+ };
+ }),
+});
diff --git a/.oxlint/rules/noExtraneousDependencies.mjs b/.oxlint/rules/noExtraneousDependencies.mjs
new file mode 100644
index 0000000000..3bae0dbc24
--- /dev/null
+++ b/.oxlint/rules/noExtraneousDependencies.mjs
@@ -0,0 +1,202 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { ESLintUtils } from '@typescript-eslint/utils';
+import { readFileSync } from 'node:fs';
+import { dirname } from 'node:path';
+import { isBuiltin, findPackageJSON } from 'node:module';
+import { createImportSourceVisitor } from './utils/createImportSourceVisitor.mjs';
+
+/**
+ * @param value {unknown}
+ * @returns {value is Record}
+ */
+function isObject(value) {
+ return typeof value === 'object' && value != null;
+}
+
+/**
+ * @param deps {unknown}
+ */
+function getDepsKeys(deps) {
+ return new Set(isObject(deps) ? Object.keys(deps) : null);
+}
+
+/**
+ * @param deps {unknown}
+ * @returns {Set}
+ */
+function getBundledDepsKeys(deps) {
+ return Array.isArray(deps) ? new Set(deps) : getDepsKeys(deps);
+}
+
+/**
+ * @typedef {object} PkgDeps
+ * @property {Set} dependencies
+ * @property {Set} devDependencies
+ * @property {Set} peerDependencies
+ * @property {Set} optionalDependencies
+ * @property {Set} bundledDependencies
+ */
+
+/** @type {Map} */
+const PKG_DEPS_CACHE = new Map();
+
+/** @param {string} currentFile */
+function getPkgDeps(currentFile) {
+ const currentDir = dirname(currentFile);
+
+ const cached = PKG_DEPS_CACHE.get(currentDir);
+ if (cached != null) {
+ return cached;
+ }
+
+ const pkgPath = findPackageJSON('.', currentFile);
+ if (pkgPath == null) {
+ throw new Error(`Could not resolve package.json from ${currentFile}`);
+ }
+ const pkgText = readFileSync(pkgPath, 'utf8');
+ const pkgJson = JSON.parse(pkgText);
+
+ /** @type {PkgDeps} */
+ const pkgDeps = {
+ dependencies: getDepsKeys(pkgJson.dependencies),
+ devDependencies: getDepsKeys(pkgJson.devDependencies),
+ peerDependencies: getDepsKeys(pkgJson.peerDependencies),
+ optionalDependencies: getDepsKeys(pkgJson.optionalDependencies),
+ bundledDependencies: getBundledDepsKeys(pkgJson.bundledDependencies),
+ };
+
+ PKG_DEPS_CACHE.set(currentDir, pkgDeps);
+
+ return pkgDeps;
+}
+
+/** @param {string} source */
+function getPackageNameFromSource(source) {
+ if (source.startsWith('@')) {
+ const [scope, name] = source.split('/', 2);
+ return `${scope}/${name}`;
+ }
+ const [name] = source.split('/', 1);
+ return `${name}`;
+}
+
+/**
+ * @typedef {object} Options
+ * @property {boolean=} devDependencies
+ * @property {boolean=} peerDependencies
+ * @property {boolean=} optionalDependencies
+ * @property {boolean=} bundledDependencies
+ */
+
+/** @type {[Options]} */
+const defaultOptions = [
+ {
+ devDependencies: true,
+ peerDependencies: true,
+ optionalDependencies: true,
+ bundledDependencies: true,
+ },
+];
+
+export const noExtraneousDependencies = ESLintUtils.RuleCreator.withoutDocs({
+ name: 'no-extraneous-dependencies',
+ meta: {
+ type: 'problem',
+ messages: {
+ missingFromProjectDeps:
+ "'{{pkgName}}' should be listed in the project's dependencies",
+ wrongProjectDeps:
+ "'{{pkgName}}' should be listed in the project's dependencies, found in {{found}}",
+ },
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ devDependencies: { type: 'boolean' },
+ peerDependencies: { type: 'boolean' },
+ optionalDependencies: { type: 'boolean' },
+ bundledDependencies: { type: 'boolean' },
+ },
+ additionalProperties: false,
+ },
+ ],
+ defaultOptions,
+ },
+ create(context) {
+ const { sourceCode, options } = context;
+
+ const opts = {
+ devDependencies: options[0]?.devDependencies ?? true,
+ peerDependencies: options[0]?.peerDependencies ?? true,
+ optionalDependencies: options[0]?.optionalDependencies ?? true,
+ bundledDependencies: options[0]?.bundledDependencies ?? true,
+ };
+
+ const pkgDeps = getPkgDeps(context.physicalFilename);
+
+ return createImportSourceVisitor(sourceCode, node => {
+ const source = node.value;
+
+ if (
+ source.startsWith('.') ||
+ source.startsWith('/') ||
+ source.trim() === ''
+ ) {
+ return;
+ }
+
+ if (isBuiltin(source)) {
+ return;
+ }
+
+ const pkgName = getPackageNameFromSource(source);
+
+ /** @type {Array} */
+ const found = [];
+
+ if (pkgDeps.dependencies.has(pkgName)) {
+ return;
+ }
+ if (pkgDeps.devDependencies.has(pkgName)) {
+ found.push('devDependencies');
+ if (opts.devDependencies) {
+ return;
+ }
+ }
+ if (pkgDeps.peerDependencies.has(pkgName)) {
+ found.push('peerDependencies');
+ if (opts.peerDependencies) {
+ return;
+ }
+ }
+ if (pkgDeps.optionalDependencies.has(pkgName)) {
+ found.push('optionalDependencies');
+ if (opts.optionalDependencies) {
+ return;
+ }
+ }
+ if (pkgDeps.bundledDependencies.has(pkgName)) {
+ found.push('bundledDependencies');
+ if (opts.bundledDependencies) {
+ return;
+ }
+ }
+
+ if (found.length > 0) {
+ context.report({
+ node,
+ messageId: 'wrongProjectDeps',
+ data: { pkgName, found: found.join(', ') },
+ });
+ } else {
+ context.report({
+ node,
+ messageId: 'missingFromProjectDeps',
+ data: { pkgName },
+ });
+ }
+ });
+ },
+});
diff --git a/.oxlint/rules/noExtraneousDependencies.test.mjs b/.oxlint/rules/noExtraneousDependencies.test.mjs
new file mode 100644
index 0000000000..095f5a5ef8
--- /dev/null
+++ b/.oxlint/rules/noExtraneousDependencies.test.mjs
@@ -0,0 +1,112 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import path from 'node:path';
+import { noExtraneousDependencies } from './noExtraneousDependencies.mjs';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+
+/**
+ * @typedef {import("./noExtraneousDependencies.mjs").Options} Options
+ */
+
+const ruleTester = new RuleTester();
+
+const filename = path.join(
+ import.meta.dirname,
+ 'fixtures/noExtraneousDependencies/package/foo.js'
+);
+
+/** @type {Options} */
+const NONE = {
+ devDependencies: false,
+ peerDependencies: false,
+ optionalDependencies: false,
+ bundledDependencies: false,
+};
+
+/**
+ * @satisfies {Record}
+ */
+const opts = {
+ none: [NONE],
+ dev: [{ ...NONE, devDependencies: true }],
+ peer: [{ ...NONE, peerDependencies: true }],
+ optional: [{ ...NONE, optionalDependencies: true }],
+ bundled: [{ ...NONE, bundledDependencies: true }],
+};
+
+ruleTester.run('no-extraneous-dependencies', noExtraneousDependencies, {
+ valid: [
+ { filename, code: `import a from "./a";`, options: opts.none },
+ { filename, code: `import a from "../a";`, options: opts.none },
+ { filename, code: `import a from "path";`, options: opts.none },
+ { filename, code: `import a from "node:path";`, options: opts.none },
+ { filename, code: `import a from "";`, options: opts.none },
+ { filename, code: `import a from "prod-dep";`, options: opts.none },
+ { filename, code: `import a from "prod-dep/nested";`, options: opts.none },
+ { filename, code: `import a from "@scoped/prod-dep";`, options: opts.none },
+ {
+ filename,
+ code: `import a from "@scoped/prod-dep/nested";`,
+ options: opts.none,
+ },
+ { filename, code: `import a from "dev-dep";`, options: opts.dev },
+ { filename, code: `import a from "peer-dep";`, options: opts.peer },
+ {
+ filename,
+ code: `import a from "optional-dep";`,
+ options: opts.optional,
+ },
+ { filename, code: `import a from "bundled-dep";`, options: opts.bundled },
+ ],
+ invalid: [
+ {
+ filename,
+ code: `import a from "dev-dep";`,
+ options: opts.none,
+ errors: [{ messageId: 'wrongProjectDeps' }],
+ },
+ {
+ filename,
+ code: `import a from "peer-dep";`,
+ options: opts.none,
+ errors: [{ messageId: 'wrongProjectDeps' }],
+ },
+ {
+ filename,
+ code: `import a from "optional-dep";`,
+ options: opts.none,
+ errors: [{ messageId: 'wrongProjectDeps' }],
+ },
+ {
+ filename,
+ code: `import a from "bundled-dep";`,
+ options: opts.none,
+ errors: [{ messageId: 'wrongProjectDeps' }],
+ },
+ {
+ filename,
+ code: `import a from "dev-dep";`,
+ options: opts.peer,
+ errors: [{ messageId: 'wrongProjectDeps' }],
+ },
+ {
+ filename,
+ code: `import a from "dev-dep";`,
+ options: opts.optional,
+ errors: [{ messageId: 'wrongProjectDeps' }],
+ },
+ {
+ filename,
+ code: `import a from "dev-dep";`,
+ options: opts.bundled,
+ errors: [{ messageId: 'wrongProjectDeps' }],
+ },
+ {
+ filename,
+ code: `import a from "does-not-exist";`,
+ options: opts.bundled,
+ errors: [{ messageId: 'missingFromProjectDeps' }],
+ },
+ ],
+});
diff --git a/.oxlint/rules/noFocusedTests.mjs b/.oxlint/rules/noFocusedTests.mjs
new file mode 100644
index 0000000000..54964817b2
--- /dev/null
+++ b/.oxlint/rules/noFocusedTests.mjs
@@ -0,0 +1,60 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { ESLintUtils } from '@typescript-eslint/utils';
+import { getReferenceType } from './utils/getReferenceType.mjs';
+import { isPropertyAccess } from './utils/astUtils.mjs';
+
+export const noFocusedTests = ESLintUtils.RuleCreator.withoutDocs({
+ name: 'no-focused-tests',
+ meta: {
+ type: 'problem',
+ hasSuggestions: true,
+ fixable: 'code',
+ messages: {
+ unexpectedFocusedTest: 'Unexpected focused test',
+ },
+ schema: [],
+ },
+ create(context) {
+ const { sourceCode } = context;
+
+ return {
+ MemberExpression(node) {
+ if (node.object.type !== 'Identifier') {
+ return;
+ }
+ let replacement;
+ if (node.object.name === 'describe') {
+ replacement = 'describe';
+ } else if (node.object.name === 'it') {
+ replacement = 'it';
+ } else if (node.object.name === 'test') {
+ replacement = 'test';
+ } else {
+ return;
+ }
+
+ if (!isPropertyAccess(node, 'only')) {
+ return;
+ }
+
+ const refType = getReferenceType(sourceCode, node.object);
+ if (refType != null && refType !== 'global') {
+ return;
+ }
+
+ context.report({
+ node,
+ messageId: 'unexpectedFocusedTest',
+ fix(fixer) {
+ if (node.range == null) {
+ return null;
+ }
+ return [fixer.replaceTextRange(node.range, replacement)];
+ },
+ });
+ },
+ };
+ },
+});
diff --git a/.oxlint/rules/noFocusedTests.test.mjs b/.oxlint/rules/noFocusedTests.test.mjs
new file mode 100644
index 0000000000..638aa0e695
--- /dev/null
+++ b/.oxlint/rules/noFocusedTests.test.mjs
@@ -0,0 +1,52 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { noFocusedTests } from './noFocusedTests.mjs';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+
+const ruleTester = new RuleTester();
+
+ruleTester.run('no-focused-tests', noFocusedTests, {
+ valid: [
+ { code: 'describe(() => {});' },
+ { code: 'it(() => {});' },
+ { code: 'test(() => {});' },
+ { code: 'describe.skip(() => {});' },
+ { code: 'it.skip(() => {});' },
+ { code: 'test.skip(() => {});' },
+ { code: 'let describe; describe.only(() => {});' },
+ { code: 'x.describe.only(() => {});' },
+ ],
+ invalid: [
+ {
+ code: `describe.only(() => {});`,
+ output: `describe(() => {});`,
+ errors: [{ messageId: 'unexpectedFocusedTest' }],
+ },
+ {
+ code: `it.only(() => {});`,
+ output: `it(() => {});`,
+ errors: [{ messageId: 'unexpectedFocusedTest' }],
+ },
+ {
+ code: `test.only(() => {});`,
+ output: `test(() => {});`,
+ errors: [{ messageId: 'unexpectedFocusedTest' }],
+ },
+ {
+ code: `describe['only'](() => {});`,
+ output: `describe(() => {});`,
+ errors: [{ messageId: 'unexpectedFocusedTest' }],
+ },
+ {
+ code: `it['only'](() => {});`,
+ output: `it(() => {});`,
+ errors: [{ messageId: 'unexpectedFocusedTest' }],
+ },
+ {
+ code: `test['only'](() => {});`,
+ output: `test(() => {});`,
+ errors: [{ messageId: 'unexpectedFocusedTest' }],
+ },
+ ],
+});
diff --git a/.oxlint/rules/noForIn.mjs b/.oxlint/rules/noForIn.mjs
new file mode 100644
index 0000000000..d8eef9bc02
--- /dev/null
+++ b/.oxlint/rules/noForIn.mjs
@@ -0,0 +1,25 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { ESLintUtils } from '@typescript-eslint/utils';
+
+export const noForIn = ESLintUtils.RuleCreator.withoutDocs({
+ name: 'no-for-in',
+ meta: {
+ type: 'problem',
+ messages: {
+ preferForOf: 'Prefer for..of loops',
+ },
+ schema: [],
+ },
+ create(context) {
+ return {
+ ForInStatement(node) {
+ context.report({
+ node,
+ messageId: 'preferForOf',
+ });
+ },
+ };
+ },
+});
diff --git a/.oxlint/rules/noForIn.test.mjs b/.oxlint/rules/noForIn.test.mjs
new file mode 100644
index 0000000000..51564a444f
--- /dev/null
+++ b/.oxlint/rules/noForIn.test.mjs
@@ -0,0 +1,25 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { noForIn } from './noForIn.mjs';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+
+const ruleTester = new RuleTester();
+
+ruleTester.run('no-for-in', noForIn, {
+ valid: [
+ { code: 'for (let a of b) {}' },
+ { code: 'for (;;) {}' },
+ { code: 'if (a in b) {}' },
+ ],
+ invalid: [
+ {
+ code: `for (let a in b) {}`,
+ errors: [{ messageId: 'preferForOf' }],
+ },
+ {
+ code: `for (a in b) {}`,
+ errors: [{ messageId: 'preferForOf' }],
+ },
+ ],
+});
diff --git a/.oxlint/rules/noRestrictedPaths.mjs b/.oxlint/rules/noRestrictedPaths.mjs
new file mode 100644
index 0000000000..9aa0e820c4
--- /dev/null
+++ b/.oxlint/rules/noRestrictedPaths.mjs
@@ -0,0 +1,264 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { ESLintUtils } from '@typescript-eslint/utils';
+import { createImportSourceVisitor } from './utils/createImportSourceVisitor.mjs';
+import micromatch from 'micromatch';
+import isGlob from 'is-glob';
+import * as path from 'node:path';
+import { assert } from './utils/assert.mjs';
+import enhancedResolve from 'enhanced-resolve';
+
+const resolver = enhancedResolve.create.sync({
+ extensionAlias: {
+ '.js': ['.ts', '.tsx', '.js'],
+ },
+});
+
+/**
+ * @param {string} fromDir
+ * @param {string} moduleName
+ */
+function resolveFrom(fromDir, moduleName) {
+ try {
+ const result = resolver(fromDir, moduleName);
+ if (result === false) {
+ return null;
+ }
+ return result;
+ } catch (error) {
+ return null;
+ }
+}
+
+/**
+ * @param {string | string[]} input
+ * @returns {string[]}
+ */
+function toArray(input) {
+ return Array.isArray(input) ? input : [input];
+}
+
+/**
+ * @param {string} filePath
+ * @param {string} target
+ */
+function containsPath(filePath, target) {
+ const relative = path.relative(target, filePath);
+ return relative === '' || !relative.startsWith('..');
+}
+
+/**
+ * @param {string} fileName
+ * @param {RegExp | string} targetPath
+ */
+function isMatchingTargetPath(fileName, targetPath) {
+ return typeof targetPath === 'string'
+ ? containsPath(fileName, targetPath)
+ : targetPath.test(fileName);
+}
+
+/** @type {Map} */
+const REGEX_CACHE = new Map();
+
+/**
+ * @typedef {object} Zone
+ * @property {string | string[]=} target
+ * @property {string | string[]=} from
+ * @property {string[]=} except
+ * @property {string=} message
+ */
+
+/**
+ * @typedef {object} Matcher
+ * @property {(RegExp | string)[]} targetPaths
+ * @property {(RegExp | string)[]} fromPaths
+ * @property {(RegExp | string)[] | null} exceptPaths
+ * @property {string | null} message
+ */
+
+/** @type {[Options]} */
+const defaultOptions = [{}];
+
+/**
+ * @typedef {object} Options
+ * @property {Zone[]=} zones
+ * @property {string=} basePath
+ */
+
+export const noRestrictedPaths = ESLintUtils.RuleCreator.withoutDocs({
+ name: 'no-restricted-paths',
+ meta: {
+ type: 'problem',
+ messages: {
+ pathRestrictedNoMessage:
+ 'Unexpected path "{{moduleName}}" imported in restricted zone.',
+ pathRestrictedWithMessage:
+ 'Unexpected path "{{moduleName}}" imported in restricted zone. {{message}}',
+ },
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ zones: {
+ type: 'array',
+ minItems: 1,
+ items: {
+ type: 'object',
+ properties: {
+ target: {
+ anyOf: [
+ { type: 'string' },
+ {
+ type: 'array',
+ items: { type: 'string' },
+ uniqueItems: true,
+ minItems: 1,
+ },
+ ],
+ },
+ from: {
+ anyOf: [
+ { type: 'string' },
+ {
+ type: 'array',
+ items: { type: 'string' },
+ uniqueItems: true,
+ minItems: 1,
+ },
+ ],
+ },
+ except: {
+ type: 'array',
+ items: {
+ type: 'string',
+ },
+ uniqueItems: true,
+ },
+ message: { type: 'string' },
+ },
+ additionalProperties: false,
+ },
+ },
+ basePath: { type: 'string' },
+ },
+ additionalProperties: false,
+ },
+ ],
+ defaultOptions,
+ },
+ create(context) {
+ const { filename, sourceCode } = context;
+
+ const zones = context.options[0]?.zones ?? [];
+ const basePath = context.options[0]?.basePath ?? context.cwd;
+
+ const matchers = zones.map(zone => {
+ assert(zone.target != null, 'Zone missing `target`');
+ assert(zone.from != null, 'Zone missing `from`');
+ const zoneTarget = toArray(zone.target);
+ const zoneFrom = toArray(zone.from);
+ assert(zoneTarget.length > 0, 'Zone needs at least one `target`');
+ assert(zoneFrom.length > 0, 'Zone needs at least one `from`');
+
+ let zoneExcept = zone.except != null ? toArray(zone.except) : null;
+ if (zoneExcept?.length === 0) {
+ zoneExcept = null;
+ }
+
+ let hasGlobPatterns = false;
+ let hasNonGlobPatterns = false;
+
+ /** @param {string} target */
+ function compilePattern(target) {
+ const targetPath = path.resolve(basePath, target);
+
+ const cached = REGEX_CACHE.get(targetPath);
+ if (cached != null) {
+ return cached;
+ }
+
+ /** @type {RegExp | string} */
+ let result;
+ if (isGlob(targetPath)) {
+ hasGlobPatterns = true;
+ result = micromatch.makeRe(targetPath);
+ } else {
+ hasNonGlobPatterns = true;
+ result = targetPath;
+ }
+
+ if (hasGlobPatterns && hasNonGlobPatterns) {
+ throw new Error(
+ 'Cannot have both glob and non-glob patterns in the same zone'
+ );
+ }
+
+ REGEX_CACHE.set(targetPath, result);
+ return result;
+ }
+
+ /** @type {Matcher} */
+ const matcher = {
+ targetPaths: zoneTarget.map(target => compilePattern(target)),
+ fromPaths: zoneFrom.map(from => compilePattern(from)),
+ exceptPaths: zoneExcept?.map(except => compilePattern(except)) ?? null,
+ message: zone.message ?? null,
+ };
+
+ return matcher;
+ });
+
+ const targetMatchers = matchers.filter(matcher => {
+ return matcher.targetPaths.some(targetPath => {
+ return isMatchingTargetPath(filename, targetPath);
+ });
+ });
+
+ if (targetMatchers.length === 0) {
+ return {};
+ }
+
+ return createImportSourceVisitor(sourceCode, source => {
+ const dirname = path.dirname(filename);
+ const moduleName = source.value;
+
+ const resolvedPath = resolveFrom(dirname, moduleName);
+ if (resolvedPath == null) {
+ return;
+ }
+
+ for (const matcher of targetMatchers) {
+ const matchesFromPath = matcher.fromPaths.some(fromPath => {
+ return isMatchingTargetPath(resolvedPath, fromPath);
+ });
+
+ if (!matchesFromPath) {
+ continue;
+ }
+
+ const matchesExceptPath = matcher.exceptPaths?.some(exceptPath => {
+ return isMatchingTargetPath(resolvedPath, exceptPath);
+ });
+
+ if (matchesExceptPath) {
+ continue;
+ }
+
+ if (matcher.message != null) {
+ context.report({
+ node: source,
+ messageId: 'pathRestrictedWithMessage',
+ data: { moduleName, message: matcher.message },
+ });
+ } else {
+ context.report({
+ node: source,
+ messageId: 'pathRestrictedNoMessage',
+ data: { moduleName },
+ });
+ }
+ }
+ });
+ },
+});
diff --git a/.oxlint/rules/noRestrictedPaths.test.mjs b/.oxlint/rules/noRestrictedPaths.test.mjs
new file mode 100644
index 0000000000..11c51dff51
--- /dev/null
+++ b/.oxlint/rules/noRestrictedPaths.test.mjs
@@ -0,0 +1,54 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { noRestrictedPaths } from './noRestrictedPaths.mjs';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+import * as path from 'node:path';
+
+const basePath = path.join(import.meta.dirname, 'fixtures/noRestrictedPaths');
+const filename = path.join(basePath, 'client/entry.ts');
+
+/**
+ * @param {boolean=} withMessage
+ * @returns {[import("./noRestrictedPaths.mjs").Options]}
+ */
+function opts(withMessage) {
+ const message = withMessage ? 'just stop it' : undefined;
+ return [
+ { basePath, zones: [{ target: './client', from: './server', message }] },
+ ];
+}
+
+const ruleTester = new RuleTester();
+
+ruleTester.run('no-restricted-paths', noRestrictedPaths, {
+ valid: [
+ { filename, options: opts(), code: `import b from './client.ts';` },
+ { filename, options: opts(), code: `import b from './client.js';` },
+ { filename, options: opts(), code: `import b from '../client/client.ts';` },
+ { filename, options: opts(), code: `import b from './nonexistant';` },
+ { filename, options: opts(), code: `import b from 'node:path';` },
+ { filename, options: opts(), code: `import b from 'react';` },
+ { filename, options: opts(), code: `import b from 'fake-module';` },
+ ],
+ invalid: [
+ {
+ filename,
+ options: opts(),
+ code: `import b from '../server/server.ts';`,
+ errors: [{ messageId: 'pathRestrictedNoMessage' }],
+ },
+ {
+ filename,
+ options: opts(),
+ code: `import b from '../server/server.js';`,
+ errors: [{ messageId: 'pathRestrictedNoMessage' }],
+ },
+ {
+ filename,
+ options: opts(true),
+ code: `import b from '../server/server.ts';`,
+ errors: [{ messageId: 'pathRestrictedWithMessage' }],
+ },
+ ],
+});
diff --git a/.oxlint/rules/noThen.mjs b/.oxlint/rules/noThen.mjs
new file mode 100644
index 0000000000..fa3954b0a9
--- /dev/null
+++ b/.oxlint/rules/noThen.mjs
@@ -0,0 +1,35 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+import { ESLintUtils } from '@typescript-eslint/utils';
+import { isPropertyAccess } from './utils/astUtils.mjs';
+
+export const noThen = ESLintUtils.RuleCreator.withoutDocs({
+ name: 'no-then',
+ meta: {
+ type: 'problem',
+ messages: {
+ preferAwait: 'Prefer await instead of .then()',
+ },
+ schema: [],
+ defaultOptions: [],
+ },
+ create(context) {
+ return {
+ MemberExpression(node) {
+ if (!isPropertyAccess(node, 'then')) {
+ return;
+ }
+
+ if (node.parent.type !== 'CallExpression') {
+ return;
+ }
+
+ context.report({
+ node: node.property,
+ messageId: 'preferAwait',
+ });
+ },
+ };
+ },
+});
diff --git a/.oxlint/rules/utils/assert.mjs b/.oxlint/rules/utils/assert.mjs
new file mode 100644
index 0000000000..6f719db8c2
--- /dev/null
+++ b/.oxlint/rules/utils/assert.mjs
@@ -0,0 +1,14 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+
+/**
+ * @param condition {unknown}
+ * @param message {string}
+ * @returns {asserts condition}
+ */
+export function assert(condition, message) {
+ if (condition == null || condition === false) {
+ throw new TypeError(message);
+ }
+}
diff --git a/.oxlint/rules/utils/astUtils.mjs b/.oxlint/rules/utils/astUtils.mjs
new file mode 100644
index 0000000000..b483192331
--- /dev/null
+++ b/.oxlint/rules/utils/astUtils.mjs
@@ -0,0 +1,36 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+
+/**
+ * @typedef {import("@typescript-eslint/utils").TSESTree.Node} Node
+ * @typedef {import("@typescript-eslint/utils").TSESTree.Literal} Literal
+ * @typedef {import("@typescript-eslint/utils").TSESTree.StringLiteral} StringLiteral
+ * @typedef {import("@typescript-eslint/utils").TSESTree.Identifier} Identifier
+ * @typedef {import("@typescript-eslint/utils").TSESTree.MemberExpression} MemberExpression
+ */
+
+/**
+ * @param {Node=} node
+ * @returns {node is StringLiteral}
+ */
+export function isStringLiteral(node) {
+ return node?.type === 'Literal' && typeof node.value === 'string';
+}
+
+/**
+ * @param {Node | null | undefined} node
+ * @param {string} property
+ * @returns {node is MemberExpression}
+ */
+export function isPropertyAccess(node, property) {
+ if (node?.type !== 'MemberExpression') {
+ return false;
+ }
+
+ if (node.computed) {
+ return node.property.type === 'Literal' && node.property.value === property;
+ }
+
+ return node.property.type === 'Identifier' && node.property.name === property;
+}
diff --git a/.oxlint/rules/utils/createImportSourceVisitor.mjs b/.oxlint/rules/utils/createImportSourceVisitor.mjs
new file mode 100644
index 0000000000..68788b3c04
--- /dev/null
+++ b/.oxlint/rules/utils/createImportSourceVisitor.mjs
@@ -0,0 +1,123 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+
+import { getReferenceType } from './getReferenceType.mjs';
+import { isStringLiteral } from './astUtils.mjs';
+
+/**
+ * @typedef {import("@typescript-eslint/utils").TSESTree.StringLiteral} StringLiteral
+ * @typedef {import("@typescript-eslint/utils").TSESLint.SourceCode} SourceCode
+ * @typedef {import("@typescript-eslint/utils").TSESLint.RuleListener} RuleListener
+ */
+
+/**
+ * @param {SourceCode} sourceCode
+ * @param {(source: StringLiteral) => void} visitSource
+ * @returns {RuleListener}
+ */
+export function createImportSourceVisitor(sourceCode, visitSource) {
+ return {
+ // import ... from ''
+ ImportDeclaration(node) {
+ visitSource(node.source);
+ },
+ // import('')
+ ImportExpression(node) {
+ if (!isStringLiteral(node.source)) {
+ return;
+ }
+ visitSource(node.source);
+ },
+ CallExpression(node) {
+ // require('')
+ if (node.callee.type === 'Identifier') {
+ if (node.callee.name !== 'require') {
+ return;
+ }
+ const refType = getReferenceType(sourceCode, node.callee);
+ if (refType != null && refType !== 'global') {
+ return;
+ }
+
+ const arg = node.arguments.at(0);
+ if (!isStringLiteral(arg)) {
+ return;
+ }
+
+ visitSource(arg);
+ return;
+ }
+
+ // require.resolve('')
+ if (node.callee.type === 'MemberExpression') {
+ const { object, property } = node.callee;
+
+ if (object.type !== 'Identifier') {
+ return;
+ }
+ if (object.name !== 'require') {
+ return;
+ }
+ const refType = getReferenceType(sourceCode, object);
+ if (refType != null && refType !== 'global') {
+ return;
+ }
+
+ if (property.type !== 'Identifier') {
+ return;
+ }
+ if (property.name !== 'resolve') {
+ return;
+ }
+
+ const arg = node.arguments.at(0);
+ if (!isStringLiteral(arg)) {
+ return;
+ }
+
+ visitSource(arg);
+ }
+ },
+ // import.meta.resolve('')
+ MetaProperty(node) {
+ if (node.meta.name !== 'import') {
+ return;
+ }
+ if (node.property.name !== 'meta') {
+ return;
+ }
+ const memberExpression = node.parent;
+ if (memberExpression.type !== 'MemberExpression') {
+ return;
+ }
+ if (memberExpression.property.type !== 'Identifier') {
+ return;
+ }
+ if (memberExpression.property.name !== 'resolve') {
+ return;
+ }
+ const callExpression = memberExpression.parent;
+ if (callExpression.type !== 'CallExpression') {
+ return;
+ }
+ const arg = callExpression.arguments.at(0);
+ if (!isStringLiteral(arg)) {
+ return;
+ }
+
+ visitSource(arg);
+ },
+ // export {...} from ''
+ ExportNamedDeclaration(node) {
+ if (node.source == null) {
+ return;
+ }
+ visitSource(node.source);
+ },
+ // export * ... from ''
+ ExportAllDeclaration(node) {
+ visitSource(node.source);
+ },
+ };
+}
diff --git a/.oxlint/rules/utils/getReferenceType.mjs b/.oxlint/rules/utils/getReferenceType.mjs
new file mode 100644
index 0000000000..b0d463cea8
--- /dev/null
+++ b/.oxlint/rules/utils/getReferenceType.mjs
@@ -0,0 +1,18 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+// @ts-check
+
+/**
+ * @typedef {import("@typescript-eslint/utils").TSESTree.Identifier} Identifier
+ * @typedef {import("@typescript-eslint/utils").TSESLint.SourceCode} SourceCode
+ */
+
+/**
+ * @param {SourceCode} sourceCode
+ * @param {Identifier} node
+ */
+export function getReferenceType(sourceCode, node) {
+ const scope = sourceCode.getScope(node);
+ const ref = scope.references.find(r => r.identifier === node);
+ return ref?.resolved?.scope.type ?? null;
+}
diff --git a/.oxlint/test-setup.mjs b/.oxlint/test-setup.mjs
new file mode 100644
index 0000000000..351e313291
--- /dev/null
+++ b/.oxlint/test-setup.mjs
@@ -0,0 +1,6 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+import * as mocha from 'mocha';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+
+RuleTester.afterAll = mocha.after;
diff --git a/.oxlintrc.json b/.oxlintrc.json
new file mode 100644
index 0000000000..260695ef9f
--- /dev/null
+++ b/.oxlintrc.json
@@ -0,0 +1,1816 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "options": {
+ "typeAware": true,
+ "reportUnusedDisableDirectives": "error"
+ },
+ "plugins": [
+ "eslint",
+ "import",
+ // "jsdoc",
+ // "jest",
+ "jsx-a11y",
+ // "nextjs",
+ "node",
+ "oxc",
+ "promise",
+ "react",
+ "react-perf",
+ "typescript",
+ "unicorn"
+ // "vitest",
+ // "vue"
+ ],
+ "jsPlugins": ["./.oxlint/plugin.mjs"],
+ "categories": {
+ "correctness": "warn",
+ "nursery": "warn",
+ "pedantic": "warn",
+ "perf": "warn",
+ "restriction": "warn",
+ "style": "warn",
+ "suspicious": "warn"
+ },
+ "rules": {
+ // ------------------------------------------------------------------------
+ // Local Rules
+ // ------------------------------------------------------------------------
+
+ // [local] (🛠️ autofix)
+ "signal-desktop/enforce-array-buffer": "error",
+ // [local]
+ "signal-desktop/enforce-file-suffix": "error",
+ // [local] (🛠️ autofix)
+ "signal-desktop/enforce-license-comments": "error",
+ // [local]
+ "signal-desktop/enforce-tw": "error",
+ // [local]
+ "signal-desktop/enforce-type-alias-readonlydeep": "off", // [reason: Only enabled in specific files]
+ // [local] (💡 suggestion)
+ "signal-desktop/no-disabled-tests": "error",
+ // [local]
+ "signal-desktop/no-extraneous-dependencies": "error",
+ // [local] (🛠️ autofix)
+ "signal-desktop/no-focused-tests": "error",
+ // [local]
+ "signal-desktop/no-for-in": "error",
+ "signal-desktop/no-restricted-paths": [
+ "error",
+ {
+ "zones": [
+ {
+ "target": ["ts/util/**", "ts/types/**"],
+ "from": ["ts/components/**", "ts/axo/**/*.dom.*"],
+ "message": "Importing components is forbidden from ts/{util,types}"
+ }
+ ]
+ }
+ ],
+ // [local]
+ "signal-desktop/no-then": "error",
+
+ // ------------------------------------------------------------------------
+ // Category: Correctness
+ // ------------------------------------------------------------------------
+
+ // [correctness] (✅ recommended) Require super() calls in constructors
+ "eslint/constructor-super": "error",
+ // [correctness] (✅ recommended) (⚠️ 🛠️ dangerous autofix) Enforce for loop update clause moving the counter in the right direction
+ "eslint/for-direction": "error",
+ // [correctness] (✅ recommended) Disallow using an async function as a Promise executor
+ "eslint/no-async-promise-executor": "error",
+ // [correctness] (✅ recommended) Disallow the use of arguments.caller or arguments.callee
+ "eslint/no-caller": "error",
+ // [correctness] (✅ recommended) Disallow reassigning class members
+ "eslint/no-class-assign": "error",
+ // [correctness] (✅ recommended) (🛠️ autofix) (💡 suggestion) Disallow comparing against -0
+ "eslint/no-compare-neg-zero": "error",
+ // [correctness] (✅ recommended) Disallow assignment operators in conditional expressions
+ "eslint/no-cond-assign": ["error", "always"],
+ // [correctness] (✅ recommended) Disallow reassigning const, using, and await using variables
+ "eslint/no-const-assign": "error",
+ // [correctness] (✅ recommended) Disallow expressions where the operation doesn't affect the value
+ "eslint/no-constant-binary-expression": "off",
+ // [correctness] (✅ recommended) Disallow constant expressions in conditions
+ "eslint/no-constant-condition": [
+ "error",
+ {
+ "checkLoops": "all"
+ }
+ ],
+ // [correctness] (✅ recommended) Disallow control characters in regular expressions
+ "eslint/no-control-regex": "off",
+ // [correctness] (✅ recommended) (🛠️ autofix) Disallow the use of debugger
+ "eslint/no-debugger": "error",
+ // [correctness] (✅ recommended) Disallow deleting variables
+ "eslint/no-delete-var": "error",
+ // [correctness] (✅ recommended) Disallow duplicate class members
+ "eslint/no-dupe-class-members": "error",
+ // [correctness] (✅ recommended) Disallow duplicate conditions in if-else-if chains
+ "eslint/no-dupe-else-if": "error",
+ // [correctness] (✅ recommended) Disallow duplicate keys in object literals
+ "eslint/no-dupe-keys": "error",
+ // [correctness] (✅ recommended) Disallow duplicate case labels
+ "eslint/no-duplicate-case": "error",
+ // [correctness] (✅ recommended) Disallow empty character classes in regular expressions
+ "eslint/no-empty-character-class": "error",
+ // [correctness] (✅ recommended) Disallow empty destructuring patterns
+ "eslint/no-empty-pattern": "error",
+ // [correctness] (✅ recommended) (💡 suggestion) Disallow empty static blocks
+ "eslint/no-empty-static-block": "error",
+ // [correctness] (✅ recommended) Disallow the use of eval()
+ "eslint/no-eval": "error",
+ // [correctness] (✅ recommended) Disallow reassigning exceptions in catch clauses
+ "eslint/no-ex-assign": "error",
+ // [correctness] (✅ recommended) (🛠️ autofix) (💡 suggestion) Disallow unnecessary boolean casts
+ "eslint/no-extra-boolean-cast": "error",
+ // [correctness] (✅ recommended) Disallow reassigning function declarations
+ "eslint/no-func-assign": "error",
+ // [correctness] (✅ recommended) Disallow assignments to native objects or read-only global variables
+ "eslint/no-global-assign": "error",
+ // [correctness] (✅ recommended) Disallow assigning to imported bindings
+ "eslint/no-import-assign": "error",
+ // [correctness] (✅ recommended) Disallow invalid regular expression strings in RegExp constructors
+ "eslint/no-invalid-regexp": "error",
+ // [correctness] (✅ recommended) Disallow irregular whitespace
+ "eslint/no-irregular-whitespace": "error",
+ // [correctness] (✅ recommended) (💡 suggestion) Disallow the use of the __iterator__ property
+ "eslint/no-iterator": "error",
+ // [correctness] (✅ recommended) Disallow literal numbers that lose precision
+ "eslint/no-loss-of-precision": "error",
+ // [correctness] (✅ recommended) (🚧 planned autofix) Disallow characters which are made with multiple code points in character class syntax
+ "eslint/no-misleading-character-class": "error",
+ // [correctness] (✅ recommended) Disallow new operators with global non-constructor functions
+ "eslint/no-new-native-nonconstructor": "error",
+ // [correctness] (✅ recommended) (💡 suggestion) Disallow \8 and \9 escape sequences in string literals
+ "eslint/no-nonoctal-decimal-escape": "error",
+ // [correctness] (✅ recommended) Disallow calling global object properties as functions
+ "eslint/no-obj-calls": "error",
+ // [correctness] (✅ recommended) Disallow assignments where both sides are exactly the same
+ "eslint/no-self-assign": "error",
+ // [correctness] (✅ recommended) Disallow returning values from setters
+ "eslint/no-setter-return": "error",
+ // [correctness] (✅ recommended) Disallow identifiers from shadowing restricted names
+ "eslint/no-shadow-restricted-names": "error",
+ // [correctness] (✅ recommended) Disallow sparse arrays
+ "eslint/no-sparse-arrays": "error",
+ // [correctness] (✅ recommended) Disallow this/super before calling super() in constructors
+ "eslint/no-this-before-super": "error",
+ // [correctness] (✅ recommended) Disallow let or var variables that are read but never assigned
+ "eslint/no-unassigned-vars": "error",
+ // [correctness] (✅ recommended) Disallow control flow statements in finally blocks
+ "eslint/no-unsafe-finally": "error",
+ // [correctness] (✅ recommended) (🛠️ autofix) Disallow negating the left operand of relational operators
+ "eslint/no-unsafe-negation": "error",
+ // [correctness] (✅ recommended) Disallow use of optional chaining in contexts where the undefined value is not allowed
+ "eslint/no-unsafe-optional-chaining": "error",
+ // [correctness] (✅ recommended) Disallow unused expressions
+ "eslint/no-unused-expressions": "error",
+ // [correctness] (✅ recommended) (🛠️ autofix) Disallow unused labels
+ "eslint/no-unused-labels": "error",
+ // [correctness] (✅ recommended) Disallow unused private class members
+ "eslint/no-unused-private-class-members": "off",
+ // [correctness] (✅ recommended) ⚠️ 🛠 (💡 suggestion) Disallow unused variables
+ "eslint/no-unused-vars": [
+ "error", // [reason: FILLMEIN]
+ { "argsIgnorePattern": "^_", "caughtErrors": "none" }
+ ],
+ // [correctness] (✅ recommended) Disallow useless backreferences in regular expressions
+ "eslint/no-useless-backreference": "error",
+ // [correctness] (✅ recommended) Disallow unnecessary catch clauses
+ "eslint/no-useless-catch": "error",
+ // [correctness] (✅ recommended) (🛠️ autofix) Disallow unnecessary escape characters
+ "eslint/no-useless-escape": "off",
+ // [correctness] (✅ recommended) (🛠️ autofix) Disallow renaming import, export, and destructured assignments to the same name
+ "eslint/no-useless-rename": "error",
+ // [correctness] (✅ recommended) Disallow with statements
+ "eslint/no-with": "error",
+ // [correctness] (✅ recommended) Require generator functions to contain yield
+ "eslint/require-yield": "error",
+ // [correctness] (✅ recommended) (🛠️ autofix) Require calls to isNaN() when checking for NaN
+ "eslint/use-isnan": "error",
+ // [correctness] (✅ recommended) (🛠️ autofix) Enforce comparing typeof expressions against valid strings
+ "eslint/valid-typeof": [
+ "error",
+ {
+ "requireStringLiterals": true
+ }
+ ],
+ // [correctness] Ensure a default export is present, given a default import.
+ "import/default": "error",
+ // [correctness] Ensure imported namespaces contain dereferenced properties as they are dereferenced.
+ "import/namespace": "error",
+ // [correctness] Enforce assertion to be made in a test body
+ // "jest/expect-expect": "error",
+ // [correctness] Disallow calling expect conditionally
+ // "jest/no-conditional-expect": "error",
+ // [correctness] Disallow disabled tests
+ // "jest/no-disabled-tests": "error",
+ // [correctness] Disallow using exports in files containing tests
+ // "jest/no-export": "error",
+ // [correctness] (🛠️ autofix) Disallow focused tests
+ // "jest/no-focused-tests": "error",
+ // [correctness] Disallow using expect outside of it or test blocks
+ // "jest/no-standalone-expect": "error",
+ // [correctness] Require a message for toThrow()
+ // "jest/require-to-throw-message": "error",
+ // [correctness] Enforce valid describe() callback
+ // "jest/valid-describe-callback": "error",
+ // [correctness] (🚧 planned autofix) Enforce valid expect() usage
+ // "jest/valid-expect": "error",
+ // [correctness] (🛠️ autofix) Enforce valid titles
+ // "jest/valid-title": "error",
+ // [correctness] (🚧 planned autofix) Ensures that property names in JSDoc are not duplicated on the same block and that nested properties have defined roots.
+ // "jsdoc/check-property-names": "error",
+ // [correctness] (🚧 planned autofix) Reports invalid block tag names.
+ // "jsdoc/check-tag-names": "error",
+ // [correctness] Prohibits use of @implements on non-constructor functions (to enforce the tag only being used on classes/constructors).
+ // "jsdoc/implements-on-classes": "error",
+ // [correctness] (🚧 planned autofix) This rule reports defaults being used on the relevant portion of @param or @default.
+ // "jsdoc/no-defaults": "error",
+ // [correctness] (🚧 planned autofix) Requires that all @typedef and @namespace tags have @property when their type is a plain object, Object, or PlainObject.
+ // "jsdoc/require-property": "error",
+ // [correctness] Requires that each @property tag has a description value.
+ // "jsdoc/require-property-description": "error",
+ // [correctness] Requires that all @property tags have names.
+ // "jsdoc/require-property-name": "error",
+ // [correctness] Requires that each @property tag has a type value (in curly brackets).
+ // "jsdoc/require-property-type": "error",
+ // [correctness] Requires yields are documented with @yields tags.
+ // "jsdoc/require-yields": "error",
+ // [correctness] Enforce all elements that require alternative text have meaningful information to relay back to end user.
+ "jsx-a11y/alt-text": "error",
+ // [correctness] (💡 suggestion) Enforce all anchors to contain accessible content.
+ "jsx-a11y/anchor-has-content": "error",
+ // [correctness] Enforce all anchors are valid, navigable elements.
+ "jsx-a11y/anchor-is-valid": "error",
+ // [correctness] Enforce elements with aria-activedescendant are tabbable.
+ "jsx-a11y/aria-activedescendent-has-tabindex": "error",
+ // [correctness] (🛠️ autofix) Enforce all aria-* props are valid.
+ "jsx-a11y/aria-props": "error",
+ // [correctness] Enforce ARIA state and property values are valid.
+ "jsx-a11y/aria-proptypes": "off",
+ // [correctness] Enforce that elements with ARIA roles must use a valid, non-abstract ARIA role.
+ "jsx-a11y/aria-role": "error",
+ // [correctness] (🛠️ autofix) Enforce that elements that do not support ARIA roles, states, and properties do not have those attributes.
+ "jsx-a11y/aria-unsupported-elements": "error",
+ // [correctness] Enforce that autocomplete attributes are used correctly.
+ "jsx-a11y/autocomplete-valid": "error",
+ // [correctness] Enforce a clickable non-interactive element has at least one keyboard event listener.
+ "jsx-a11y/click-events-have-key-events": "error",
+ // [correctness] Enforce heading (h1, h2, etc) elements contain accessible content.
+ "jsx-a11y/heading-has-content": "error",
+ // [correctness] Enforce element has lang prop.
+ "jsx-a11y/html-has-lang": "error",
+ // [correctness] Enforce iframe elements have a title attribute.
+ "jsx-a11y/iframe-has-title": "error",
+ // [correctness] Enforce
alt prop does not contain the word "image", "picture", or "photo".
+ "jsx-a11y/img-redundant-alt": "error",
+ // [correctness] Enforce that a label tag has a text label and an associated control.
+ "jsx-a11y/label-has-associated-control": "error",
+ // [correctness] Enforce lang attribute has a valid value.
+ "jsx-a11y/lang": "error",
+ // [correctness] Enforces that