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: `
` }, - { code: `tw("flex")` }, - ], - invalid: [ - { code: `classNames("flex")`, errors: [{ message }] }, - { code: `
`, errors: [{ message }] }, - { 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