From 8cdf3430d1b4d61a0cc5eb1b03b2714b60a93bc9 Mon Sep 17 00:00:00 2001 From: Nicolas Morel Date: Wed, 1 Apr 2026 23:17:54 +0200 Subject: [PATCH 1/4] feat: initial implementation of oxc rules --- .github/workflows/ci-module.yml | 13 + .gitignore | 14 + API.md | 125 +++++ LICENSE.md | 11 + README.md | 17 + oxfmt.config.ts | 8 + oxlint.config.ts | 15 + package.json | 65 +++ src/configs/oxfmt.config.d.ts | 5 + src/configs/oxfmt.config.js | 36 ++ src/configs/recommended.d.ts | 5 + src/configs/recommended.js | 76 ++++ src/index.d.ts | 5 + src/index.js | 20 + src/rules/capitalize-modules.js | 55 +++ src/rules/for-loop.js | 104 +++++ src/rules/no-arrowception.js | 29 ++ src/rules/no-var.js | 73 +++ src/rules/scope-start.js | 97 ++++ src/vitest.d.ts | 27 ++ src/vitest.js | 22 + src/vitest.test.js | 64 +++ test/configs/__snapshots__/oxfmt.js.snap | 106 +++++ .../configs/__snapshots__/recommended.js.snap | 106 +++++ test/configs/fixtures/arrow-parens.js | 10 + test/configs/fixtures/arrow-spacing.js | 15 + test/configs/fixtures/brace-style.js | 17 + test/configs/fixtures/camelcase.js | 4 + test/configs/fixtures/handle-callback-err.js | 28 ++ .../fixtures/hapi-capitalize-modules.js | 12 + test/configs/fixtures/hapi-for-you.js | 7 + test/configs/fixtures/hapi-scope-start.js | 20 + test/configs/fixtures/indent-switch-case.js | 17 + test/configs/fixtures/indent.js | 10 + test/configs/fixtures/key-spacing.js | 5 + test/configs/fixtures/no-arrowception.js | 3 + .../configs/fixtures/no-constant-condition.js | 3 + test/configs/fixtures/no-dupe-keys.js | 6 + test/configs/fixtures/no-extra-semi.js | 12 + test/configs/fixtures/no-shadow.js | 34 ++ test/configs/fixtures/no-undef.js | 9 + test/configs/fixtures/no-unsafe-finally.js | 13 + test/configs/fixtures/no-unused-vars.js | 7 + .../fixtures/no-useless-computed-key.js | 5 + test/configs/fixtures/no-var.js | 7 + test/configs/fixtures/node-env.js | 3 + test/configs/fixtures/object-shorthand.js | 12 + test/configs/fixtures/one-var.js | 7 + .../configs/fixtures/prefer-arrow-callback.js | 23 + test/configs/fixtures/prefer-const.js | 10 + test/configs/fixtures/private-class-field.js | 5 + test/configs/fixtures/semi.js | 9 + test/configs/fixtures/space-before-blocks.js | 11 + .../fixtures/space-before-function-paren.js | 17 + test/configs/oxfmt.js | 43 ++ test/configs/oxfmt.test.config.ts | 7 + test/configs/oxlint.test.config.ts | 10 + test/configs/recommended.js | 426 ++++++++++++++++++ test/index.js | 19 + test/rules/capitalize-modules.js | 83 ++++ test/rules/for-loop.js | 139 ++++++ test/rules/no-arrowception.js | 35 ++ test/rules/no-var.js | 47 ++ test/rules/scope-start.js | 249 ++++++++++ test/vitest.js | 59 +++ vitest.config.js | 26 ++ 66 files changed, 2582 insertions(+) create mode 100644 .github/workflows/ci-module.yml create mode 100644 .gitignore create mode 100644 API.md create mode 100755 LICENSE.md create mode 100755 README.md create mode 100644 oxfmt.config.ts create mode 100644 oxlint.config.ts create mode 100644 package.json create mode 100644 src/configs/oxfmt.config.d.ts create mode 100644 src/configs/oxfmt.config.js create mode 100644 src/configs/recommended.d.ts create mode 100644 src/configs/recommended.js create mode 100644 src/index.d.ts create mode 100755 src/index.js create mode 100644 src/rules/capitalize-modules.js create mode 100644 src/rules/for-loop.js create mode 100644 src/rules/no-arrowception.js create mode 100644 src/rules/no-var.js create mode 100644 src/rules/scope-start.js create mode 100644 src/vitest.d.ts create mode 100644 src/vitest.js create mode 100644 src/vitest.test.js create mode 100644 test/configs/__snapshots__/oxfmt.js.snap create mode 100644 test/configs/__snapshots__/recommended.js.snap create mode 100644 test/configs/fixtures/arrow-parens.js create mode 100644 test/configs/fixtures/arrow-spacing.js create mode 100644 test/configs/fixtures/brace-style.js create mode 100644 test/configs/fixtures/camelcase.js create mode 100644 test/configs/fixtures/handle-callback-err.js create mode 100644 test/configs/fixtures/hapi-capitalize-modules.js create mode 100644 test/configs/fixtures/hapi-for-you.js create mode 100644 test/configs/fixtures/hapi-scope-start.js create mode 100644 test/configs/fixtures/indent-switch-case.js create mode 100644 test/configs/fixtures/indent.js create mode 100755 test/configs/fixtures/key-spacing.js create mode 100644 test/configs/fixtures/no-arrowception.js create mode 100644 test/configs/fixtures/no-constant-condition.js create mode 100644 test/configs/fixtures/no-dupe-keys.js create mode 100644 test/configs/fixtures/no-extra-semi.js create mode 100644 test/configs/fixtures/no-shadow.js create mode 100644 test/configs/fixtures/no-undef.js create mode 100644 test/configs/fixtures/no-unsafe-finally.js create mode 100644 test/configs/fixtures/no-unused-vars.js create mode 100644 test/configs/fixtures/no-useless-computed-key.js create mode 100644 test/configs/fixtures/no-var.js create mode 100644 test/configs/fixtures/node-env.js create mode 100644 test/configs/fixtures/object-shorthand.js create mode 100644 test/configs/fixtures/one-var.js create mode 100644 test/configs/fixtures/prefer-arrow-callback.js create mode 100644 test/configs/fixtures/prefer-const.js create mode 100644 test/configs/fixtures/private-class-field.js create mode 100644 test/configs/fixtures/semi.js create mode 100755 test/configs/fixtures/space-before-blocks.js create mode 100644 test/configs/fixtures/space-before-function-paren.js create mode 100644 test/configs/oxfmt.js create mode 100644 test/configs/oxfmt.test.config.ts create mode 100644 test/configs/oxlint.test.config.ts create mode 100644 test/configs/recommended.js create mode 100755 test/index.js create mode 100644 test/rules/capitalize-modules.js create mode 100644 test/rules/for-loop.js create mode 100644 test/rules/no-arrowception.js create mode 100644 test/rules/no-var.js create mode 100644 test/rules/scope-start.js create mode 100644 test/vitest.js create mode 100644 vitest.config.js diff --git a/.github/workflows/ci-module.yml b/.github/workflows/ci-module.yml new file mode 100644 index 0000000..49bcabb --- /dev/null +++ b/.github/workflows/ci-module.yml @@ -0,0 +1,13 @@ +name: ci + +on: + push: + branches: + - master + - next + pull_request: + workflow_dispatch: + +jobs: + test: + uses: hapijs/.github/.github/workflows/ci-module.yml@min-node-22-hapi-21 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cba0217 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +**/node_modules +**/package-lock.json +pnpm-lock.yaml + +coverage + +**/.DS_Store +**/._* + +**/*.pem + +**/.vs +**/.vscode +**/.idea diff --git a/API.md b/API.md new file mode 100644 index 0000000..d8d2805 --- /dev/null +++ b/API.md @@ -0,0 +1,125 @@ +## Configurations + +### `@hapi/oxc-plugin/oxlint` + +The oxlint configuration containing hapi style guide rules and config. To use in your project, add +[`@hapi/oxc-plugin`](https://github.com/hapijs/oxc-plugin) to your `package.json`, then in your `oxlint.config.ts` add: + +```ts +import { defineConfig } from 'oxlint'; +import DefaultOxlintConfig from '@hapi/oxc-plugin/oxlint'; + +export default defineConfig({ + extends: [DefaultOxlintConfig], + env: { + ...DefaultOxlintConfig.env, + }, +}); +``` + +### `@hapi/oxc-plugin/oxfmt` + +The [oxfmt](https://oxc.rs/docs/guide/usage/formatter/oxfmt.html) configuration containing hapi style guide formatting rules. To use in your project, add +[`@hapi/oxc-plugin`](https://github.com/hapijs/oxc-plugin) to your `package.json`, then in your `oxfmt.config.ts` add: + +```ts +import { defineConfig } from 'oxfmt'; +import DefaultOxfmtConfig from '@hapi/oxc-plugin/oxfmt'; + +export default defineConfig({ + ...DefaultOxfmtConfig, +}); +``` + +## Rules + +This plugin includes the following Oxlint rules: + +### capitalize-modules + +Enforces capitalization of imported module variables. + +#### `global-scope-only` + +If the string `'global-scope-only'` is passed as the first option to this rule, +then it will only be enforced on assignments in the module's top level scope. + +### for-loop + +Enforces `for` loop iterator variable rules and restricts loop nesting depth. + +This rule enforces the following: + +- Restrict iterator variable names. `for` loop iterator variables should be named `i`. Nested loops should use the variables `j`, `k`, and so on. +- Restrict loop nesting. You can restrict the maximum nesting of `for` loops. By default, this limit is three. +- Prevent postfix increment and decrement operators. The hapi style guide does not allow postfix increment and decrement operators in `for` loop updates. The prefix version of these operators should be used instead. +- Single variable declaration in initialization section. A single `var i = 0;` is allowed in the initialization section. This only applies to variable declarations, not assignments to existing variables. This means that `for (i = 0, j = 0)` is allowed if `i` and `j` are existing variables. Variable declarations involving destructuring are not allowed. + +#### Rule options + +This rule can be configured by providing a single options object. The object supports the following keys. + +##### `maxDepth` + +A number representing the maximum allowed nesting of `for` loops. Defaults to three. + +##### `startIterator` + +The first variable iterator name to use. This defaults to `'i'`. + +### no-var + +Enforces the usage of var declarations only in try-catch scope. + +### scope-start + +Enforces a new line at the beginning of function scope. + +_Note: This rule is currently disabled in the recommended configuration due to a conflict with oxfmt._ + +### no-arrowception + +Prevents arrow functions that implicitly create additional arrow functions. + +This rule prevents the pattern () => () => () => ...;. + +Functions can still be returned by arrow functions whose bodies use curly braces and an explicit return. + +This rule does not accept any configuration options. + +## Vitest plugin + +This plugin also exposes a [Vitest](https://vitest.dev) plugin at `@hapi/oxc-plugin/vitest`. It runs +the oxlint and oxfmt checks as a dedicated test project, so linting and formatting violations surface +as failing tests in your existing test run. + +To use it, add [`@hapi/oxc-plugin`](https://github.com/hapijs/oxc-plugin) to your `package.json`, then +in your `vitest.config.js` add: + +```js +import { defineConfig } from 'vitest/config'; +import oxc from '@hapi/oxc-plugin/vitest'; + +export default defineConfig({ + plugins: [oxc()], +}); +``` + +When run, the plugin checks for an existing oxlint or oxfmt configuration in the working directory. If +none is found, it falls back to the configurations shipped by this plugin. + +### Options + +The plugin accepts a single options object with the following keys. + +#### `oxlint` + +A boolean controlling whether the oxlint check runs. Defaults to `true`. + +#### `oxfmt` + +A boolean controlling whether the oxfmt check runs. Defaults to `true`. + +#### `cwd` + +The working directory the checks run against. Defaults to `process.cwd()`. diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 0000000..060bbf0 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,11 @@ +Copyright (c) 2019-2020, Sideway Inc, and project contributors +Copyright (c) 2015-2019 Continuation Labs and Colin J. Ihrig +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +- The names of any contributors may not be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS OFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100755 index 0000000..657417e --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ + + +# @hapi/oxc-plugin + +#### Oxlint & Oxfmt plugin containing hapi style guide rules and config. + +**oxc-plugin** is part of the **hapi** ecosystem and was designed to work seamlessly with the [hapi web framework](https://hapi.dev) and its other components (but works great on its own or with other frameworks). If you are using a different web framework and find this module useful, check out [hapi](https://hapi.dev) – they work even better together. + +### Visit the [hapi.dev](https://hapi.dev) Developer Portal for tutorials, documentation, and support + +## Useful resources + +- [Documentation and API](https://hapi.dev/family/oxc-plugin/) +- [Version status](https://hapi.dev/resources/status/#oxc-plugin) (builds, dependencies, node versions, licenses, eol) +- [Changelog](https://hapi.dev/family/oxc-plugin/changelog/) +- [Project policies](https://hapi.dev/policies/) +- [Free and commercial support options](https://hapi.dev/support/) diff --git a/oxfmt.config.ts b/oxfmt.config.ts new file mode 100644 index 0000000..550241b --- /dev/null +++ b/oxfmt.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'oxfmt'; + +import DefaultOxfmtConfig from './src/configs/oxfmt.config.js'; + +export default defineConfig({ + ...DefaultOxfmtConfig, + ignorePatterns: ['test/configs/fixtures/**'], +}); diff --git a/oxlint.config.ts b/oxlint.config.ts new file mode 100644 index 0000000..18affe6 --- /dev/null +++ b/oxlint.config.ts @@ -0,0 +1,15 @@ +import HapiRecommended from '@hapi/oxc-plugin/oxlint'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [HapiRecommended], + env: { + ...HapiRecommended.env, + }, + globals: { + __HAPI_OXC_OXLINT__: 'readonly', + __HAPI_OXC_OXFMT__: 'readonly', + __HAPI_OXC_CWD__: 'readonly', + }, + ignorePatterns: ['test/configs/fixtures/**'], +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..149d49f --- /dev/null +++ b/package.json @@ -0,0 +1,65 @@ +{ + "name": "@hapi/oxc-plugin", + "version": "1.0.0", + "description": "ESLint plugin containing hapi style guide rules and config", + "keywords": [ + "hapi", + "lint", + "oxfmt", + "oxlint" + ], + "license": "BSD-3-Clause", + "repository": { + "type": "git", + "url": "git+https://github.com/hapijs/oxc-plugin.git" + }, + "files": [ + "src" + ], + "type": "module", + "main": "src/index.js", + "types": "src/index.d.ts", + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./src/index.js" + }, + "./oxlint": { + "types": "./src/configs/recommended.d.ts", + "import": "./src/configs/recommended.js" + }, + "./oxfmt": { + "types": "./src/configs/oxfmt.config.d.ts", + "import": "./src/configs/oxfmt.config.js" + }, + "./vitest": { + "types": "./src/vitest.d.ts", + "import": "./src/vitest.js" + } + }, + "scripts": { + "test": "vitest run", + "lint": "oxlint && oxfmt --check", + "lint:fix": "oxlint --fix && oxfmt --write" + }, + "dependencies": { + "@oxlint/plugins": "1.68.0", + "globals": "^17.6.0", + "oxlint-plugin-eslint": "1.68.0" + }, + "devDependencies": { + "@hapi/oxc-plugin": "file:.", + "@vitest/coverage-v8": "^4.1.8", + "oxfmt": "^0.53.0", + "oxlint": "^1.68.0", + "vitest": "^4.1.8" + }, + "peerDependencies": { + "oxfmt": "0.46.0", + "oxlint": "1.61.0", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=22" + } +} diff --git a/src/configs/oxfmt.config.d.ts b/src/configs/oxfmt.config.d.ts new file mode 100644 index 0000000..212458e --- /dev/null +++ b/src/configs/oxfmt.config.d.ts @@ -0,0 +1,5 @@ +import type { OxfmtConfig } from 'oxfmt'; + +declare const config: OxfmtConfig; + +export default config; diff --git a/src/configs/oxfmt.config.js b/src/configs/oxfmt.config.js new file mode 100644 index 0000000..3f972a0 --- /dev/null +++ b/src/configs/oxfmt.config.js @@ -0,0 +1,36 @@ +import { defineConfig } from 'oxfmt'; + +export default defineConfig({ + singleQuote: true, + tabWidth: 4, + printWidth: 120, + jsdoc: {}, + sortImports: { + ignoreCase: true, + newlinesBetween: false, + sortSideEffects: true, + groups: [ + 'side_effect', + { newlinesBetween: true }, + 'value-builtin', + { newlinesBetween: true }, + 'external', + { newlinesBetween: true }, + 'internal', + { newlinesBetween: true }, + 'value-index', + 'sibling', + 'parent', + { newlinesBetween: true }, + 'type-builtin', + 'type-external', + 'type-internal', + 'type-index', + 'type-sibling', + 'type-parent', + 'type-import', + 'unknown', + ], + }, + sortPackageJson: true, +}); diff --git a/src/configs/recommended.d.ts b/src/configs/recommended.d.ts new file mode 100644 index 0000000..0c2aa87 --- /dev/null +++ b/src/configs/recommended.d.ts @@ -0,0 +1,5 @@ +import type { OxlintConfig } from 'oxlint'; + +declare const config: OxlintConfig; + +export default config; diff --git a/src/configs/recommended.js b/src/configs/recommended.js new file mode 100644 index 0000000..6983f05 --- /dev/null +++ b/src/configs/recommended.js @@ -0,0 +1,76 @@ +export default { + jsPlugins: ['@hapi/oxc-plugin', 'oxlint-plugin-eslint'], + plugins: ['node', 'unicorn'], + env: { + builtin: true, + node: true, + }, + rules: { + // Custom rules + '@hapi/capitalize-modules': ['warn', 'global-scope-only'], + '@hapi/for-loop': ['warn', { maxDepth: 3, startIterator: 'i' }], + '@hapi/no-var': 'error', + // '@hapi/scope-start': 'warn', // This rule conflicts with oxfmt for now + '@hapi/no-arrowception': 'error', + + 'no-constant-condition': 'error', + 'no-undef': ['error', { typeof: false }], + 'no-shadow': ['warn', { allow: ['err', 'done'] }], + 'no-unused-vars': [ + 'warn', + { vars: 'all', caughtErrors: 'all', varsIgnorePattern: '^internals$', args: 'none' }, + ], + 'no-eq-null': 'warn', + 'no-extend-native': 'warn', + 'no-redeclare': 'warn', + 'no-loop-func': 'warn', + yoda: ['warn', 'never'], + 'sort-vars': 'warn', + 'no-array-constructor': 'error', + eqeqeq: 'error', + curly: ['error', 'all'], + 'no-eval': 'error', + 'no-else-return': 'error', + 'no-return-assign': 'error', + 'no-new-wrappers': 'error', + 'no-ex-assign': 'error', + 'prefer-const': ['error', { destructuring: 'all' }], + 'func-style': ['error', 'expression'], + 'no-unsafe-finally': 'error', + 'no-useless-computed-key': 'error', + 'require-await': 'error', + 'constructor-super': 'error', + 'no-caller': 'error', + 'no-const-assign': 'error', + 'no-dupe-class-members': 'error', + 'no-class-assign': 'warn', + 'no-this-before-super': 'error', + 'prefer-rest-params': 'error', + 'prefer-spread': 'error', + 'no-useless-call': 'error', + 'no-dupe-keys': 'error', + 'prefer-arrow-callback': 'error', + 'object-shorthand': ['error', 'properties'], + 'node/handle-callback-err': ['error', '^(e|err|error)$'], + 'unicorn/no-new-buffer': 'error', + 'node/no-new-require': 'error', + 'typescript/dot-notation': 'warn', + 'eslint/no-object-constructor': 'error', + 'eslint/no-new-native-nonconstructor': 'error', + 'typescript/consistent-return': 'off', + 'eslint/vars-on-top': 'off', + 'eslint/new-cap': 'off', + 'eslint/no-console': 'off', + 'eslint/no-empty': 'off', + 'eslint/no-global-assign': 'off', + 'unicorn/no-process-exit': 'off', + 'eslint/no-unused-expressions': 'off', + 'eslint/no-regex-spaces': 'off', + 'eslint/no-lonely-if': 'off', + 'eslint/no-sparse-arrays': 'error', + + // Unsupported + 'eslint-js/consistent-this': ['error', 'self'], + 'eslint-js/one-var': ['error', 'never'], + }, +}; diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..42fcec2 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,5 @@ +import type { Plugin } from '@oxlint/plugins'; + +declare const plugin: Plugin; + +export default plugin; diff --git a/src/index.js b/src/index.js new file mode 100755 index 0000000..e84e9c5 --- /dev/null +++ b/src/index.js @@ -0,0 +1,20 @@ +import { definePlugin } from '@oxlint/plugins'; + +import CapitalizeModules from './rules/capitalize-modules.js'; +import ForLoop from './rules/for-loop.js'; +import NoArrowception from './rules/no-arrowception.js'; +import NoVar from './rules/no-var.js'; +import ScopeStart from './rules/scope-start.js'; + +export default definePlugin({ + meta: { + name: '@hapi', + }, + rules: { + 'capitalize-modules': CapitalizeModules, + 'for-loop': ForLoop, + 'no-var': NoVar, + 'scope-start': ScopeStart, + 'no-arrowception': NoArrowception, + }, +}); diff --git a/src/rules/capitalize-modules.js b/src/rules/capitalize-modules.js new file mode 100644 index 0000000..5d561e9 --- /dev/null +++ b/src/rules/capitalize-modules.js @@ -0,0 +1,55 @@ +import { defineRule } from '@oxlint/plugins'; + +export default defineRule({ + meta: { + type: 'suggestion', + docs: { + description: 'enforce the capitalization of imported module variables', + category: 'Stylistic Issues', + recommended: true, + }, + schema: [ + { + enum: ['global-scope-only'], + }, + ], + messages: { + notCapitalized: 'Imported module variable name not capitalized.', + }, + }, + createOnce(context) { + const check = (node) => { + const globalScopeOnly = context.options[0] === 'global-scope-only'; + + if (globalScopeOnly && context.sourceCode.getScope(node).type !== 'module') { + return; + } + + const name = node.local ? node.local.name : node.id.name; + if (name[0] !== name[0].toUpperCase()) { + context.report({ + node: node.local || node.id, + messageId: 'notCapitalized', + data: { name }, + }); + } + }; + + const checkVariable = (node) => { + if ( + node.init && + node.init.type === 'AwaitExpression' && + node.init.argument.type === 'ImportExpression' && + node.id.type === 'Identifier' + ) { + check(node); + } + }; + + return { + ImportDefaultSpecifier: check, + ImportNamespaceSpecifier: check, + VariableDeclarator: checkVariable, + }; + }, +}); diff --git a/src/rules/for-loop.js b/src/rules/for-loop.js new file mode 100644 index 0000000..9a7471f --- /dev/null +++ b/src/rules/for-loop.js @@ -0,0 +1,104 @@ +import { defineRule } from '@oxlint/plugins'; + +export default defineRule({ + meta: { + type: 'suggestion', + docs: { + description: 'enforce for loop syntax', + category: 'Stylistic Issues', + recommended: true, + }, + schema: [ + { + type: 'object', + properties: { + maxDepth: { + type: 'integer', + }, + startIterator: { + type: 'string', + }, + }, + additionalProperties: false, + }, + ], + messages: { + depthExceeded: 'Too many nested for loops.', + singleInit: 'Only one variable can be initialized per loop.', + singleVar: 'Left hand side of initializer must be a single variable.', + badIter: "Expected iterator '{{designatedIter}}', but got '{{iteratorVar}}'.", + usePrefixOp: 'Update to iterator should use prefix operator.', + }, + }, + createOnce(context) { + let stack = []; + + const check = function (node) { + const options = context.options[0] || {}; + const maxDepth = options.maxDepth || 3; + const startIterator = options.startIterator || 'i'; + + const getIteratorVariable = function (offset) { + return String.fromCharCode(startIterator.charCodeAt(0) + offset); + }; + + stack.push(node); + + // Make sure that for loops are not nested excessively + + if (stack.length > maxDepth) { + context.report({ node, messageId: 'depthExceeded' }); + } + + const init = node.init; + if (init !== null && init.type === 'VariableDeclaration') { + // Verify that there is 1 initialized variable at most + + if (init.declarations.length > 1) { + context.report({ node, messageId: 'singleInit' }); + } + + const declaration = init.declarations[0]; + + // Verify that this is a normal variable declaration, not destructuring + + if (declaration.id.type !== 'Identifier') { + context.report({ node, messageId: 'singleVar' }); + } else { + const iteratorVar = declaration.id.name; + const designatedIter = getIteratorVariable(stack.length - 1); + + // Verify that the iterator variable has the expected value + + if (iteratorVar !== designatedIter) { + context.report({ + node, + messageId: 'badIter', + data: { designatedIter, iteratorVar }, + }); + } + } + } + + const update = node.update; + + // Verify that postfix increment/decrement are not used + + if (update && update.type === 'UpdateExpression' && !update.prefix) { + context.report({ node, messageId: 'usePrefixOp' }); + } + }; + + const popStack = function () { + stack.pop(); + }; + + return { + before() { + stack = []; + }, + ForStatement: check, + 'ForStatement:exit': popStack, + }; + }, +}); diff --git a/src/rules/no-arrowception.js b/src/rules/no-arrowception.js new file mode 100644 index 0000000..0dbe3bd --- /dev/null +++ b/src/rules/no-arrowception.js @@ -0,0 +1,29 @@ +import { defineRule } from '@oxlint/plugins'; + +export default defineRule({ + meta: { + type: 'problem', + docs: { + description: 'prevent arrow functions that implicitly create arrow functions', + category: 'ECMAScript 6', + recommended: true, + }, + schema: [], + messages: { + implicitCreate: 'Arrow function implicitly creates arrow function.', + }, + }, + createOnce(context) { + const check = function (node) { + const fnBody = node.body; + + if (fnBody.type === 'ArrowFunctionExpression') { + context.report({ node, messageId: 'implicitCreate' }); + } + }; + + return { + ArrowFunctionExpression: check, + }; + }, +}); diff --git a/src/rules/no-var.js b/src/rules/no-var.js new file mode 100644 index 0000000..e09a3f1 --- /dev/null +++ b/src/rules/no-var.js @@ -0,0 +1,73 @@ +// Based on https://github.com/eslint/eslint/blob/master/lib/rules/no-var.js + +import { defineRule } from '@oxlint/plugins'; + +const internals = { + scopeTypes: new Set([ + 'Program', + 'BlockStatement', + 'SwitchStatement', + 'ForStatement', + 'ForInStatement', + 'ForOfStatement', + ]), +}; + +export default defineRule({ + meta: { + type: 'suggestion', + docs: { + description: 'require `let` or `const` instead of `var` when outside of try...catch', + category: 'ECMAScript 6', + recommended: true, + }, + schema: [], + messages: { + unexpectedVar: 'Unexpected var, use let or const instead.', + }, + }, + createOnce(context) { + const check = (node) => { + const sourceCode = context.sourceCode; + + if ( + node.parent.parent && + (node.parent.parent.type === 'TryStatement' || node.parent.parent.type === 'CatchClause') + ) { + const variables = sourceCode.getDeclaredVariables(node, context); + const scopeNode = internals.getScopeNode(node); + if (variables.some(internals.isUsedFromOutsideOf(scopeNode))) { + return; + } + } + + context.report({ node, messageId: 'unexpectedVar' }); + }; + + return { + 'VariableDeclaration:exit'(node) { + if (node.kind === 'var') { + check(node); + } + }, + }; + }, +}); + +internals.getScopeNode = function (node) { + if (internals.scopeTypes.has(node.type)) { + return node; + } + + return internals.getScopeNode(node.parent); +}; + +internals.isUsedFromOutsideOf = function (scopeNode) { + const isOutsideOfScope = (reference) => { + const scope = scopeNode.range; + const id = reference.identifier.range; + return id[0] < scope[0] || id[1] > scope[1]; + }; + + return (variable) => variable.references.some(isOutsideOfScope); +}; diff --git a/src/rules/scope-start.js b/src/rules/scope-start.js new file mode 100644 index 0000000..ed150a4 --- /dev/null +++ b/src/rules/scope-start.js @@ -0,0 +1,97 @@ +import { defineRule } from '@oxlint/plugins'; + +export default defineRule({ + meta: { + type: 'layout', + docs: { + description: 'enforce new line at the beginning of function scope', + category: 'Stylistic Issues', + recommended: true, + }, + fixable: 'whitespace', + schema: [ + { + enum: ['allow-one-liners'], + }, + { + type: 'integer', + }, + ], + messages: { + missingBlank: 'Missing blank line at beginning of function.', + }, + }, + createOnce(context) { + const checkFunction = function (node) { + const allowOneLiners = context.options[0] === 'allow-one-liners'; + check(node, allowOneLiners); + }; + + const checkArrow = function (node) { + check(node, true); + }; + + const check = function (node, allowOneLiners) { + const sourceCode = context.sourceCode; + const maxInOneLiner = context.options[1] !== undefined ? context.options[1] : 1; + + const fnBody = node.body; + + // Arrow functions can return literals that span multiple lines + + if (fnBody.type === 'ObjectExpression' || fnBody.type === 'ArrayExpression') { + return; + } + + const isBlockBody = fnBody.type === 'BlockStatement'; + const body = isBlockBody ? fnBody.body : [fnBody]; + + // Allow empty function bodies to be of any size + + if (body.length === 0) { + return; + } + + const stmt = body[0]; + const openToken = sourceCode.getTokenBefore(stmt); + const openTokenLine = openToken.loc.start.line; + const commentsBefore = sourceCode.getCommentsBefore(stmt); + const firstThing = + commentsBefore.length > 0 && commentsBefore[0].range[0] > openToken.range[1] ? commentsBefore[0] : stmt; + const bodyStartLine = firstThing.loc.start.line; + const closeTokenLine = isBlockBody + ? sourceCode.getTokenAfter(stmt).loc.start.line + : sourceCode.getLastToken(stmt).loc.start.line; + + if (allowOneLiners === true && body.length <= maxInOneLiner && openTokenLine === closeTokenLine) { + return; + } + + if (bodyStartLine - openTokenLine < 2) { + context.report({ + node, + messageId: 'missingBlank', + fix(fixer) { + const commentsAfter = sourceCode.getCommentsAfter(openToken); + let lastTokenOnOpenLine = openToken; + for (const comment of commentsAfter) { + if (comment.loc.start.line === openTokenLine) { + lastTokenOnOpenLine = comment; + } else { + break; + } + } + + return fixer.insertTextAfter(lastTokenOnOpenLine, '\n'); + }, + }); + } + }; + + return { + ArrowFunctionExpression: checkArrow, + FunctionExpression: checkFunction, + FunctionDeclaration: checkFunction, + }; + }, +}); diff --git a/src/vitest.d.ts b/src/vitest.d.ts new file mode 100644 index 0000000..c868063 --- /dev/null +++ b/src/vitest.d.ts @@ -0,0 +1,27 @@ +import type { Plugin } from 'vitest/config'; + +export interface Options { + /** + * Run the oxlint checks. + * + * @default true + */ + oxlint?: boolean; + /** + * Run the oxfmt checks. + * + * @default true + */ + oxfmt?: boolean; + /** + * Working directory the checks run against. + * + * @default process.cwd() + */ + cwd?: string; +} + +/** Vitest plugin that runs the hapi oxlint and oxfmt checks as a test project. */ +declare function plugin(options?: Options): Plugin; + +export default plugin; diff --git a/src/vitest.js b/src/vitest.js new file mode 100644 index 0000000..253182f --- /dev/null +++ b/src/vitest.js @@ -0,0 +1,22 @@ +import * as Path from 'node:path'; + +export default function (options = {}) { + const { oxlint = true, oxfmt = true, cwd } = options; + + return { + name: '@hapi/oxc-plugin:vitest', + async configureVitest({ injectTestProjects }) { + await injectTestProjects({ + define: { + __HAPI_OXC_OXLINT__: oxlint, + __HAPI_OXC_OXFMT__: oxfmt, + __HAPI_OXC_CWD__: JSON.stringify(cwd ?? null), + }, + test: { + name: '@hapi/oxc-plugin', + include: [Path.join(import.meta.dirname, 'vitest.test.js')], + }, + }); + }, + }; +} diff --git a/src/vitest.test.js b/src/vitest.test.js new file mode 100644 index 0000000..ef0eb4b --- /dev/null +++ b/src/vitest.test.js @@ -0,0 +1,64 @@ +/* global __HAPI_OXC_OXLINT__, __HAPI_OXC_OXFMT__, __HAPI_OXC_CWD__ */ + +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { delimiter, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { describe, it } from 'vitest'; + +const internals = {}; + +internals.execFile = promisify(execFile); + +internals.cwd = __HAPI_OXC_CWD__ ?? process.cwd(); + +internals.oxlintConfigs = ['oxlint.config.ts', 'oxlint.config.js', 'oxlint.config.json', '.oxlintrc.json']; +internals.oxfmtConfigs = ['oxfmt.config.ts', 'oxfmt.config.js', 'oxfmt.config.json']; + +internals.hasConfig = function (files) { + for (const file of files) { + if (existsSync(resolve(internals.cwd, file))) { + return true; + } + } + + return false; +}; + +internals.exec = function (command, args) { + const env = { ...process.env }; + env.PATH = `${resolve(internals.cwd, 'node_modules/.bin')}${delimiter}${env.PATH}`; + + return internals.execFile(command, args, { encoding: 'utf8', cwd: internals.cwd, env }); +}; + +describe('@hapi/oxc-plugin', () => { + it.skipIf(!__HAPI_OXC_OXLINT__)('oxlint', async () => { + const args = []; + if (!internals.hasConfig(internals.oxlintConfigs)) { + args.push('-c', join(import.meta.dirname, 'configs', 'recommended.js')); + } + + try { + await internals.exec('oxlint', args); + } catch (err) { + throw new Error(`oxlint check failed:\n${err.stdout + err.stderr}`); + } + }); + + it.skipIf(!__HAPI_OXC_OXFMT__)('oxfmt', async () => { + const args = ['--check']; + if (!internals.hasConfig(internals.oxfmtConfigs)) { + args.push('-c', join(import.meta.dirname, 'configs', 'oxfmt.config.js')); + } + + args.push('.'); + + try { + await internals.exec('oxfmt', args); + } catch (err) { + throw new Error(`oxfmt check failed:\n${err.stdout + err.stderr}`); + } + }); +}); diff --git a/test/configs/__snapshots__/oxfmt.js.snap b/test/configs/__snapshots__/oxfmt.js.snap new file mode 100644 index 0000000..dcff96f --- /dev/null +++ b/test/configs/__snapshots__/oxfmt.js.snap @@ -0,0 +1,106 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`oxfmt config > formats arrow-parens.js correctly 1`] = ` +"/* eslint-disable no-unused-vars */ +const foo = (bar) => { + return bar + 1; +}; + +const baz = (quux) => { + return quux + 1; +}; +" +`; + +exports[`oxfmt config > formats arrow-spacing.js correctly 1`] = ` +"/* eslint-disable no-unused-vars */ +const foo = (bar) => { + return bar + 1; +}; + +const baz = (quux) => { + return quux + 1; +}; + +const fn = (arg) => { + return arg + 1; +}; +" +`; + +exports[`oxfmt config > formats indent.js correctly 1`] = ` +"export const foo = function (value) { + return value + 1; +}; + +export const bar = function (value) { + return value + 1; +}; +" +`; + +exports[`oxfmt config > formats indent-switch-case.js correctly 1`] = ` +"const foo = 'foo'; +let result = 0; + +switch (foo) { + case 'foo': + result = 1; + break; + + case 'bar': + result = 2; + break; + case 'baz': + result = 3; + break; +} + +result.toString(); +" +`; + +exports[`oxfmt config > formats no-extra-semi.js correctly 1`] = ` +"/* eslint-disable padding-line-between-statements */ + +export const foo = function () { + try { + } catch {} +}; +" +`; + +exports[`oxfmt config > formats semi.js correctly 1`] = ` +"export const foo = function () { + return 42; +}; + +export const bar = function () { + return 85; +}; +" +`; + +exports[`oxfmt config > formats space-before-blocks.js correctly 1`] = ` +"/* eslint-disable no-unused-vars */ + +const foo = function () {}; + +const bar = function () {}; + +const baz = function () {}; +" +`; + +exports[`oxfmt config > formats space-before-function-paren.js correctly 1`] = ` +"/* eslint-disable no-unused-vars */ + +const foo = function () {}; + +const bar = function () {}; + +const baz = function baz() {}; + +const quux = function quux() {}; +" +`; diff --git a/test/configs/__snapshots__/recommended.js.snap b/test/configs/__snapshots__/recommended.js.snap new file mode 100644 index 0000000..1c2c0ec --- /dev/null +++ b/test/configs/__snapshots__/recommended.js.snap @@ -0,0 +1,106 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`recommended config > oxfmt > formats arrow-parens.js correctly 1`] = ` +"/* eslint-disable no-unused-vars */ +const foo = (bar) => { + return bar + 1; +}; + +const baz = (quux) => { + return quux + 1; +}; +" +`; + +exports[`recommended config > oxfmt > formats arrow-spacing.js correctly 1`] = ` +"/* eslint-disable no-unused-vars */ +const foo = (bar) => { + return bar + 1; +}; + +const baz = (quux) => { + return quux + 1; +}; + +const fn = (arg) => { + return arg + 1; +}; +" +`; + +exports[`recommended config > oxfmt > formats indent.js correctly 1`] = ` +"export const foo = function (value) { + return value + 1; +}; + +export const bar = function (value) { + return value + 1; +}; +" +`; + +exports[`recommended config > oxfmt > formats indent-switch-case.js correctly 1`] = ` +"const foo = 'foo'; +let result = 0; + +switch (foo) { + case 'foo': + result = 1; + break; + + case 'bar': + result = 2; + break; + case 'baz': + result = 3; + break; +} + +result.toString(); +" +`; + +exports[`recommended config > oxfmt > formats no-extra-semi.js correctly 1`] = ` +"/* eslint-disable padding-line-between-statements */ + +export const foo = function () { + try { + } catch {} +}; +" +`; + +exports[`recommended config > oxfmt > formats semi.js correctly 1`] = ` +"export const foo = function () { + return 42; +}; + +export const bar = function () { + return 85; +}; +" +`; + +exports[`recommended config > oxfmt > formats space-before-blocks.js correctly 1`] = ` +"/* eslint-disable no-unused-vars */ + +const foo = function () {}; + +const bar = function () {}; + +const baz = function () {}; +" +`; + +exports[`recommended config > oxfmt > formats space-before-function-paren.js correctly 1`] = ` +"/* eslint-disable no-unused-vars */ + +const foo = function () {}; + +const bar = function () {}; + +const baz = function baz() {}; + +const quux = function quux() {}; +" +`; diff --git a/test/configs/fixtures/arrow-parens.js b/test/configs/fixtures/arrow-parens.js new file mode 100644 index 0000000..d3264ea --- /dev/null +++ b/test/configs/fixtures/arrow-parens.js @@ -0,0 +1,10 @@ +/* eslint-disable no-unused-vars */ +const foo = bar => { + + return bar + 1; +}; + +const baz = (quux) => { + + return quux + 1; +}; diff --git a/test/configs/fixtures/arrow-spacing.js b/test/configs/fixtures/arrow-spacing.js new file mode 100644 index 0000000..e6f4959 --- /dev/null +++ b/test/configs/fixtures/arrow-spacing.js @@ -0,0 +1,15 @@ +/* eslint-disable no-unused-vars */ +const foo = (bar)=> { + + return bar + 1; +}; + +const baz = (quux) =>{ + + return quux + 1; +}; + +const fn = (arg) => { + + return arg + 1; +}; diff --git a/test/configs/fixtures/brace-style.js b/test/configs/fixtures/brace-style.js new file mode 100644 index 0000000..0047ac3 --- /dev/null +++ b/test/configs/fixtures/brace-style.js @@ -0,0 +1,17 @@ +const foo = true; +let bar = 0; + +if (foo) { + bar = 1; +} else { + bar = 2; +} + +if (foo) { + bar = 3; +} +else { + bar = 4; +} + +bar.toString(); diff --git a/test/configs/fixtures/camelcase.js b/test/configs/fixtures/camelcase.js new file mode 100644 index 0000000..656e800 --- /dev/null +++ b/test/configs/fixtures/camelcase.js @@ -0,0 +1,4 @@ +const foo_bar = '123'; +const barBaz = '456'; + +foo_bar + barBaz; diff --git a/test/configs/fixtures/handle-callback-err.js b/test/configs/fixtures/handle-callback-err.js new file mode 100644 index 0000000..25dbde8 --- /dev/null +++ b/test/configs/fixtures/handle-callback-err.js @@ -0,0 +1,28 @@ +/* eslint-disable no-unused-vars */ + +export const foo = function (value) { + + const top = function (err) { + + const inner = function (e) { + + return value; + }; + }; + + top(); +}; + + +export const bar = function (value) { + + const top = function (abc) { + + const inner = function (xyz) { + + return value; + }; + }; + + top(); +}; diff --git a/test/configs/fixtures/hapi-capitalize-modules.js b/test/configs/fixtures/hapi-capitalize-modules.js new file mode 100644 index 0000000..b5d5d2c --- /dev/null +++ b/test/configs/fixtures/hapi-capitalize-modules.js @@ -0,0 +1,12 @@ +/* eslint-disable no-unused-vars */ + +import Fs from 'fs'; +import net from 'net'; + +const fn = async function () { + + const Assert = await import('assert'); + const dgram = await import('dgram'); +}; + +fn(); diff --git a/test/configs/fixtures/hapi-for-you.js b/test/configs/fixtures/hapi-for-you.js new file mode 100644 index 0000000..04bd567 --- /dev/null +++ b/test/configs/fixtures/hapi-for-you.js @@ -0,0 +1,7 @@ +const arr = []; + +for (let i = 0; i < arr.length; ++i) { + for (let k = 0; k < arr.length; k++) { + + } +} diff --git a/test/configs/fixtures/hapi-scope-start.js b/test/configs/fixtures/hapi-scope-start.js new file mode 100644 index 0000000..6f593d3 --- /dev/null +++ b/test/configs/fixtures/hapi-scope-start.js @@ -0,0 +1,20 @@ +/* eslint-disable no-unused-vars */ +const foo = function () { + return 'there should be a blank line before this line'; +}; + +const bar = function () { + + return 'no lint errors'; +}; + +const baz = () => { + + return 'no lint errors'; +}; + +const quux = () => 85; // no lint errors + +const buux = () => ({ + a: 'b' +}); diff --git a/test/configs/fixtures/indent-switch-case.js b/test/configs/fixtures/indent-switch-case.js new file mode 100644 index 0000000..8eba854 --- /dev/null +++ b/test/configs/fixtures/indent-switch-case.js @@ -0,0 +1,17 @@ +const foo = 'foo'; +let result = 0; + +switch (foo) { + case 'foo': + result = 1; + break; + +case 'bar': + result = 2; + break; + case 'baz': + result = 3; + break; +} + +result.toString(); diff --git a/test/configs/fixtures/indent.js b/test/configs/fixtures/indent.js new file mode 100644 index 0000000..409da0d --- /dev/null +++ b/test/configs/fixtures/indent.js @@ -0,0 +1,10 @@ +export const foo = function (value) { + + return value + 1; +}; + + +export const bar = function (value) { + + return value + 1; +}; diff --git a/test/configs/fixtures/key-spacing.js b/test/configs/fixtures/key-spacing.js new file mode 100755 index 0000000..f1f6d8d --- /dev/null +++ b/test/configs/fixtures/key-spacing.js @@ -0,0 +1,5 @@ +/* eslint-disable no-unused-vars */ +const a = { + b: 'c', + c : 'd' +}; diff --git a/test/configs/fixtures/no-arrowception.js b/test/configs/fixtures/no-arrowception.js new file mode 100644 index 0000000..d1367f7 --- /dev/null +++ b/test/configs/fixtures/no-arrowception.js @@ -0,0 +1,3 @@ +/* eslint-disable no-unused-vars */ +const foo = () => () => 85; +const bar = () => 85; diff --git a/test/configs/fixtures/no-constant-condition.js b/test/configs/fixtures/no-constant-condition.js new file mode 100644 index 0000000..f26e65f --- /dev/null +++ b/test/configs/fixtures/no-constant-condition.js @@ -0,0 +1,3 @@ +if ((foo) => 1) { + // Do nothing +} diff --git a/test/configs/fixtures/no-dupe-keys.js b/test/configs/fixtures/no-dupe-keys.js new file mode 100644 index 0000000..bff6f65 --- /dev/null +++ b/test/configs/fixtures/no-dupe-keys.js @@ -0,0 +1,6 @@ +/* eslint-disable no-unused-vars */ + +const obj = { + a: 1, + a: 2 +}; diff --git a/test/configs/fixtures/no-extra-semi.js b/test/configs/fixtures/no-extra-semi.js new file mode 100644 index 0000000..c10026b --- /dev/null +++ b/test/configs/fixtures/no-extra-semi.js @@ -0,0 +1,12 @@ +/* eslint-disable padding-line-between-statements */ + +export const foo = function () { + + try { + + } + catch { + + }; + +}; diff --git a/test/configs/fixtures/no-shadow.js b/test/configs/fixtures/no-shadow.js new file mode 100644 index 0000000..0564481 --- /dev/null +++ b/test/configs/fixtures/no-shadow.js @@ -0,0 +1,34 @@ +/* eslint-disable no-unused-vars, handle-callback-err */ + + +// Declare internals + +const internals = {}; + + +export const foo = function (value) { + + const top = function (err) { + + const inner = function (err) { + + return value; + }; + }; + + top(); +}; + + +export const bar = function (value) { + + const top = function (res) { + + const inner = function (res) { + + return value; + }; + }; + + top(); +}; diff --git a/test/configs/fixtures/no-undef.js b/test/configs/fixtures/no-undef.js new file mode 100644 index 0000000..3cc4a4f --- /dev/null +++ b/test/configs/fixtures/no-undef.js @@ -0,0 +1,9 @@ +/* eslint-disable no-unused-vars */ + +try { + const foo = typeof bar; + const baz = bar; +} +catch (ignoreErr) { + +} diff --git a/test/configs/fixtures/no-unsafe-finally.js b/test/configs/fixtures/no-unsafe-finally.js new file mode 100644 index 0000000..361633c --- /dev/null +++ b/test/configs/fixtures/no-unsafe-finally.js @@ -0,0 +1,13 @@ +export const foo = function () { + + try { + return 1; + } + catch { + + return 2; + } + finally { + return 3; + } +}; diff --git a/test/configs/fixtures/no-unused-vars.js b/test/configs/fixtures/no-unused-vars.js new file mode 100644 index 0000000..7918756 --- /dev/null +++ b/test/configs/fixtures/no-unused-vars.js @@ -0,0 +1,7 @@ +const internals = {}; +const internals2 = {}; +const bar = function (foo) { + +}; + +bar.toString(); diff --git a/test/configs/fixtures/no-useless-computed-key.js b/test/configs/fixtures/no-useless-computed-key.js new file mode 100644 index 0000000..0006eb8 --- /dev/null +++ b/test/configs/fixtures/no-useless-computed-key.js @@ -0,0 +1,5 @@ +export const a = { ['0']: 0 }; +export const b = { ['0+1,234']: 0 }; +export const c = { [0]: 0 }; +export const d = { ['x']: 0 }; +export const e = { ['x']() {} }; diff --git a/test/configs/fixtures/no-var.js b/test/configs/fixtures/no-var.js new file mode 100644 index 0000000..5487d03 --- /dev/null +++ b/test/configs/fixtures/no-var.js @@ -0,0 +1,7 @@ +/* eslint-disable no-unused-vars */ + +var foo = 1; +let bar = 2; +const baz = 3; + +bar = 4; diff --git a/test/configs/fixtures/node-env.js b/test/configs/fixtures/node-env.js new file mode 100644 index 0000000..4166d9d --- /dev/null +++ b/test/configs/fixtures/node-env.js @@ -0,0 +1,3 @@ +import Fs from "fs"; + +export default Fs; diff --git a/test/configs/fixtures/object-shorthand.js b/test/configs/fixtures/object-shorthand.js new file mode 100644 index 0000000..208ccd8 --- /dev/null +++ b/test/configs/fixtures/object-shorthand.js @@ -0,0 +1,12 @@ +/* eslint-disable no-unused-vars */ + +const a = 1; +const b = 2; +const c = function () {}; +const d = function () {}; +const obj = { + a, + b: b, + c: function () {}, + d() {} +}; diff --git a/test/configs/fixtures/one-var.js b/test/configs/fixtures/one-var.js new file mode 100644 index 0000000..1edf18e --- /dev/null +++ b/test/configs/fixtures/one-var.js @@ -0,0 +1,7 @@ +/* eslint-disable no-unused-vars, prefer-const */ + +const foo = 1; +let bar; +let baz, quux; + +bar = 1; diff --git a/test/configs/fixtures/prefer-arrow-callback.js b/test/configs/fixtures/prefer-arrow-callback.js new file mode 100644 index 0000000..d58f936 --- /dev/null +++ b/test/configs/fixtures/prefer-arrow-callback.js @@ -0,0 +1,23 @@ +/* eslint-disable handle-callback-err */ + +const foo = (arg, callback) => { + + return callback(null, arg + 1); +}; + +const bar = function (err, value) { + +}; + +const baz = (err, value) => { + +}; + +foo(1, bar); +foo(2, baz); +foo(3, (err, value) => { + +}); +foo(4, function (err, value) { + +}); diff --git a/test/configs/fixtures/prefer-const.js b/test/configs/fixtures/prefer-const.js new file mode 100644 index 0000000..71e7925 --- /dev/null +++ b/test/configs/fixtures/prefer-const.js @@ -0,0 +1,10 @@ +/* eslint-disable no-unused-vars */ + +let foo = 1; +let bar = 2; +const baz = 3; + +bar++; + +let { a, b } = { a: 1, b: 2 }; +a++; diff --git a/test/configs/fixtures/private-class-field.js b/test/configs/fixtures/private-class-field.js new file mode 100644 index 0000000..7040f0e --- /dev/null +++ b/test/configs/fixtures/private-class-field.js @@ -0,0 +1,5 @@ +/* eslint-disable no-unused-vars */ + +class Test { + #a = 1; +} diff --git a/test/configs/fixtures/semi.js b/test/configs/fixtures/semi.js new file mode 100644 index 0000000..55b5512 --- /dev/null +++ b/test/configs/fixtures/semi.js @@ -0,0 +1,9 @@ +export const foo = function () { + + return 42 +}; + +export const bar = function () { + + return 85; +}; diff --git a/test/configs/fixtures/space-before-blocks.js b/test/configs/fixtures/space-before-blocks.js new file mode 100755 index 0000000..2344960 --- /dev/null +++ b/test/configs/fixtures/space-before-blocks.js @@ -0,0 +1,11 @@ +/* eslint-disable no-unused-vars */ + +const foo = function (){ + +}; + +const bar = function () { + +}; + +const baz = function (){}; diff --git a/test/configs/fixtures/space-before-function-paren.js b/test/configs/fixtures/space-before-function-paren.js new file mode 100644 index 0000000..77cffb2 --- /dev/null +++ b/test/configs/fixtures/space-before-function-paren.js @@ -0,0 +1,17 @@ +/* eslint-disable no-unused-vars */ + +const foo = function () { + +}; + +const bar = function() { + +}; + +const baz = function baz() { + +}; + +const quux = function quux () { + +}; diff --git a/test/configs/oxfmt.js b/test/configs/oxfmt.js new file mode 100644 index 0000000..39d7c88 --- /dev/null +++ b/test/configs/oxfmt.js @@ -0,0 +1,43 @@ +import { execSync } from 'child_process'; +import Path from 'path'; + +import { describe, it, expect } from 'vitest'; + +const internals = {}; + +internals.formatFile = function (configPath, filePath) { + const command = `./node_modules/.bin/oxfmt -c ${configPath} --stdin-filepath ${filePath}`; + + try { + const output = execSync(`cat ${filePath} | ${command}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + }); + return output; + } catch (err) { + console.error(err.stderr); + throw err; + } +}; + +describe('oxfmt config', () => { + const configPath = Path.join(import.meta.dirname, './oxfmt.test.config.ts'); + const fixturesDir = Path.join(import.meta.dirname, './fixtures'); + + const testFormatting = (fileName) => { + it(`formats ${fileName} correctly`, () => { + const filePath = Path.join(fixturesDir, fileName); + const output = internals.formatFile(configPath, filePath); + expect(output).toMatchSnapshot(); + }); + }; + + testFormatting('indent.js'); + testFormatting('indent-switch-case.js'); + testFormatting('semi.js'); + testFormatting('no-extra-semi.js'); + testFormatting('space-before-function-paren.js'); + testFormatting('arrow-parens.js'); + testFormatting('arrow-spacing.js'); + testFormatting('space-before-blocks.js'); +}); diff --git a/test/configs/oxfmt.test.config.ts b/test/configs/oxfmt.test.config.ts new file mode 100644 index 0000000..bb1221f --- /dev/null +++ b/test/configs/oxfmt.test.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'oxfmt'; + +import DefaultOxFmtConfig from '../../src/configs/oxfmt.config.js'; + +export default defineConfig({ + ...DefaultOxFmtConfig, +}); diff --git a/test/configs/oxlint.test.config.ts b/test/configs/oxlint.test.config.ts new file mode 100644 index 0000000..5b5da6f --- /dev/null +++ b/test/configs/oxlint.test.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'oxlint'; + +import HapiRecommended from '../../src/configs/recommended.js'; + +export default defineConfig({ + extends: [HapiRecommended], + env: { + ...HapiRecommended.env, + }, +}); diff --git a/test/configs/recommended.js b/test/configs/recommended.js new file mode 100644 index 0000000..27ce4b9 --- /dev/null +++ b/test/configs/recommended.js @@ -0,0 +1,426 @@ +import { execSync } from 'child_process'; +import Fs from 'fs'; +import Path from 'path'; + +import { describe, it } from 'vitest'; + +describe.concurrent('recommended config', () => { + describe('oxlint', () => { + const configPath = Path.join(import.meta.dirname, './oxlint.test.config.ts'); + + const checkFile = function (filePath) { + const command = `./node_modules/.bin/oxlint -c ${configPath} --format json --no-ignore --disable-nested-config ${filePath}`; + + let output; + try { + output = execSync(command, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }); + } catch (err) { + output = err.stdout; + if (!output) { + console.error(err.stderr); + throw err; + } + } + + const result = JSON.parse(output); + + const { diagnostics } = result; + + const messages = diagnostics.map((d) => ({ + ruleId: d.code, + severity: d.severity === 'error' ? 2 : 1, + message: d.message, + line: d.labels[0].span.line, + column: d.labels[0].span.column, + })); + + return messages; + }; + + const lintFile = function (file) { + const messages = checkFile(Path.join(import.meta.dirname, file)); + + return [ + { + messages, + errorCount: messages.filter((m) => m.severity === 2).length, + warningCount: messages.filter((m) => m.severity === 1).length, + }, + ]; + }; + + it('enforces @hapi/for-loop', async ({ expect }) => { + const output = await lintFile('fixtures/hapi-for-you.js'); + const results = output[0]; + + expect(results.errorCount).toBe(0); + expect(results.warningCount).toBe(2); + + let msg = results.messages[0]; + + expect(msg.ruleId).toBe('@hapi(for-loop)'); + expect(msg.severity).toBe(1); + expect(msg.message).toBe("Expected iterator 'j', but got 'k'."); + expect(msg.line).toBe(4); + expect(msg.column).toBe(5); + + msg = results.messages[1]; + + expect(msg.ruleId).toBe('@hapi(for-loop)'); + expect(msg.severity).toBe(1); + expect(msg.message).toBe('Update to iterator should use prefix operator.'); + expect(msg.line).toBe(4); + expect(msg.column).toBe(5); + }); + + it.skip('enforces @hapi/scope-start', async ({ expect }) => { + const output = await lintFile('fixtures/hapi-scope-start.js'); + const results = output[0]; + + expect(results.errorCount).toBe(0); + expect(results.warningCount).toBe(1); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('@hapi(scope-start)'); + expect(msg.severity).toBe(1); + expect(msg.message).toBe('Missing blank line at beginning of function.'); + expect(msg.line).toBe(2); + expect(msg.column).toBe(13); + }); + + it('enforces @hapi/capitalize-modules', async ({ expect }) => { + const output = await lintFile('fixtures/hapi-capitalize-modules.js'); + const results = output[0]; + + expect(results.errorCount).toBe(0); + expect(results.warningCount).toBe(1); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('@hapi(capitalize-modules)'); + expect(msg.severity).toBe(1); + expect(msg.message).toBe('Imported module variable name not capitalized.'); + expect(msg.line).toBe(4); + expect(msg.column).toBe(8); + }); + + it('enforces @hapi/no-arrowception', async ({ expect }) => { + const output = await lintFile('fixtures/no-arrowception.js'); + const results = output[0]; + + expect(results.errorCount).toBe(1); + expect(results.warningCount).toBe(0); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('@hapi(no-arrowception)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe('Arrow function implicitly creates arrow function.'); + expect(msg.line).toBe(2); + expect(msg.column).toBe(13); + }); + + it('enforces no-shadow rule', async ({ expect }) => { + const output = await lintFile('fixtures/no-shadow.js'); + const results = output[0]; + + expect(results.errorCount).toBe(0); + expect(results.warningCount).toBe(1); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('eslint(no-shadow)'); + expect(msg.severity).toBe(1); + expect(msg.message).toBe("'res' is already declared in the upper scope."); + expect(msg.line).toBe(27); + expect(msg.column).toBe(33); + }); + + it('enforces one-var rule', async ({ expect }) => { + const output = await lintFile('fixtures/one-var.js'); + const results = output[0]; + + expect(results.errorCount).toBe(1); + expect(results.warningCount).toBe(0); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('eslint-js(one-var)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe("Split 'let' declarations into multiple statements."); + expect(msg.line).toBe(5); + expect(msg.column).toBe(1); + }); + + it('enforces no-undef rule', async ({ expect }) => { + const output = await lintFile('fixtures/no-undef.js'); + const results = output[0]; + + expect(results.errorCount).toBe(1); + expect(results.warningCount).toBe(0); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('eslint(no-undef)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe("'bar' is not defined."); + expect(msg.line).toBe(5); + expect(msg.column).toBe(17); + }); + + it('enforces no-unused-vars', async ({ expect }) => { + const output = await lintFile('fixtures/no-unused-vars.js'); + const results = output[0]; + + expect(results.errorCount).toBe(0); + expect(results.warningCount).toBe(1); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('eslint(no-unused-vars)'); + expect(msg.severity).toBe(1); + expect(msg.message).toMatch(/Variable 'internals2' is declared but never used\./); + expect(msg.line).toBe(2); + expect(msg.column).toBe(7); + }); + + it('enforces prefer-const', async ({ expect }) => { + const output = await lintFile('fixtures/prefer-const.js'); + const results = output[0]; + + expect(results.errorCount).toBe(1); + expect(results.warningCount).toBe(0); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('eslint(prefer-const)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe('`foo` is never reassigned.'); + expect(msg.line).toBe(3); + expect(msg.column).toBe(5); + }); + + it('enforces @hapi/no-var', async ({ expect }) => { + const output = await lintFile('fixtures/no-var.js'); + const results = output[0]; + + expect(results.errorCount).toBe(1); + expect(results.warningCount).toBe(0); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('@hapi(no-var)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe('Unexpected var, use let or const instead.'); + expect(msg.line).toBe(3); + expect(msg.column).toBe(1); + }); + + it('enforces object-shorthand', async ({ expect }) => { + const output = await lintFile('fixtures/object-shorthand.js'); + const results = output[0]; + + expect(results.errorCount).toBe(1); + expect(results.warningCount).toBe(0); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('eslint(object-shorthand)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe('Expected property shorthand.'); + expect(msg.line).toBe(9); + expect(msg.column).toBe(5); + }); + + it('enforces prefer-arrow-callback', async ({ expect }) => { + const output = await lintFile('fixtures/prefer-arrow-callback.js'); + const results = output[0]; + + expect(results.errorCount).toBe(1); + expect(results.warningCount).toBe(0); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('eslint(prefer-arrow-callback)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe('Unexpected function expression.'); + expect(msg.line).toBe(21); + expect(msg.column).toBe(8); + }); + + it('enforces no-constant-condition rule', async ({ expect }) => { + const output = await lintFile('fixtures/no-constant-condition.js'); + const results = output[0]; + + expect(results.errorCount).toBe(1); + expect(results.warningCount).toBe(0); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('eslint(no-constant-condition)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe('Unexpected constant condition'); + expect(msg.line).toBe(1); + expect(msg.column).toBe(5); + }); + + it('enforces no-unsafe-finally rule', async ({ expect }) => { + const output = await lintFile('fixtures/no-unsafe-finally.js'); + const results = output[0]; + + expect(results.errorCount).toBe(1); + expect(results.warningCount).toBe(0); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('eslint(no-unsafe-finally)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe('Unsafe `finally` block.'); + expect(msg.line).toBe(11); + expect(msg.column).toBe(9); + }); + + it('enforces no-useless-computed-key rule', async ({ expect }) => { + const output = await lintFile('fixtures/no-useless-computed-key.js'); + const results = output[0]; + + expect(results.errorCount).toBe(5); + expect(results.warningCount).toBe(0); + + let msg = results.messages[0]; + + expect(msg.ruleId).toBe('eslint(no-useless-computed-key)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe("Unnecessarily computed property `'0'` found."); + expect(msg.line).toBe(1); + expect(msg.column).toBe(21); + + msg = results.messages[1]; + + expect(msg.ruleId).toBe('eslint(no-useless-computed-key)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe("Unnecessarily computed property `'0+1,234'` found."); + expect(msg.line).toBe(2); + expect(msg.column).toBe(21); + + msg = results.messages[2]; + + expect(msg.ruleId).toBe('eslint(no-useless-computed-key)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe('Unnecessarily computed property `0` found.'); + expect(msg.line).toBe(3); + expect(msg.column).toBe(21); + + msg = results.messages[3]; + + expect(msg.ruleId).toBe('eslint(no-useless-computed-key)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe("Unnecessarily computed property `'x'` found."); + expect(msg.line).toBe(4); + expect(msg.column).toBe(21); + + msg = results.messages[4]; + + expect(msg.ruleId).toBe('eslint(no-useless-computed-key)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe("Unnecessarily computed property `'x'` found."); + expect(msg.line).toBe(5); + expect(msg.column).toBe(21); + }); + + it('enforces handle-callback-err rule', async ({ expect }) => { + const output = await lintFile('fixtures/handle-callback-err.js'); + const results = output[0]; + + expect(results.errorCount).toBe(2); + expect(results.warningCount).toBe(0); + + let msg = results.messages[0]; + + expect(msg.ruleId).toBe('node(handle-callback-err)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe('Expected error to be handled.'); + expect(msg.line).toBe(5); + expect(msg.column).toBe(27); + + msg = results.messages[1]; + + expect(msg.ruleId).toBe('node(handle-callback-err)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe('Expected error to be handled.'); + expect(msg.line).toBe(7); + expect(msg.column).toBe(33); + }); + + it('enforces no-dupe-keys rule', async ({ expect }) => { + const output = await lintFile('fixtures/no-dupe-keys.js'); + const results = output[0]; + + expect(results.errorCount).toBe(1); + expect(results.warningCount).toBe(0); + + const msg = results.messages[0]; + + expect(msg.ruleId).toBe('eslint(no-dupe-keys)'); + expect(msg.severity).toBe(2); + expect(msg.message).toBe("Duplicate key 'a'"); + expect(msg.line).toBe(4); + expect(msg.column).toBe(5); + }); + + it('uses the node environment', async ({ expect }) => { + const output = await lintFile('fixtures/node-env.js'); + const results = output[0]; + + expect(results.errorCount).toBe(0); + expect(results.warningCount).toBe(0); + expect(results.messages).toEqual([]); + }); + + it('does not enforce the camelcase lint rule', async ({ expect }) => { + const output = await lintFile('fixtures/camelcase.js'); + const results = output[0]; + + expect(results.errorCount).toBe(0); + expect(results.warningCount).toBe(0); + expect(results.messages).toEqual([]); + }); + }); + + describe('oxfmt', () => { + const configPath = Path.join(import.meta.dirname, './oxfmt.test.config.ts'); + const fixturesDir = Path.join(import.meta.dirname, './fixtures'); + + const formatFile = (file) => { + const filePath = Path.join(fixturesDir, file); + const command = `./node_modules/.bin/oxfmt -c ${configPath} --stdin-filepath ${filePath}`; + + try { + const output = execSync(command, { + encoding: 'utf8', + input: Fs.readFileSync(filePath, 'utf8'), + stdio: ['pipe', 'pipe', 'ignore'], + }); + return output; + } catch (err) { + console.error(err.stderr); + throw err; + } + }; + + it.for([ + 'indent.js', + 'indent-switch-case.js', + 'semi.js', + 'no-extra-semi.js', + 'space-before-function-paren.js', + 'arrow-parens.js', + 'arrow-spacing.js', + 'space-before-blocks.js', + ])('formats %s correctly', (fileName, { expect }) => { + const output = formatFile(fileName); + expect(output).toMatchSnapshot(); + }); + }); +}); diff --git a/test/index.js b/test/index.js new file mode 100755 index 0000000..3ecbfc2 --- /dev/null +++ b/test/index.js @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; + +import Plugin from '..'; + +describe('ESLint Plugin', () => { + it('exposes all expected rules', () => { + expect(Plugin.rules).toBeDefined(); + expect(Plugin.rules).toBeTypeOf('object'); + + const rules = Object.keys(Plugin.rules); + + expect(rules.length).toBe(5); + expect(rules.includes('capitalize-modules')).toBe(true); + expect(rules.includes('for-loop')).toBe(true); + expect(rules.includes('no-var')).toBe(true); + expect(rules.includes('scope-start')).toBe(true); + expect(rules.includes('no-arrowception')).toBe(true); + }); +}); diff --git a/test/rules/capitalize-modules.js b/test/rules/capitalize-modules.js new file mode 100644 index 0000000..2127a5e --- /dev/null +++ b/test/rules/capitalize-modules.js @@ -0,0 +1,83 @@ +import { RuleTester } from 'oxlint/plugins-dev'; +import { describe, it } from 'vitest'; + +import HapiRecommended from '../../src/configs/recommended.js'; +import Rule from '../../src/rules/capitalize-modules'; + +const ruleTester = new RuleTester(HapiRecommended); + +describe('capitalize-modules rule', () => { + it('reports warning when module is not capitalized', () => { + const sample = [ + 'import hapi from "hapi";', + 'import * as hapi from "hapi";', + 'async function x() { const hapi = await import("hapi"); }', + ]; + + ruleTester.run('test', Rule, { + valid: [], + invalid: sample.map((code) => { + return { + code, + errors: [{ message: 'Imported module variable name not capitalized.' }], + }; + }), + }); + }); + + it('does not report anything if module variable is capitalized', () => { + const sample = ['import Hapi from "hapi";', 'import * as Hapi from "hapi";', 'import { hapi } from "hapi";']; + + ruleTester.run('test', Rule, { + valid: sample.map((code) => { + return { code }; + }), + invalid: [], + }); + }); + + it('only warns on globals when global-scope-only is set', () => { + const valid = [ + 'import Hapi from "hapi";', + 'import * as Hapi from "hapi";', + 'async function x() { const hapi = await import("hapi"); }', + ]; + + const invalid = ['import hapi from "hapi";', 'import * as hapi from "hapi";']; + + ruleTester.run('test', Rule, { + valid: valid.map((code) => { + return { + code, + options: ['global-scope-only'], + }; + }), + invalid: invalid.map((code) => { + return { + code, + options: ['global-scope-only'], + errors: [{ message: 'Imported module variable name not capitalized.' }], + }; + }), + }); + }); + + it('does not report anything for non-module variables', () => { + const sample = [ + 'let foo, bar, baz;', + 'const foo = fn()', + 'const foo = "string";', + 'const foo = this.bar()', + 'foo[bar] = 5;', + 'this.foo = null;', + ' [foo, bar] = [1, 2];', + ]; + + ruleTester.run('test', Rule, { + valid: sample.map((code) => { + return { code }; + }), + invalid: [], + }); + }); +}); diff --git a/test/rules/for-loop.js b/test/rules/for-loop.js new file mode 100644 index 0000000..fd40bc0 --- /dev/null +++ b/test/rules/for-loop.js @@ -0,0 +1,139 @@ +import { RuleTester } from 'oxlint/plugins-dev'; +import { describe, it } from 'vitest'; + +import HapiRecommended from '../../src/configs/recommended.js'; +import Rule from '../../src/rules/for-loop'; + +const ruleTester = new RuleTester(HapiRecommended); + +describe('for-loop rule', () => { + it('enforces iterator variable naming', () => { + const valids = [ + { + code: 'for (let i = 0; i < a.length; ++i) { for (let j = 0; j < b.length; ++j) {} }', + }, + { + code: 'for (let j = 0; j < a.length; ++j) { for (let k = 0; k < b.length; ++k) {} }', + options: [{ startIterator: 'j' }], + }, + { + code: 'for (let i = 0; i < a.length; ++i) {}; for (let i = 0; i < a.length; ++i) {}', + }, + { + code: 'for (;;) {}', + }, + ]; + + const invalids = [ + { + code: 'for (let j = 0; j < a.length; ++j) {}', + errors: [{ message: "Expected iterator 'i', but got 'j'." }], + }, + { + code: 'for (let i = 0; i < a.length; ++i) {}', + options: [{ startIterator: 'j' }], + errors: [{ message: "Expected iterator 'j', but got 'i'." }], + }, + ]; + + ruleTester.run('test', Rule, { + valid: valids, + invalid: invalids, + }); + }); + + it('enforces a maximum of one variable initialized per loop', () => { + const valids = [ + { + code: 'for (let i = 0; i < a.length; ++i) {}', + }, + { + code: 'for (i = 0, j = 1; i < a.length; ++i) {}', + }, + { + code: 'for (; i < a.length; ++i) {}', + }, + ]; + + const invalids = [ + { + code: 'for (let i = 0, j; i < a.length; ++i) {}', + errors: [{ message: 'Only one variable can be initialized per loop.' }], + }, + { + code: 'for (let [i] = [0]; i < a.length; ++i) {}', + errors: [{ message: 'Left hand side of initializer must be a single variable.' }], + }, + ]; + + ruleTester.run('test', Rule, { + valid: valids, + invalid: invalids, + }); + }); + + it('enforces the maximum number of nested for loops', () => { + const valids = [ + { + code: 'for (let i = 0; i < a.length; ++i) {}', + }, + { + code: 'for (let i = 0; i < a.length; ++i) { for (let j = 0; j < b.length; ++j) { for (let k = 0; k < c.length; ++k) { for (let l = 0; l < d.length; ++l) {} } } }', + options: [{ maxDepth: 4 }], + }, + ]; + + const invalids = [ + { + code: 'for (let i = 0; i < a.length; ++i) { for (let j = 0; j < b.length; ++j) { for (let k = 0; k < c.length; ++k) { for (let l = 0; l < d.length; ++l) {} } } }', + errors: [{ message: 'Too many nested for loops.' }], + }, + { + code: 'for (let i = 0; i < a.length; ++i) { for (let j = 0; j < b.length; ++j) {} }', + options: [{ maxDepth: 1 }], + errors: [{ message: 'Too many nested for loops.' }], + }, + ]; + + ruleTester.run('test', Rule, { + valid: valids, + invalid: invalids, + }); + }); + + it('prevents post-increment and post-decrement', () => { + const valids = [ + { + code: 'for (let i = 0; i < a.length; ++i) {}', + }, + { + code: 'for (let i = 0; i < a.length; --i) {}', + }, + { + code: 'for (let i = 0; i < a.length; i += 1) {}', + }, + { + code: 'for (let i = 0; i < a.length; i = i + 1) {}', + }, + { + code: 'for (let i = 0; i < a.length;) {}', + }, + ]; + + const invalids = [ + { + code: 'for (let i = 0; i < a.length; i++) {}', + errors: [{ message: 'Update to iterator should use prefix operator.' }], + }, + { + code: 'for (let i = 0; i < a.length; i--) {}', + errors: [{ message: 'Update to iterator should use prefix operator.' }], + }, + ]; + + ruleTester.run('test', Rule, { + valid: valids, + invalid: invalids, + }); + }); +}); diff --git a/test/rules/no-arrowception.js b/test/rules/no-arrowception.js new file mode 100644 index 0000000..2ac4ee0 --- /dev/null +++ b/test/rules/no-arrowception.js @@ -0,0 +1,35 @@ +import { RuleTester } from 'oxlint/plugins-dev'; +import { describe, it } from 'vitest'; + +import HapiRecommended from '../../src/configs/recommended.js'; +import Rule from '../../src/rules/no-arrowception'; + +const ruleTester = new RuleTester(HapiRecommended); + +describe('no-arrowception rule', () => { + it('reports error when an arrow function implicitly creates another arrow function', () => { + const valids = [ + 'const foo = () => 85;', + 'const foo = () => { return 42; }', + 'const foo = () => ({});', + 'const foo = () => ({\nbar: 1});', + 'const foo = () => [];', + 'const foo = () => [\n1,\n2];', + 'const foo = () => { return () => 85; };', + ].map((code) => { + return { code }; + }); + + const invalids = ['const foo = () => () => 85;'].map((code) => { + return { + code, + errors: [{ message: 'Arrow function implicitly creates arrow function.' }], + }; + }); + + ruleTester.run('test', Rule, { + valid: valids, + invalid: invalids, + }); + }); +}); diff --git a/test/rules/no-var.js b/test/rules/no-var.js new file mode 100644 index 0000000..0c98891 --- /dev/null +++ b/test/rules/no-var.js @@ -0,0 +1,47 @@ +import { RuleTester } from 'oxlint/plugins-dev'; +import { describe, it } from 'vitest'; + +import HapiRecommended from '../../src/configs/recommended.js'; +import Rule from '../../src/rules/no-var'; + +const ruleTester = new RuleTester(HapiRecommended); + +describe('no-var rule', () => { + it('reports warning when vars used outside of try...catch scope', () => { + const sample = [ + 'function test() { var a = 1; }', + 'function test() { try { var bf = 2; console.log(bf); } catch (err) {} }', + 'function test() { try {} catch (err) { var cf = 3; console.log(cf); } }', + 'function test() { try { var bf = 2; if (bf) { console.log(bf); } } catch (err) {} }', + 'function test() { try { if (true) { var bf = 2; } console.log(bf); } catch (err) {} }', + 'var a = 1; try {} catch (err) {}', + ]; + + ruleTester.run('test', Rule, { + valid: [], + invalid: sample.map((code) => { + return { + code, + errors: [{ message: 'Unexpected var, use let or const instead.' }], + }; + }), + }); + }); + + it('ignores vars used inside try...catch scope and referenced from outside', () => { + const sample = [ + 'const a = 1;', + 'function test() { try { var bf = 2; } catch (err) {} console.log(bf); }', + 'function test() { try {} catch (err) { var cf = 3; } console.log(cf); }', + 'function test() { a = 1; try { var a = 2; } catch (err) {} }', + 'try { var a = 1; } catch (err) {} console.log(a);', + ]; + + ruleTester.run('test', Rule, { + valid: sample.map((code) => { + return { code }; + }), + invalid: [], + }); + }); +}); diff --git a/test/rules/scope-start.js b/test/rules/scope-start.js new file mode 100644 index 0000000..3df6cb6 --- /dev/null +++ b/test/rules/scope-start.js @@ -0,0 +1,249 @@ +import { RuleTester } from 'oxlint/plugins-dev'; +import { describe, it } from 'vitest'; + +import HapiRecommended from '../../src/configs/recommended.js'; +import Rule from '../../src/rules/scope-start'; + +const ruleTester = new RuleTester(HapiRecommended); + +describe('scope-start rule', () => { + it('reports warning when function body does not begin with a blank line', () => { + const invalids = [ + [ + `function fn() { + return; + }`, + `function fn() { + + return; + }`, + ], + [ + `function fn(foo, bar, baz) { + const fizz = 1; + }`, + `function fn(foo, bar, baz) { + + const fizz = 1; + }`, + ], + [ + `function fn(foo) { + return 'foo'; + }`, + `function fn(foo) { + + return 'foo'; + }`, + ], + [ + `function fn() {/*test*/ + return; + }`, + `function fn() {/*test*/ + + return; + }`, + ], + [ + `function fn() { // test + return; + }`, + `function fn() { // test + + return; + }`, + ], + [ + `function fn() { + /* test */ return; + }`, + `function fn() { + + /* test */ return; + }`, + ], + [ + `function fn() { + /* test */ + return; + }`, + `function fn() { + + /* test */ + return; + }`, + ], + [ + `function fn() { + /* test */ // test + return; + }`, + `function fn() { + + /* test */ // test + return; + }`, + ], + [ + `function fn() {/*test*//*test2*/ + return; + }`, + `function fn() {/*test*//*test2*/ + + return; + }`, + ], + ['function fn() { return; }', 'function fn() {\n return; }'], + ['function fn(foo, bar, baz) { return; }', 'function fn(foo, bar, baz) {\n return; }'], + ]; + + ruleTester.run('test', Rule, { + valid: [], + invalid: invalids.map(([code, output]) => { + return { + code, + output, + errors: [{ message: 'Missing blank line at beginning of function.' }], + }; + }), + }); + }); + + it('does not report anything when function body begins with a blank line', () => { + const valids = [ + `function fn() { + + return; + }`, + `function fn(foo, bar, baz) { + + const fizz = 1; + }`, + `function fn(foo) { + + return 'foo'; + }`, + `function fn() {/*test*/ + + return; + }`, + ]; + + ruleTester.run('test', Rule, { + valid: valids.map((code) => { + return { code }; + }), + invalid: [], + }); + }); + + it('does not report anything when function is one line and allow-one-liners is set', () => { + const valids = ['function fn() { return; }', 'function fn(foo, bar, baz) { return; }']; + + ruleTester.run('test', Rule, { + valid: valids.map((code) => { + return { + code, + options: ['allow-one-liners'], + }; + }), + invalid: [], + }); + }); + + it('reports an error when function is allow-one-liners is set but function body contains too many statements', () => { + ruleTester.run('test', Rule, { + valid: [], + invalid: [ + { + code: 'function fn() { let i = 0; i++; return; }', + output: 'function fn() {\n let i = 0; i++; return; }', + options: ['allow-one-liners', 2], + errors: [{ message: 'Missing blank line at beginning of function.' }], + }, + ], + }); + }); + + it('allow-one-liners defaults to 1', () => { + ruleTester.run('test', Rule, { + valid: [], + invalid: [ + { + code: "function fn() { console.log('broken'); return; }", + output: "function fn() {\n console.log('broken'); return; }", + options: ['allow-one-liners'], + errors: [{ message: 'Missing blank line at beginning of function.' }], + }, + ], + }); + }); + + it('does not report anything when function body is empty', () => { + const valids = [ + 'function fn() { }', + 'function fn(foo, bar, baz) { }', + `function fn(foo) { + + }`, + 'function fn() {/*test*/ }', + ]; + + ruleTester.run('test', Rule, { + valid: valids.map((code) => { + return { code }; + }), + invalid: [], + }); + }); + + it('handles function expressions', () => { + const code = `const foo = function () { + + return; + }`; + + ruleTester.run('test', Rule, { + valid: [{ code }], + invalid: [], + }); + }); + + it('handles arrow functions', () => { + const valids = [ + 'const foo = () => {\n\nreturn;};', + 'const foo = () => {\n\nreturn;}', + 'const foo = () => 42;', + 'const foo = () => 42\n', + 'const foo = () => ({});', + 'const foo = () => ({})', + 'const foo = () => ({\nbar: 1});', + 'const foo = () => [];', + 'const foo = () => [\n1,\n2];', + 'const foo = (isTrue) ? () => bar()\n: false;', + 'const foo = (isTrue) ? true:\n () => 1;', + ].map((code) => { + return { code }; + }); + + const invalids = [ + ['const foo = () => {\nreturn;};', 'const foo = () => {\n\nreturn;};'], + ['const foo = () => {const foo = 1; return foo;};', 'const foo = () => {\nconst foo = 1; return foo;};'], + ['const foo = () => {const foo = 1;\nreturn foo;};', 'const foo = () => {\nconst foo = 1;\nreturn foo;};'], + ['const foo = () => \n12;', 'const foo = () =>\n \n12;'], + ['const foo = () => "1" + \n"2";', 'const foo = () =>\n "1" + \n"2";'], + ]; + + ruleTester.run('test', Rule, { + valid: valids, + invalid: invalids.map(([code, output]) => { + return { + code, + output, + errors: [{ message: 'Missing blank line at beginning of function.' }], + }; + }), + }); + }); +}); diff --git a/test/vitest.js b/test/vitest.js new file mode 100644 index 0000000..4d621cf --- /dev/null +++ b/test/vitest.js @@ -0,0 +1,59 @@ +import Path from 'node:path'; + +import { describe, it } from 'vitest'; + +import OxcVitest from '../src/vitest.js'; + +const internals = {}; + +internals.inject = async function (plugin) { + const injected = []; + const context = { + injectTestProjects(config) { + injected.push(config); + return Promise.resolve([]); + }, + }; + + await plugin.configureVitest(context); + return injected; +}; + +describe('vitest plugin', () => { + it('returns a valid plugin', ({ expect }) => { + const plugin = OxcVitest(); + + expect(plugin.name).toBe('@hapi/oxc-plugin:vitest'); + expect(plugin.configureVitest).toBeTypeOf('function'); + }); + + it('injects a test project with default options', async ({ expect }) => { + const plugin = OxcVitest(); + const injected = await internals.inject(plugin); + + expect(injected).toHaveLength(1); + expect(injected[0].test.name).toBe('@hapi/oxc-plugin'); + expect(injected[0].test.include).toHaveLength(1); + expect(injected[0].test.include[0]).toMatch(/vitest\.test\.js$/); + expect(injected[0].define.__HAPI_OXC_OXLINT__).toBe(true); + expect(injected[0].define.__HAPI_OXC_OXFMT__).toBe(true); + expect(injected[0].define.__HAPI_OXC_CWD__).toBe('null'); + }); + + it('passes custom options to the injected project', async ({ expect }) => { + const plugin = OxcVitest({ oxlint: false, oxfmt: false, cwd: '/custom/path' }); + const injected = await internals.inject(plugin); + + expect(injected[0].define.__HAPI_OXC_OXLINT__).toBe(false); + expect(injected[0].define.__HAPI_OXC_OXFMT__).toBe(false); + expect(injected[0].define.__HAPI_OXC_CWD__).toBe('"/custom/path"'); + }); + + it('includes the test file from the src directory', async ({ expect }) => { + const plugin = OxcVitest(); + const injected = await internals.inject(plugin); + + const expectedPath = Path.join(import.meta.dirname, '..', 'src', 'vitest.test.js'); + expect(injected[0].test.include[0]).toBe(expectedPath); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..2f3cde7 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config'; + +import Oxc from './src/vitest.js'; + +export default defineConfig({ + plugins: [Oxc()], + test: { + environment: 'node', + include: ['test/**/*.js'], + exclude: ['test/configs/fixtures/**', 'test/configs/common.js'], + coverage: { + enabled: true, + provider: 'v8', + all: true, + thresholds: { + functions: 100, + lines: 100, + branches: 100, + statements: 100, + }, + reportsDirectory: './coverage', + reporter: ['text', 'lcov'], + exclude: ['test/**', 'dist/**', '**/*.d.ts'], + }, + }, +}); From 9b589f7c8c9e52b3710676605980cb7788deebc0 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Thu, 4 Jun 2026 00:11:46 -0400 Subject: [PATCH 2/4] refactor: ESM-align recommended config and drop scope-start Adapt the recommended oxlint config to the ecosystem's ESM migration and remove a rule that structurally conflicts with oxfmt. - Drop the `scope-start` rule entirely (rule, tests, fixture, export, docs). The "blank line at start of function scope" convention cannot coexist with oxfmt: oxfmt strips the blank line while the rule re-inserts it, and no single file state satisfies both `oxfmt --check` and the rule. Rather than ship a permanently-disabled rule, retire it. - `func-style`: off. ESM packages use exported `function` declarations (`export function foo() {}`), not the CJS-era `const foo = function () {}` expression form the rule enforced. - `no-useless-escape`: off. hapi style deliberately over-escapes characters in regex for readability; oxlint enables this rule by default. - Remove `node/no-new-require` (CJS-only; meaningless under ESM). - peerDependencies: `oxlint`/`oxfmt` were exact pins (1.61.0 / 0.46.0) that disagreed with the bundled `@oxlint/plugins@1.68.0` and the tested versions, producing unmet-peer warnings. Relaxed to `>=` minimums matching what is actually tested. - Drop `vitest` as a peer dependency. It is only needed for the optional `/vitest` helper; declaring it as a peer (even optional) makes npm raise an ERESOLVE conflict for any consumer not on vitest 4 that only wants the oxlint/oxfmt configs. Documented the >= 4 requirement in API.md instead. - Fix package description (was "ESLint plugin"). Verified: 52 tests pass (no skips), 100% coverage, self-lint clean. The ESM-aligned config now lints hoek/lib and bourne/lib with zero findings (previously ~40 no-useless-escape + func-style errors). --- API.md | 10 +- package.json | 7 +- src/configs/recommended.js | 9 +- src/index.js | 2 - src/rules/scope-start.js | 97 --------- test/configs/fixtures/hapi-scope-start.js | 20 -- test/configs/recommended.js | 16 -- test/index.js | 3 +- test/rules/scope-start.js | 249 ---------------------- 9 files changed, 14 insertions(+), 399 deletions(-) delete mode 100644 src/rules/scope-start.js delete mode 100644 test/configs/fixtures/hapi-scope-start.js delete mode 100644 test/rules/scope-start.js diff --git a/API.md b/API.md index d8d2805..7e263b8 100644 --- a/API.md +++ b/API.md @@ -71,12 +71,6 @@ The first variable iterator name to use. This defaults to `'i'`. Enforces the usage of var declarations only in try-catch scope. -### scope-start - -Enforces a new line at the beginning of function scope. - -_Note: This rule is currently disabled in the recommended configuration due to a conflict with oxfmt._ - ### no-arrowception Prevents arrow functions that implicitly create additional arrow functions. @@ -93,6 +87,10 @@ This plugin also exposes a [Vitest](https://vitest.dev) plugin at `@hapi/oxc-plu the oxlint and oxfmt checks as a dedicated test project, so linting and formatting violations surface as failing tests in your existing test run. +The Vitest plugin requires `vitest` >= 4 in your project. It is an optional part of this package — the +`oxlint` and `oxfmt` configurations work without `vitest` installed — so `vitest` is not declared as a +peer dependency. + To use it, add [`@hapi/oxc-plugin`](https://github.com/hapijs/oxc-plugin) to your `package.json`, then in your `vitest.config.js` add: diff --git a/package.json b/package.json index 149d49f..0248400 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hapi/oxc-plugin", "version": "1.0.0", - "description": "ESLint plugin containing hapi style guide rules and config", + "description": "Oxlint & Oxfmt plugin containing hapi style guide rules and config", "keywords": [ "hapi", "lint", @@ -55,9 +55,8 @@ "vitest": "^4.1.8" }, "peerDependencies": { - "oxfmt": "0.46.0", - "oxlint": "1.61.0", - "vitest": "^4.1.5" + "oxfmt": ">=0.53.0", + "oxlint": ">=1.68.0" }, "engines": { "node": ">=22" diff --git a/src/configs/recommended.js b/src/configs/recommended.js index 6983f05..ecde83e 100644 --- a/src/configs/recommended.js +++ b/src/configs/recommended.js @@ -10,7 +10,6 @@ export default { '@hapi/capitalize-modules': ['warn', 'global-scope-only'], '@hapi/for-loop': ['warn', { maxDepth: 3, startIterator: 'i' }], '@hapi/no-var': 'error', - // '@hapi/scope-start': 'warn', // This rule conflicts with oxfmt for now '@hapi/no-arrowception': 'error', 'no-constant-condition': 'error', @@ -35,7 +34,9 @@ export default { 'no-new-wrappers': 'error', 'no-ex-assign': 'error', 'prefer-const': ['error', { destructuring: 'all' }], - 'func-style': ['error', 'expression'], + // Off for ESM: packages use exported `function` declarations (`export function foo() {}`), + // not the CJS-era `const foo = function () {}` expression form. + 'func-style': 'off', 'no-unsafe-finally': 'error', 'no-useless-computed-key': 'error', 'require-await': 'error', @@ -53,7 +54,6 @@ export default { 'object-shorthand': ['error', 'properties'], 'node/handle-callback-err': ['error', '^(e|err|error)$'], 'unicorn/no-new-buffer': 'error', - 'node/no-new-require': 'error', 'typescript/dot-notation': 'warn', 'eslint/no-object-constructor': 'error', 'eslint/no-new-native-nonconstructor': 'error', @@ -68,6 +68,9 @@ export default { 'eslint/no-regex-spaces': 'off', 'eslint/no-lonely-if': 'off', 'eslint/no-sparse-arrays': 'error', + // Off: hapi style deliberately over-escapes characters in regex character classes + // and literals for readability (e.g. `[\!\:\|]`). oxlint enables this by default. + 'no-useless-escape': 'off', // Unsupported 'eslint-js/consistent-this': ['error', 'self'], diff --git a/src/index.js b/src/index.js index e84e9c5..5ee587c 100755 --- a/src/index.js +++ b/src/index.js @@ -4,7 +4,6 @@ import CapitalizeModules from './rules/capitalize-modules.js'; import ForLoop from './rules/for-loop.js'; import NoArrowception from './rules/no-arrowception.js'; import NoVar from './rules/no-var.js'; -import ScopeStart from './rules/scope-start.js'; export default definePlugin({ meta: { @@ -14,7 +13,6 @@ export default definePlugin({ 'capitalize-modules': CapitalizeModules, 'for-loop': ForLoop, 'no-var': NoVar, - 'scope-start': ScopeStart, 'no-arrowception': NoArrowception, }, }); diff --git a/src/rules/scope-start.js b/src/rules/scope-start.js deleted file mode 100644 index ed150a4..0000000 --- a/src/rules/scope-start.js +++ /dev/null @@ -1,97 +0,0 @@ -import { defineRule } from '@oxlint/plugins'; - -export default defineRule({ - meta: { - type: 'layout', - docs: { - description: 'enforce new line at the beginning of function scope', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'whitespace', - schema: [ - { - enum: ['allow-one-liners'], - }, - { - type: 'integer', - }, - ], - messages: { - missingBlank: 'Missing blank line at beginning of function.', - }, - }, - createOnce(context) { - const checkFunction = function (node) { - const allowOneLiners = context.options[0] === 'allow-one-liners'; - check(node, allowOneLiners); - }; - - const checkArrow = function (node) { - check(node, true); - }; - - const check = function (node, allowOneLiners) { - const sourceCode = context.sourceCode; - const maxInOneLiner = context.options[1] !== undefined ? context.options[1] : 1; - - const fnBody = node.body; - - // Arrow functions can return literals that span multiple lines - - if (fnBody.type === 'ObjectExpression' || fnBody.type === 'ArrayExpression') { - return; - } - - const isBlockBody = fnBody.type === 'BlockStatement'; - const body = isBlockBody ? fnBody.body : [fnBody]; - - // Allow empty function bodies to be of any size - - if (body.length === 0) { - return; - } - - const stmt = body[0]; - const openToken = sourceCode.getTokenBefore(stmt); - const openTokenLine = openToken.loc.start.line; - const commentsBefore = sourceCode.getCommentsBefore(stmt); - const firstThing = - commentsBefore.length > 0 && commentsBefore[0].range[0] > openToken.range[1] ? commentsBefore[0] : stmt; - const bodyStartLine = firstThing.loc.start.line; - const closeTokenLine = isBlockBody - ? sourceCode.getTokenAfter(stmt).loc.start.line - : sourceCode.getLastToken(stmt).loc.start.line; - - if (allowOneLiners === true && body.length <= maxInOneLiner && openTokenLine === closeTokenLine) { - return; - } - - if (bodyStartLine - openTokenLine < 2) { - context.report({ - node, - messageId: 'missingBlank', - fix(fixer) { - const commentsAfter = sourceCode.getCommentsAfter(openToken); - let lastTokenOnOpenLine = openToken; - for (const comment of commentsAfter) { - if (comment.loc.start.line === openTokenLine) { - lastTokenOnOpenLine = comment; - } else { - break; - } - } - - return fixer.insertTextAfter(lastTokenOnOpenLine, '\n'); - }, - }); - } - }; - - return { - ArrowFunctionExpression: checkArrow, - FunctionExpression: checkFunction, - FunctionDeclaration: checkFunction, - }; - }, -}); diff --git a/test/configs/fixtures/hapi-scope-start.js b/test/configs/fixtures/hapi-scope-start.js deleted file mode 100644 index 6f593d3..0000000 --- a/test/configs/fixtures/hapi-scope-start.js +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable no-unused-vars */ -const foo = function () { - return 'there should be a blank line before this line'; -}; - -const bar = function () { - - return 'no lint errors'; -}; - -const baz = () => { - - return 'no lint errors'; -}; - -const quux = () => 85; // no lint errors - -const buux = () => ({ - a: 'b' -}); diff --git a/test/configs/recommended.js b/test/configs/recommended.js index 27ce4b9..42c5f21 100644 --- a/test/configs/recommended.js +++ b/test/configs/recommended.js @@ -73,22 +73,6 @@ describe.concurrent('recommended config', () => { expect(msg.column).toBe(5); }); - it.skip('enforces @hapi/scope-start', async ({ expect }) => { - const output = await lintFile('fixtures/hapi-scope-start.js'); - const results = output[0]; - - expect(results.errorCount).toBe(0); - expect(results.warningCount).toBe(1); - - const msg = results.messages[0]; - - expect(msg.ruleId).toBe('@hapi(scope-start)'); - expect(msg.severity).toBe(1); - expect(msg.message).toBe('Missing blank line at beginning of function.'); - expect(msg.line).toBe(2); - expect(msg.column).toBe(13); - }); - it('enforces @hapi/capitalize-modules', async ({ expect }) => { const output = await lintFile('fixtures/hapi-capitalize-modules.js'); const results = output[0]; diff --git a/test/index.js b/test/index.js index 3ecbfc2..4b08afd 100755 --- a/test/index.js +++ b/test/index.js @@ -9,11 +9,10 @@ describe('ESLint Plugin', () => { const rules = Object.keys(Plugin.rules); - expect(rules.length).toBe(5); + expect(rules.length).toBe(4); expect(rules.includes('capitalize-modules')).toBe(true); expect(rules.includes('for-loop')).toBe(true); expect(rules.includes('no-var')).toBe(true); - expect(rules.includes('scope-start')).toBe(true); expect(rules.includes('no-arrowception')).toBe(true); }); }); diff --git a/test/rules/scope-start.js b/test/rules/scope-start.js deleted file mode 100644 index 3df6cb6..0000000 --- a/test/rules/scope-start.js +++ /dev/null @@ -1,249 +0,0 @@ -import { RuleTester } from 'oxlint/plugins-dev'; -import { describe, it } from 'vitest'; - -import HapiRecommended from '../../src/configs/recommended.js'; -import Rule from '../../src/rules/scope-start'; - -const ruleTester = new RuleTester(HapiRecommended); - -describe('scope-start rule', () => { - it('reports warning when function body does not begin with a blank line', () => { - const invalids = [ - [ - `function fn() { - return; - }`, - `function fn() { - - return; - }`, - ], - [ - `function fn(foo, bar, baz) { - const fizz = 1; - }`, - `function fn(foo, bar, baz) { - - const fizz = 1; - }`, - ], - [ - `function fn(foo) { - return 'foo'; - }`, - `function fn(foo) { - - return 'foo'; - }`, - ], - [ - `function fn() {/*test*/ - return; - }`, - `function fn() {/*test*/ - - return; - }`, - ], - [ - `function fn() { // test - return; - }`, - `function fn() { // test - - return; - }`, - ], - [ - `function fn() { - /* test */ return; - }`, - `function fn() { - - /* test */ return; - }`, - ], - [ - `function fn() { - /* test */ - return; - }`, - `function fn() { - - /* test */ - return; - }`, - ], - [ - `function fn() { - /* test */ // test - return; - }`, - `function fn() { - - /* test */ // test - return; - }`, - ], - [ - `function fn() {/*test*//*test2*/ - return; - }`, - `function fn() {/*test*//*test2*/ - - return; - }`, - ], - ['function fn() { return; }', 'function fn() {\n return; }'], - ['function fn(foo, bar, baz) { return; }', 'function fn(foo, bar, baz) {\n return; }'], - ]; - - ruleTester.run('test', Rule, { - valid: [], - invalid: invalids.map(([code, output]) => { - return { - code, - output, - errors: [{ message: 'Missing blank line at beginning of function.' }], - }; - }), - }); - }); - - it('does not report anything when function body begins with a blank line', () => { - const valids = [ - `function fn() { - - return; - }`, - `function fn(foo, bar, baz) { - - const fizz = 1; - }`, - `function fn(foo) { - - return 'foo'; - }`, - `function fn() {/*test*/ - - return; - }`, - ]; - - ruleTester.run('test', Rule, { - valid: valids.map((code) => { - return { code }; - }), - invalid: [], - }); - }); - - it('does not report anything when function is one line and allow-one-liners is set', () => { - const valids = ['function fn() { return; }', 'function fn(foo, bar, baz) { return; }']; - - ruleTester.run('test', Rule, { - valid: valids.map((code) => { - return { - code, - options: ['allow-one-liners'], - }; - }), - invalid: [], - }); - }); - - it('reports an error when function is allow-one-liners is set but function body contains too many statements', () => { - ruleTester.run('test', Rule, { - valid: [], - invalid: [ - { - code: 'function fn() { let i = 0; i++; return; }', - output: 'function fn() {\n let i = 0; i++; return; }', - options: ['allow-one-liners', 2], - errors: [{ message: 'Missing blank line at beginning of function.' }], - }, - ], - }); - }); - - it('allow-one-liners defaults to 1', () => { - ruleTester.run('test', Rule, { - valid: [], - invalid: [ - { - code: "function fn() { console.log('broken'); return; }", - output: "function fn() {\n console.log('broken'); return; }", - options: ['allow-one-liners'], - errors: [{ message: 'Missing blank line at beginning of function.' }], - }, - ], - }); - }); - - it('does not report anything when function body is empty', () => { - const valids = [ - 'function fn() { }', - 'function fn(foo, bar, baz) { }', - `function fn(foo) { - - }`, - 'function fn() {/*test*/ }', - ]; - - ruleTester.run('test', Rule, { - valid: valids.map((code) => { - return { code }; - }), - invalid: [], - }); - }); - - it('handles function expressions', () => { - const code = `const foo = function () { - - return; - }`; - - ruleTester.run('test', Rule, { - valid: [{ code }], - invalid: [], - }); - }); - - it('handles arrow functions', () => { - const valids = [ - 'const foo = () => {\n\nreturn;};', - 'const foo = () => {\n\nreturn;}', - 'const foo = () => 42;', - 'const foo = () => 42\n', - 'const foo = () => ({});', - 'const foo = () => ({})', - 'const foo = () => ({\nbar: 1});', - 'const foo = () => [];', - 'const foo = () => [\n1,\n2];', - 'const foo = (isTrue) ? () => bar()\n: false;', - 'const foo = (isTrue) ? true:\n () => 1;', - ].map((code) => { - return { code }; - }); - - const invalids = [ - ['const foo = () => {\nreturn;};', 'const foo = () => {\n\nreturn;};'], - ['const foo = () => {const foo = 1; return foo;};', 'const foo = () => {\nconst foo = 1; return foo;};'], - ['const foo = () => {const foo = 1;\nreturn foo;};', 'const foo = () => {\nconst foo = 1;\nreturn foo;};'], - ['const foo = () => \n12;', 'const foo = () =>\n \n12;'], - ['const foo = () => "1" + \n"2";', 'const foo = () =>\n "1" + \n"2";'], - ]; - - ruleTester.run('test', Rule, { - valid: valids, - invalid: invalids.map(([code, output]) => { - return { - code, - output, - errors: [{ message: 'Missing blank line at beginning of function.' }], - }; - }), - }); - }); -}); From 653c1908ccdea1facf1973f3ec2c4c9203c4f6da Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Thu, 4 Jun 2026 00:46:40 -0400 Subject: [PATCH 3/4] refactor: drop oxlint-plugin-eslint, ship rules with zero dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recommended config pulled `eslint-js/consistent-this` and `eslint-js/one-var` from `oxlint-plugin-eslint`. Those are the only two rules that package supplied — every other `eslint/*` rule resolves natively in the oxlint binary. Reimplement the two as native `@hapi` rules and remove the dependency entirely. - Add `@hapi/consistent-this` (alias capture must use the designated name; recommended option `self`) and `@hapi/one-var` (one variable per declaration; recommended option `never`). Both ported to the `createOnce` rule API with full unit tests. - recommended config: drop `oxlint-plugin-eslint` from `jsPlugins`, map the two rules to the new `@hapi/*` ids. - Empty the `dependencies` field. `oxlint-plugin-eslint` and the unused `globals` are removed; `@oxlint/plugins` (only `defineRule`/`definePlugin`, which are identity no-ops) moves to devDependencies — the rules are now plain object exports, so nothing is imported at lint time. Consumers install the plugin with no transitive runtime packages, which also removes the earlier `oxlint-plugin-eslint` resolution fragility. - oxfmt config: add `ignorePatterns` for `LICENSE.md` and `.github/**` so adopting the shared format does not rewrite license text or CI workflows. Verified: 57 tests pass, 100% coverage, self-lint clean. The config still lints hoek/lib and bourne/lib at 0/0, and both reimplemented rules fire as `@hapi(consistent-this)` / `@hapi(one-var)`. --- API.md | 18 ++++++++++ oxfmt.config.ts | 2 +- package.json | 6 +--- src/configs/oxfmt.config.js | 3 ++ src/configs/recommended.js | 8 ++--- src/index.js | 10 +++--- src/rules/capitalize-modules.js | 6 ++-- src/rules/consistent-this.js | 58 ++++++++++++++++++++++++++++++++ src/rules/for-loop.js | 6 ++-- src/rules/no-arrowception.js | 6 ++-- src/rules/no-var.js | 6 ++-- src/rules/one-var.js | 31 +++++++++++++++++ test/configs/recommended.js | 2 +- test/index.js | 4 ++- test/rules/consistent-this.js | 59 +++++++++++++++++++++++++++++++++ test/rules/one-var.js | 35 +++++++++++++++++++ 16 files changed, 227 insertions(+), 33 deletions(-) create mode 100644 src/rules/consistent-this.js create mode 100644 src/rules/one-var.js create mode 100644 test/rules/consistent-this.js create mode 100644 test/rules/one-var.js diff --git a/API.md b/API.md index 7e263b8..9f2995e 100644 --- a/API.md +++ b/API.md @@ -81,6 +81,24 @@ Functions can still be returned by arrow functions whose bodies use curly braces This rule does not accept any configuration options. +### consistent-this + +Enforces consistent naming when capturing the current execution context in a variable. + +The rule takes a list of permitted alias names. In the recommended configuration the only permitted alias is +`self`, so `const self = this;` is allowed while `const that = this;` is reported. It also reports a permitted +alias that is assigned a value other than `this` (for example `const self = 5;`). + +Checks are performed at assignment sites. An alias that is declared but never assigned `this` (for example a +bare `let self;`) is not reported. + +### one-var + +Requires each variable to be declared in its own statement. Combined declarations such as `let a, b;` or +`const x = 1, y = 2;` are reported and should be split into separate statements. + +This rule is configured with the string `'never'` in the recommended configuration. + ## Vitest plugin This plugin also exposes a [Vitest](https://vitest.dev) plugin at `@hapi/oxc-plugin/vitest`. It runs diff --git a/oxfmt.config.ts b/oxfmt.config.ts index 550241b..5ee924c 100644 --- a/oxfmt.config.ts +++ b/oxfmt.config.ts @@ -4,5 +4,5 @@ import DefaultOxfmtConfig from './src/configs/oxfmt.config.js'; export default defineConfig({ ...DefaultOxfmtConfig, - ignorePatterns: ['test/configs/fixtures/**'], + ignorePatterns: [...DefaultOxfmtConfig.ignorePatterns, 'test/configs/fixtures/**'], }); diff --git a/package.json b/package.json index 0248400..aafbba3 100644 --- a/package.json +++ b/package.json @@ -42,13 +42,9 @@ "lint": "oxlint && oxfmt --check", "lint:fix": "oxlint --fix && oxfmt --write" }, - "dependencies": { - "@oxlint/plugins": "1.68.0", - "globals": "^17.6.0", - "oxlint-plugin-eslint": "1.68.0" - }, "devDependencies": { "@hapi/oxc-plugin": "file:.", + "@oxlint/plugins": "1.68.0", "@vitest/coverage-v8": "^4.1.8", "oxfmt": "^0.53.0", "oxlint": "^1.68.0", diff --git a/src/configs/oxfmt.config.js b/src/configs/oxfmt.config.js index 3f972a0..5c169a4 100644 --- a/src/configs/oxfmt.config.js +++ b/src/configs/oxfmt.config.js @@ -1,6 +1,9 @@ import { defineConfig } from 'oxfmt'; export default defineConfig({ + // Non-source files that should not be reformatted in consumer repos. oxfmt + // otherwise rewrites LICENSE.md and CI workflow YAML on first adoption. + ignorePatterns: ['LICENSE.md', '.github/**'], singleQuote: true, tabWidth: 4, printWidth: 120, diff --git a/src/configs/recommended.js b/src/configs/recommended.js index ecde83e..94ec8ac 100644 --- a/src/configs/recommended.js +++ b/src/configs/recommended.js @@ -1,5 +1,5 @@ export default { - jsPlugins: ['@hapi/oxc-plugin', 'oxlint-plugin-eslint'], + jsPlugins: ['@hapi/oxc-plugin'], plugins: ['node', 'unicorn'], env: { builtin: true, @@ -11,6 +11,8 @@ export default { '@hapi/for-loop': ['warn', { maxDepth: 3, startIterator: 'i' }], '@hapi/no-var': 'error', '@hapi/no-arrowception': 'error', + '@hapi/consistent-this': ['error', 'self'], + '@hapi/one-var': ['error', 'never'], 'no-constant-condition': 'error', 'no-undef': ['error', { typeof: false }], @@ -71,9 +73,5 @@ export default { // Off: hapi style deliberately over-escapes characters in regex character classes // and literals for readability (e.g. `[\!\:\|]`). oxlint enables this by default. 'no-useless-escape': 'off', - - // Unsupported - 'eslint-js/consistent-this': ['error', 'self'], - 'eslint-js/one-var': ['error', 'never'], }, }; diff --git a/src/index.js b/src/index.js index 5ee587c..c3e2ec0 100755 --- a/src/index.js +++ b/src/index.js @@ -1,11 +1,11 @@ -import { definePlugin } from '@oxlint/plugins'; - import CapitalizeModules from './rules/capitalize-modules.js'; +import ConsistentThis from './rules/consistent-this.js'; import ForLoop from './rules/for-loop.js'; import NoArrowception from './rules/no-arrowception.js'; import NoVar from './rules/no-var.js'; +import OneVar from './rules/one-var.js'; -export default definePlugin({ +export default { meta: { name: '@hapi', }, @@ -14,5 +14,7 @@ export default definePlugin({ 'for-loop': ForLoop, 'no-var': NoVar, 'no-arrowception': NoArrowception, + 'consistent-this': ConsistentThis, + 'one-var': OneVar, }, -}); +}; diff --git a/src/rules/capitalize-modules.js b/src/rules/capitalize-modules.js index 5d561e9..7a2f809 100644 --- a/src/rules/capitalize-modules.js +++ b/src/rules/capitalize-modules.js @@ -1,6 +1,4 @@ -import { defineRule } from '@oxlint/plugins'; - -export default defineRule({ +export default { meta: { type: 'suggestion', docs: { @@ -52,4 +50,4 @@ export default defineRule({ VariableDeclarator: checkVariable, }; }, -}); +}; diff --git a/src/rules/consistent-this.js b/src/rules/consistent-this.js new file mode 100644 index 0000000..8a2d4c5 --- /dev/null +++ b/src/rules/consistent-this.js @@ -0,0 +1,58 @@ +export default { + meta: { + type: 'suggestion', + docs: { + description: 'enforce consistent naming when capturing the current execution context', + category: 'Stylistic Issues', + recommended: true, + }, + schema: { + type: 'array', + items: { + type: 'string', + minLength: 1, + }, + uniqueItems: true, + }, + messages: { + aliasNotAssignedToThis: "Designated alias '{{name}}' is not assigned to 'this'.", + unexpectedAlias: "Unexpected alias '{{name}}' for 'this'.", + }, + }, + createOnce(context) { + // Checks that a designated alias is only assigned `this`, and that `this` + // is only ever assigned to a designated alias. The scope-level check for an + // alias declared but never assigned `this` (e.g. `let self;`) is omitted — + // assignment sites cover the hapi pattern `const self = this`. + + const checkAssignment = function (node, name, value) { + const isThis = value.type === 'ThisExpression'; + const isAlias = context.options.includes(name); + + if (isAlias && (!isThis || (node.operator && node.operator !== '='))) { + context.report({ node, messageId: 'aliasNotAssignedToThis', data: { name } }); + return; + } + + if (!isAlias && isThis) { + context.report({ node, messageId: 'unexpectedAlias', data: { name } }); + } + }; + + return { + VariableDeclarator(node) { + const id = node.id; + const isDestructuring = id.type === 'ArrayPattern' || id.type === 'ObjectPattern'; + + if (node.init !== null && !isDestructuring) { + checkAssignment(node, id.name, node.init); + } + }, + AssignmentExpression(node) { + if (node.left.type === 'Identifier') { + checkAssignment(node, node.left.name, node.right); + } + }, + }; + }, +}; diff --git a/src/rules/for-loop.js b/src/rules/for-loop.js index 9a7471f..2c62054 100644 --- a/src/rules/for-loop.js +++ b/src/rules/for-loop.js @@ -1,6 +1,4 @@ -import { defineRule } from '@oxlint/plugins'; - -export default defineRule({ +export default { meta: { type: 'suggestion', docs: { @@ -101,4 +99,4 @@ export default defineRule({ 'ForStatement:exit': popStack, }; }, -}); +}; diff --git a/src/rules/no-arrowception.js b/src/rules/no-arrowception.js index 0dbe3bd..611c515 100644 --- a/src/rules/no-arrowception.js +++ b/src/rules/no-arrowception.js @@ -1,6 +1,4 @@ -import { defineRule } from '@oxlint/plugins'; - -export default defineRule({ +export default { meta: { type: 'problem', docs: { @@ -26,4 +24,4 @@ export default defineRule({ ArrowFunctionExpression: check, }; }, -}); +}; diff --git a/src/rules/no-var.js b/src/rules/no-var.js index e09a3f1..317e9e1 100644 --- a/src/rules/no-var.js +++ b/src/rules/no-var.js @@ -1,7 +1,5 @@ // Based on https://github.com/eslint/eslint/blob/master/lib/rules/no-var.js -import { defineRule } from '@oxlint/plugins'; - const internals = { scopeTypes: new Set([ 'Program', @@ -13,7 +11,7 @@ const internals = { ]), }; -export default defineRule({ +export default { meta: { type: 'suggestion', docs: { @@ -52,7 +50,7 @@ export default defineRule({ }, }; }, -}); +}; internals.getScopeNode = function (node) { if (internals.scopeTypes.has(node.type)) { diff --git a/src/rules/one-var.js b/src/rules/one-var.js new file mode 100644 index 0000000..310a2bb --- /dev/null +++ b/src/rules/one-var.js @@ -0,0 +1,31 @@ +export default { + meta: { + type: 'suggestion', + docs: { + description: 'require variables to be declared in separate statements', + category: 'Stylistic Issues', + recommended: true, + }, + schema: [ + { + enum: ['never'], + }, + ], + messages: { + split: "Split '{{type}}' declarations into multiple statements.", + }, + }, + createOnce(context) { + const check = function (node) { + // `never`: each variable must be declared in its own statement. + + if (node.declarations.length > 1) { + context.report({ node, messageId: 'split', data: { type: node.kind } }); + } + }; + + return { + VariableDeclaration: check, + }; + }, +}; diff --git a/test/configs/recommended.js b/test/configs/recommended.js index 42c5f21..77b5f78 100644 --- a/test/configs/recommended.js +++ b/test/configs/recommended.js @@ -130,7 +130,7 @@ describe.concurrent('recommended config', () => { const msg = results.messages[0]; - expect(msg.ruleId).toBe('eslint-js(one-var)'); + expect(msg.ruleId).toBe('@hapi(one-var)'); expect(msg.severity).toBe(2); expect(msg.message).toBe("Split 'let' declarations into multiple statements."); expect(msg.line).toBe(5); diff --git a/test/index.js b/test/index.js index 4b08afd..0745b45 100755 --- a/test/index.js +++ b/test/index.js @@ -9,10 +9,12 @@ describe('ESLint Plugin', () => { const rules = Object.keys(Plugin.rules); - expect(rules.length).toBe(4); + expect(rules.length).toBe(6); expect(rules.includes('capitalize-modules')).toBe(true); expect(rules.includes('for-loop')).toBe(true); expect(rules.includes('no-var')).toBe(true); expect(rules.includes('no-arrowception')).toBe(true); + expect(rules.includes('consistent-this')).toBe(true); + expect(rules.includes('one-var')).toBe(true); }); }); diff --git a/test/rules/consistent-this.js b/test/rules/consistent-this.js new file mode 100644 index 0000000..1b18a15 --- /dev/null +++ b/test/rules/consistent-this.js @@ -0,0 +1,59 @@ +import { RuleTester } from 'oxlint/plugins-dev'; +import { describe, it } from 'vitest'; + +import HapiRecommended from '../../src/configs/recommended.js'; +import Rule from '../../src/rules/consistent-this'; + +const ruleTester = new RuleTester(HapiRecommended); + +describe('consistent-this rule', () => { + it('reports a non-this value assigned to the designated alias', () => { + ruleTester.run('test', Rule, { + valid: [], + invalid: [ + { + code: 'const self = 5;', + options: ['self'], + errors: [{ message: "Designated alias 'self' is not assigned to 'this'." }], + }, + { + code: 'let self; self = 5;', + options: ['self'], + errors: [{ message: "Designated alias 'self' is not assigned to 'this'." }], + }, + { + code: 'let self; self += this;', + options: ['self'], + errors: [{ message: "Designated alias 'self' is not assigned to 'this'." }], + }, + ], + }); + }); + + it('reports this captured under an unexpected alias', () => { + ruleTester.run('test', Rule, { + valid: [], + invalid: [ + { + code: 'const that = this;', + options: ['self'], + errors: [{ message: "Unexpected alias 'that' for 'this'." }], + }, + ], + }); + }); + + it('allows this captured as the designated alias and unrelated assignments', () => { + ruleTester.run('test', Rule, { + valid: [ + { code: 'const self = this;', options: ['self'] }, + { code: 'let self; self = this;', options: ['self'] }, + { code: 'const value = 5;', options: ['self'] }, + { code: 'const node = this.node;', options: ['self'] }, + { code: 'const { x } = this;', options: ['self'] }, + { code: 'this.context = this;', options: ['self'] }, + ], + invalid: [], + }); + }); +}); diff --git a/test/rules/one-var.js b/test/rules/one-var.js new file mode 100644 index 0000000..18040d9 --- /dev/null +++ b/test/rules/one-var.js @@ -0,0 +1,35 @@ +import { RuleTester } from 'oxlint/plugins-dev'; +import { describe, it } from 'vitest'; + +import HapiRecommended from '../../src/configs/recommended.js'; +import Rule from '../../src/rules/one-var'; + +const ruleTester = new RuleTester(HapiRecommended); + +describe('one-var rule', () => { + it('reports declarations that combine multiple variables', () => { + ruleTester.run('test', Rule, { + valid: [], + invalid: [ + { code: 'let a, b;', errors: [{ message: "Split 'let' declarations into multiple statements." }] }, + { + code: 'const a = 1, b = 2;', + errors: [{ message: "Split 'const' declarations into multiple statements." }], + }, + { code: 'var a, b, c;', errors: [{ message: "Split 'var' declarations into multiple statements." }] }, + ], + }); + }); + + it('allows one variable per declaration', () => { + ruleTester.run('test', Rule, { + valid: [ + { code: 'const a = 1;' }, + { code: 'let b;' }, + { code: 'let c; let d;' }, + { code: 'const e = 1; const f = 2;' }, + ], + invalid: [], + }); + }); +}); From 6c54794614e89bbc64350392670f77d60a97b4ca Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sat, 6 Jun 2026 12:48:45 -0400 Subject: [PATCH 4/4] fix: make config tests run on Windows The test harness invoked the local binaries as `./node_modules/.bin/oxlint` and `./node_modules/.bin/oxfmt`, and piped fixtures in via `cat |`. Neither works under Windows cmd.exe, so every config test failed on windows-latest (26 in recommended.js, 8 in the duplicate oxfmt.js) while ubuntu and macOS passed. - Invoke the bare binary name (`oxlint` / `oxfmt`) and rely on the node_modules/.bin entry npm puts on PATH, which resolves on both Unix (`oxlint`) and Windows (`oxlint.cmd` via PATHEXT). - Delete test/configs/oxfmt.js and its snapshot: a strict duplicate of the oxfmt block in recommended.js (same fixtures, config, and snapshots) that also carried the non-portable `cat` pipeline. - Add .gitattributes (eol=lf) so fixtures check out with LF on Windows runners (default autocrlf=true) and match the LF snapshots. --- .gitattributes | 4 + test/configs/__snapshots__/oxfmt.js.snap | 106 ----------------------- test/configs/oxfmt.js | 43 --------- test/configs/recommended.js | 4 +- 4 files changed, 6 insertions(+), 151 deletions(-) create mode 100644 .gitattributes delete mode 100644 test/configs/__snapshots__/oxfmt.js.snap delete mode 100644 test/configs/oxfmt.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..69e44ca --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Normalize line endings to LF on checkout across platforms so the oxfmt +# snapshot tests produce identical output on Windows runners (default +# autocrlf=true would otherwise check out fixtures as CRLF). +* text=auto eol=lf diff --git a/test/configs/__snapshots__/oxfmt.js.snap b/test/configs/__snapshots__/oxfmt.js.snap deleted file mode 100644 index dcff96f..0000000 --- a/test/configs/__snapshots__/oxfmt.js.snap +++ /dev/null @@ -1,106 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`oxfmt config > formats arrow-parens.js correctly 1`] = ` -"/* eslint-disable no-unused-vars */ -const foo = (bar) => { - return bar + 1; -}; - -const baz = (quux) => { - return quux + 1; -}; -" -`; - -exports[`oxfmt config > formats arrow-spacing.js correctly 1`] = ` -"/* eslint-disable no-unused-vars */ -const foo = (bar) => { - return bar + 1; -}; - -const baz = (quux) => { - return quux + 1; -}; - -const fn = (arg) => { - return arg + 1; -}; -" -`; - -exports[`oxfmt config > formats indent.js correctly 1`] = ` -"export const foo = function (value) { - return value + 1; -}; - -export const bar = function (value) { - return value + 1; -}; -" -`; - -exports[`oxfmt config > formats indent-switch-case.js correctly 1`] = ` -"const foo = 'foo'; -let result = 0; - -switch (foo) { - case 'foo': - result = 1; - break; - - case 'bar': - result = 2; - break; - case 'baz': - result = 3; - break; -} - -result.toString(); -" -`; - -exports[`oxfmt config > formats no-extra-semi.js correctly 1`] = ` -"/* eslint-disable padding-line-between-statements */ - -export const foo = function () { - try { - } catch {} -}; -" -`; - -exports[`oxfmt config > formats semi.js correctly 1`] = ` -"export const foo = function () { - return 42; -}; - -export const bar = function () { - return 85; -}; -" -`; - -exports[`oxfmt config > formats space-before-blocks.js correctly 1`] = ` -"/* eslint-disable no-unused-vars */ - -const foo = function () {}; - -const bar = function () {}; - -const baz = function () {}; -" -`; - -exports[`oxfmt config > formats space-before-function-paren.js correctly 1`] = ` -"/* eslint-disable no-unused-vars */ - -const foo = function () {}; - -const bar = function () {}; - -const baz = function baz() {}; - -const quux = function quux() {}; -" -`; diff --git a/test/configs/oxfmt.js b/test/configs/oxfmt.js deleted file mode 100644 index 39d7c88..0000000 --- a/test/configs/oxfmt.js +++ /dev/null @@ -1,43 +0,0 @@ -import { execSync } from 'child_process'; -import Path from 'path'; - -import { describe, it, expect } from 'vitest'; - -const internals = {}; - -internals.formatFile = function (configPath, filePath) { - const command = `./node_modules/.bin/oxfmt -c ${configPath} --stdin-filepath ${filePath}`; - - try { - const output = execSync(`cat ${filePath} | ${command}`, { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - }); - return output; - } catch (err) { - console.error(err.stderr); - throw err; - } -}; - -describe('oxfmt config', () => { - const configPath = Path.join(import.meta.dirname, './oxfmt.test.config.ts'); - const fixturesDir = Path.join(import.meta.dirname, './fixtures'); - - const testFormatting = (fileName) => { - it(`formats ${fileName} correctly`, () => { - const filePath = Path.join(fixturesDir, fileName); - const output = internals.formatFile(configPath, filePath); - expect(output).toMatchSnapshot(); - }); - }; - - testFormatting('indent.js'); - testFormatting('indent-switch-case.js'); - testFormatting('semi.js'); - testFormatting('no-extra-semi.js'); - testFormatting('space-before-function-paren.js'); - testFormatting('arrow-parens.js'); - testFormatting('arrow-spacing.js'); - testFormatting('space-before-blocks.js'); -}); diff --git a/test/configs/recommended.js b/test/configs/recommended.js index 77b5f78..67ea026 100644 --- a/test/configs/recommended.js +++ b/test/configs/recommended.js @@ -9,7 +9,7 @@ describe.concurrent('recommended config', () => { const configPath = Path.join(import.meta.dirname, './oxlint.test.config.ts'); const checkFile = function (filePath) { - const command = `./node_modules/.bin/oxlint -c ${configPath} --format json --no-ignore --disable-nested-config ${filePath}`; + const command = `oxlint -c ${configPath} --format json --no-ignore --disable-nested-config ${filePath}`; let output; try { @@ -378,7 +378,7 @@ describe.concurrent('recommended config', () => { const formatFile = (file) => { const filePath = Path.join(fixturesDir, file); - const command = `./node_modules/.bin/oxfmt -c ${configPath} --stdin-filepath ${filePath}`; + const command = `oxfmt -c ${configPath} --stdin-filepath ${filePath}`; try { const output = execSync(command, {