diff --git a/README.md b/README.md index 8a54e1dea4e..195fb5ec7f4 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/heft-plugins/heft-json-schema-typings-plugin](./heft-plugins/heft-json-schema-typings-plugin/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fheft-json-schema-typings-plugin.svg)](https://badge.fury.io/js/%40rushstack%2Fheft-json-schema-typings-plugin) | [changelog](./heft-plugins/heft-json-schema-typings-plugin/CHANGELOG.md) | [@rushstack/heft-json-schema-typings-plugin](https://www.npmjs.com/package/@rushstack/heft-json-schema-typings-plugin) | | [/heft-plugins/heft-lint-plugin](./heft-plugins/heft-lint-plugin/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fheft-lint-plugin.svg)](https://badge.fury.io/js/%40rushstack%2Fheft-lint-plugin) | [changelog](./heft-plugins/heft-lint-plugin/CHANGELOG.md) | [@rushstack/heft-lint-plugin](https://www.npmjs.com/package/@rushstack/heft-lint-plugin) | | [/heft-plugins/heft-localization-typings-plugin](./heft-plugins/heft-localization-typings-plugin/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fheft-localization-typings-plugin.svg)](https://badge.fury.io/js/%40rushstack%2Fheft-localization-typings-plugin) | [changelog](./heft-plugins/heft-localization-typings-plugin/CHANGELOG.md) | [@rushstack/heft-localization-typings-plugin](https://www.npmjs.com/package/@rushstack/heft-localization-typings-plugin) | +| [/heft-plugins/heft-oxlint-plugin](./heft-plugins/heft-oxlint-plugin/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fheft-oxlint-plugin.svg)](https://badge.fury.io/js/%40rushstack%2Fheft-oxlint-plugin) | [changelog](./heft-plugins/heft-oxlint-plugin/CHANGELOG.md) | [@rushstack/heft-oxlint-plugin](https://www.npmjs.com/package/@rushstack/heft-oxlint-plugin) | | [/heft-plugins/heft-rspack-plugin](./heft-plugins/heft-rspack-plugin/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fheft-rspack-plugin.svg)](https://badge.fury.io/js/%40rushstack%2Fheft-rspack-plugin) | [changelog](./heft-plugins/heft-rspack-plugin/CHANGELOG.md) | [@rushstack/heft-rspack-plugin](https://www.npmjs.com/package/@rushstack/heft-rspack-plugin) | | [/heft-plugins/heft-sass-load-themed-styles-plugin](./heft-plugins/heft-sass-load-themed-styles-plugin/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fheft-sass-load-themed-styles-plugin.svg)](https://badge.fury.io/js/%40rushstack%2Fheft-sass-load-themed-styles-plugin) | [changelog](./heft-plugins/heft-sass-load-themed-styles-plugin/CHANGELOG.md) | [@rushstack/heft-sass-load-themed-styles-plugin](https://www.npmjs.com/package/@rushstack/heft-sass-load-themed-styles-plugin) | | [/heft-plugins/heft-sass-plugin](./heft-plugins/heft-sass-plugin/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fheft-sass-plugin.svg)](https://badge.fury.io/js/%40rushstack%2Fheft-sass-plugin) | [changelog](./heft-plugins/heft-sass-plugin/CHANGELOG.md) | [@rushstack/heft-sass-plugin](https://www.npmjs.com/package/@rushstack/heft-sass-plugin) | diff --git a/common/changes/@rushstack/heft-oxlint-plugin/add-heft-oxlint-plugin.json b/common/changes/@rushstack/heft-oxlint-plugin/add-heft-oxlint-plugin.json new file mode 100644 index 00000000000..5a514365f7a --- /dev/null +++ b/common/changes/@rushstack/heft-oxlint-plugin/add-heft-oxlint-plugin.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-oxlint-plugin", + "comment": "Initial release of a Heft plugin for running oxlint, the fast Rust-based JavaScript/TypeScript linter.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-oxlint-plugin", + "email": "yashwanth195@users.noreply.github.com" +} diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index 0f1ebb8d9ab..701a4296c80 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -242,6 +242,10 @@ "name": "@rushstack/heft-node-rig", "allowedCategories": [ "libraries", "tests", "vscode-extensions" ] }, + { + "name": "@rushstack/heft-oxlint-plugin", + "allowedCategories": [ "libraries" ] + }, { "name": "@rushstack/heft-rspack-plugin", "allowedCategories": [ "libraries", "tests" ] @@ -918,6 +922,10 @@ "name": "object-hash", "allowedCategories": [ "libraries" ] }, + { + "name": "oxlint", + "allowedCategories": [ "libraries" ] + }, { "name": "package-extractor-test-02", "allowedCategories": [ "tests" ] diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index a6e518999de..e55497bcaf0 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3338,6 +3338,31 @@ importers: specifier: workspace:* version: link:../../rigs/local-node-rig + ../../../heft-plugins/heft-oxlint-plugin: + dependencies: + '@rushstack/node-core-library': + specifier: workspace:* + version: link:../../libraries/node-core-library + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + '@rushstack/heft-typescript-plugin': + specifier: workspace:* + version: link:../heft-typescript-plugin + '@rushstack/terminal': + specifier: workspace:* + version: link:../../libraries/terminal + eslint: + specifier: ~9.37.0 + version: 9.37.0 + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + oxlint: + specifier: ~1.71.0 + version: 1.71.0 + ../../../heft-plugins/heft-rspack-plugin: dependencies: '@rspack/dev-server': @@ -4583,7 +4608,7 @@ importers: version: 1.2.22(@types/node@20.17.19) '@rushstack/heft-node-rig': specifier: 2.11.45 - version: 2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0) + version: 2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0) '@types/jest': specifier: 30.0.0 version: 30.0.0 @@ -8532,6 +8557,120 @@ packages: engines: {node: '>=10'} deprecated: This functionality has been moved to @npmcli/fs + '@oxlint/binding-android-arm-eabi@1.71.0': + resolution: {integrity: sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.71.0': + resolution: {integrity: sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.71.0': + resolution: {integrity: sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.71.0': + resolution: {integrity: sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.71.0': + resolution: {integrity: sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.71.0': + resolution: {integrity: sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.71.0': + resolution: {integrity: sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.71.0': + resolution: {integrity: sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.71.0': + resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.71.0': + resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.71.0': + resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.71.0': + resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.71.0': + resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.71.0': + resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.71.0': + resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.71.0': + resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.71.0': + resolution: {integrity: sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.71.0': + resolution: {integrity: sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.71.0': + resolution: {integrity: sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@peculiar/asn1-cms@2.6.1': resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==} @@ -8750,7 +8889,7 @@ packages: engines: {node: '>=10.16'} '@pnpm/patching.types@1000.1.0': - resolution: {integrity: sha512-Zib2ysLctRnWM4KXXlljR44qSKwyEqYmLk+8VPBDBEK3l5Gp5mT3N4ix9E4qjYynvFqahumsxzOfxOYQhUGMGw==} + resolution: {integrity: sha1-1AOzl0N4A7hZx/sullgMiXtitx4=} engines: {node: '>=18.12'} '@pnpm/patching.types@900.0.0': @@ -8813,7 +8952,7 @@ packages: engines: {node: '>=14.6'} '@pnpm/types@9.4.2': - resolution: {integrity: sha512-g1hcF8Nv4gd76POilz9gD4LITAPXOe5nX4ijgr8ixCbLQZfcpYiMfJ+C1RlMNRUDo8vhlNB4O3bUlxmT6EAQXA==} + resolution: {integrity: sha1-CjTDxB1UUkYdjYlYN0pyf5xGz7I=} engines: {node: '>=16.14'} '@pnpm/types@900.0.0': @@ -15917,6 +16056,19 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + oxlint@1.71.0: + resolution: {integrity: sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + p-all@2.1.0: resolution: {integrity: sha512-HbZxz5FONzz/z2gJfk6bFca0BCiSRF8jU3yCsWOen/vR6lZjfPOu/e7L3uFzTW1i0H8TlC3vqQstEJPQL4/uLA==} engines: {node: '>=6'} @@ -23622,6 +23774,63 @@ snapshots: mkdirp: 1.0.4 rimraf: 3.0.2 + '@oxlint/binding-android-arm-eabi@1.71.0': + optional: true + + '@oxlint/binding-android-arm64@1.71.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.71.0': + optional: true + + '@oxlint/binding-darwin-x64@1.71.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.71.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.71.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.71.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.71.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.71.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.71.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.71.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.71.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.71.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.71.0': + optional: true + '@peculiar/asn1-cms@2.6.1': dependencies: '@peculiar/asn1-schema': 2.6.0 @@ -24642,7 +24851,7 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@rushstack/heft-jest-plugin@2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)': + '@rushstack/heft-jest-plugin@2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)': dependencies: '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) '@jest/reporters': 30.3.0 @@ -24675,13 +24884,13 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@rushstack/heft-node-rig@2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)': + '@rushstack/heft-node-rig@2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)': dependencies: '@microsoft/api-extractor': 7.58.12(@types/node@20.17.19) '@rushstack/eslint-config': 4.6.4(eslint@9.37.0)(typescript@5.8.2) '@rushstack/heft': 1.2.22(@types/node@20.17.19) '@rushstack/heft-api-extractor-plugin': 1.3.22(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) - '@rushstack/heft-jest-plugin': 2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0) + '@rushstack/heft-jest-plugin': 2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0) '@rushstack/heft-lint-plugin': 1.2.22(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) '@rushstack/heft-typescript-plugin': 1.3.17(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) '@types/jest': 30.0.0 @@ -34660,6 +34869,28 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxlint@1.71.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.71.0 + '@oxlint/binding-android-arm64': 1.71.0 + '@oxlint/binding-darwin-arm64': 1.71.0 + '@oxlint/binding-darwin-x64': 1.71.0 + '@oxlint/binding-freebsd-x64': 1.71.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.71.0 + '@oxlint/binding-linux-arm-musleabihf': 1.71.0 + '@oxlint/binding-linux-arm64-gnu': 1.71.0 + '@oxlint/binding-linux-arm64-musl': 1.71.0 + '@oxlint/binding-linux-ppc64-gnu': 1.71.0 + '@oxlint/binding-linux-riscv64-gnu': 1.71.0 + '@oxlint/binding-linux-riscv64-musl': 1.71.0 + '@oxlint/binding-linux-s390x-gnu': 1.71.0 + '@oxlint/binding-linux-x64-gnu': 1.71.0 + '@oxlint/binding-linux-x64-musl': 1.71.0 + '@oxlint/binding-openharmony-arm64': 1.71.0 + '@oxlint/binding-win32-arm64-msvc': 1.71.0 + '@oxlint/binding-win32-ia32-msvc': 1.71.0 + '@oxlint/binding-win32-x64-msvc': 1.71.0 + p-all@2.1.0: dependencies: p-map: 2.1.0 diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index eb492e850ed..cc5d3da6e40 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "288ee1cccceca305122bc3d4b1eb550efbcb2a0b", + "pnpmShrinkwrapHash": "847e28e5ca77a8b155bc1b4fbc347b6796ee7020", "preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c" } diff --git a/heft-plugins/heft-oxlint-plugin/.npmignore b/heft-plugins/heft-oxlint-plugin/.npmignore new file mode 100644 index 00000000000..f7a40e10213 --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/.npmignore @@ -0,0 +1,36 @@ +# THIS IS A STANDARD TEMPLATE FOR .npmignore FILES IN THIS REPO. + +# Ignore all files by default, to avoid accidentally publishing unintended files. +* + +# Use negative patterns to bring back the specific things we want to publish. +!/bin/** +!/lib/** +!/lib-*/** +!/dist/** +!/includes/** + +!CHANGELOG.md +!CHANGELOG.json +!heft-plugin.json +!rush-plugin-manifest.json +!ThirdPartyNotice.txt + +# Ignore certain patterns that should not get published. +/dist/*.stats.* +/lib/**/test/ +/lib-*/**/test/ +*.test.js +*.test.[cm]js +*.test.d.ts +*.test.d.[cm]ts + +# NOTE: These don't need to be specified, because NPM includes them automatically. +# +# package.json +# README.md +# LICENSE + +# --------------------------------------------------------------------------- +# DO NOT MODIFY ABOVE THIS LINE! Add any project-specific overrides below. +# --------------------------------------------------------------------------- diff --git a/heft-plugins/heft-oxlint-plugin/LICENSE b/heft-plugins/heft-oxlint-plugin/LICENSE new file mode 100644 index 00000000000..603a14c7a67 --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/LICENSE @@ -0,0 +1,24 @@ +@rushstack/heft-oxlint-plugin + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/heft-plugins/heft-oxlint-plugin/README.md b/heft-plugins/heft-oxlint-plugin/README.md new file mode 100644 index 00000000000..c35b3184b66 --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/README.md @@ -0,0 +1,19 @@ +# @rushstack/heft-oxlint-plugin + +This is a Heft plugin to run [oxlint](https://oxc.rs/docs/guide/usage/linter), the fast Rust-based +JavaScript/TypeScript linter from the Oxc project. + +This plugin runs as a standalone task that invokes the `oxlint` binary and reports its findings +through the Heft logger. Type-aware linting is supported via the `typeAware` option (which passes +`--type-aware` to oxlint); enabling it additionally requires the `oxlint-tsgolint` package. As with +the `oxlint` package itself, `oxlint-tsgolint` is resolved from the consuming project or its shared +rig, so it can be installed once in a rig rather than in every project. + +## Links + +- [CHANGELOG.md]( + https://github.com/microsoft/rushstack/blob/main/heft-plugins/heft-oxlint-plugin/CHANGELOG.md) - Find + out what's new in the latest version +- [@rushstack/heft](https://www.npmjs.com/package/@rushstack/heft) - Heft is a config-driven toolchain that invokes popular tools such as TypeScript, ESLint, Jest, Webpack, and API Extractor. + +Heft is part of the [Rush Stack](https://rushstack.io/) family of projects. diff --git a/heft-plugins/heft-oxlint-plugin/config/heft.json b/heft-plugins/heft-oxlint-plugin/config/heft.json new file mode 100644 index 00000000000..0e52387039a --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/config/heft.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + "extends": "local-node-rig/profiles/default/config/heft.json", + + "phasesByName": { + "build": { + "tasksByName": { + "copy-json-schemas": { + "taskPlugin": { + "pluginPackage": "@rushstack/heft", + "pluginName": "copy-files-plugin", + "options": { + "copyOperations": [ + { + "sourcePath": "src/schemas", + "destinationFolders": ["temp/json-schemas/heft/v1"], + "fileExtensions": [".schema.json"], + "hardlink": true + } + ] + } + } + } + } + } + } +} diff --git a/heft-plugins/heft-oxlint-plugin/config/rig.json b/heft-plugins/heft-oxlint-plugin/config/rig.json new file mode 100644 index 00000000000..165ffb001f5 --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/config/rig.json @@ -0,0 +1,7 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "local-node-rig" +} diff --git a/heft-plugins/heft-oxlint-plugin/eslint.config.js b/heft-plugins/heft-oxlint-plugin/eslint.config.js new file mode 100644 index 00000000000..c15e6077310 --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/eslint.config.js @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); + +module.exports = [ + ...nodeTrustedToolProfile, + ...friendlyLocalsMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + } + } +]; diff --git a/heft-plugins/heft-oxlint-plugin/heft-plugin.json b/heft-plugins/heft-oxlint-plugin/heft-plugin.json new file mode 100644 index 00000000000..ad136730aad --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/heft-plugin.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft-plugin.schema.json", + + "taskPlugins": [ + { + "pluginName": "oxlint-plugin", + "entryPoint": "./lib-commonjs/OxlintPlugin", + "optionsSchema": "./lib-commonjs/schemas/heft-oxlint-plugin.schema.json", + + "parameterScope": "oxlint", + "parameters": [ + { + "longName": "--fix", + "parameterKind": "flag", + "description": "Fix all encountered rule violations where the violated rule provides a fixer. When running in production mode, fixes will be disabled regardless of this parameter." + } + ] + } + ] +} diff --git a/heft-plugins/heft-oxlint-plugin/package.json b/heft-plugins/heft-oxlint-plugin/package.json new file mode 100644 index 00000000000..4101f0208b0 --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/package.json @@ -0,0 +1,52 @@ +{ + "name": "@rushstack/heft-oxlint-plugin", + "version": "0.0.0", + "description": "A Heft plugin for using oxlint, the fast Rust-based JavaScript/TypeScript linter", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/rushstack.git", + "directory": "heft-plugins/heft-oxlint-plugin" + }, + "homepage": "https://rushstack.io/pages/heft/overview/", + "license": "MIT", + "scripts": { + "build": "heft build --clean", + "start": "heft build-watch", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "peerDependencies": { + "@rushstack/heft": "^1.2.16", + "oxlint": "^1.71.0" + }, + "dependencies": { + "@rushstack/node-core-library": "workspace:*" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "@rushstack/heft-typescript-plugin": "workspace:*", + "@rushstack/terminal": "workspace:*", + "local-node-rig": "workspace:*", + "eslint": "~9.37.0", + "oxlint": "~1.71.0" + }, + "exports": { + "./lib/*.schema.json": "./lib-commonjs/*.schema.json", + "./lib/*": { + "types": "./lib-dts/*.d.ts", + "node": "./lib-commonjs/*.js", + "import": "./lib-esm/*.js", + "require": "./lib-commonjs/*.js" + }, + "./heft-plugin.json": "./heft-plugin.json", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "lib/*": [ + "lib-dts/*" + ] + } + }, + "sideEffects": false +} diff --git a/heft-plugins/heft-oxlint-plugin/src/OxlintHelpers.ts b/heft-plugins/heft-oxlint-plugin/src/OxlintHelpers.ts new file mode 100644 index 00000000000..db2f143f5d1 --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/src/OxlintHelpers.ts @@ -0,0 +1,552 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileError } from '@rushstack/node-core-library'; + +/** + * Options for the oxlint plugin, specified in the "options" field of heft.json. + * + * @remarks + * These options expose the full oxlint command-line surface. Anything that can also be expressed in + * an `.oxlintrc.json` file (rules, plugins, ignores, etc.) may be configured there instead; the + * options below are layered on top of oxlint's own configuration resolution. + * + * @public + */ +export interface IOxlintPluginOptions { + // ----- Basic configuration ----- + + /** + * An optional path (relative to the project root) to an oxlint configuration file + * (e.g. ".oxlintrc.json"). Maps to oxlint's `--config`. If omitted, oxlint searches for a + * configuration file using its default resolution. + */ + configFilePath?: string; + + /** + * Override the TypeScript config used for import resolution. Maps to oxlint's `--tsconfig`. + */ + tsconfigFilePath?: string; + + /** + * The list of file or folder paths (relative to the project root) that oxlint should lint. + * Defaults to ["src"]. + */ + paths?: string[]; + + // ----- Allowing / denying rules and categories ----- + + /** + * Allow (suppress) the listed rules or categories. Maps to oxlint's `--allow` (`-A`). + */ + allow?: string[]; + + /** + * Warn on the listed rules or categories. Maps to oxlint's `--warn` (`-W`). + */ + warn?: string[]; + + /** + * Deny (error on) the listed rules or categories. Maps to oxlint's `--deny` (`-D`). + */ + deny?: string[]; + + // ----- Plugins ----- + + /** Disable the unicorn plugin, which is on by default. Maps to `--disable-unicorn-plugin`. */ + disableUnicornPlugin?: boolean; + /** Disable oxc unique rules, which are on by default. Maps to `--disable-oxc-plugin`. */ + disableOxcPlugin?: boolean; + /** Disable the TypeScript plugin, which is on by default. Maps to `--disable-typescript-plugin`. */ + disableTypeScriptPlugin?: boolean; + /** Enable the import plugin. Maps to `--import-plugin`. */ + importPlugin?: boolean; + /** Enable the react plugin. Maps to `--react-plugin`. */ + reactPlugin?: boolean; + /** Enable the jsdoc plugin. Maps to `--jsdoc-plugin`. */ + jsdocPlugin?: boolean; + /** Enable the Jest plugin. Maps to `--jest-plugin`. */ + jestPlugin?: boolean; + /** Enable the Vitest plugin. Maps to `--vitest-plugin`. */ + vitestPlugin?: boolean; + /** Enable the JSX-a11y plugin. Maps to `--jsx-a11y-plugin`. */ + jsxA11yPlugin?: boolean; + /** Enable the Next.js plugin. Maps to `--nextjs-plugin`. */ + nextjsPlugin?: boolean; + /** Enable the React performance plugin. Maps to `--react-perf-plugin`. */ + reactPerfPlugin?: boolean; + /** Enable the promise plugin. Maps to `--promise-plugin`. */ + promisePlugin?: boolean; + /** Enable the node plugin. Maps to `--node-plugin`. */ + nodePlugin?: boolean; + /** Enable the vue plugin. Maps to `--vue-plugin`. */ + vuePlugin?: boolean; + + // ----- Fixing ----- + + /** + * If set to true, fix all encountered rule violations where the violated rule provides a fixer, + * regardless of whether the "--fix" command-line argument is provided. Maps to oxlint's `--fix`. + * When running in production mode, fixes will be disabled regardless of this setting. + */ + alwaysFix?: boolean; + /** Apply auto-fixable suggestions. Maps to `--fix-suggestions`. Disabled in production mode. */ + fixSuggestions?: boolean; + /** Apply dangerous fixes and suggestions. Maps to `--fix-dangerously`. Disabled in production mode. */ + fixDangerously?: boolean; + + // ----- Ignoring files ----- + + /** Specify the file to use as your `.eslintignore`. Maps to `--ignore-path`. */ + ignorePath?: string; + /** Patterns of files to ignore. Maps to `--ignore-pattern`. */ + ignorePattern?: string[]; + /** Disable excluding files from ignore files/patterns. Maps to `--no-ignore`. */ + noIgnore?: boolean; + + // ----- Handling warnings ----- + + /** Disable reporting on warnings; only errors are reported. Maps to `--quiet`. */ + quiet?: boolean; + /** Ensure warnings produce a non-zero exit code. Maps to `--deny-warnings`. */ + denyWarnings?: boolean; + /** Warning threshold that forces an error exit status when exceeded. Maps to `--max-warnings`. */ + maxWarnings?: number; + + // ----- Miscellaneous ----- + + /** Number of threads to use. Set to 1 to use a single CPU core. Maps to `--threads`. */ + threads?: number; + /** Do not error when no files are selected for linting. Maps to `--no-error-on-unmatched-pattern`. */ + noErrorOnUnmatchedPattern?: boolean; + /** Disable automatic loading of nested configuration files. Maps to `--disable-nested-config`. */ + disableNestedConfig?: boolean; + /** + * Enable rules that require type information. Maps to `--type-aware`. Requires the + * `oxlint-tsgolint` package, which is resolved from the consuming project or its shared rig. + */ + typeAware?: boolean; + /** + * Enable experimental type checking (includes TypeScript compiler diagnostics). Maps to + * `--type-check`. Requires the `oxlint-tsgolint` package, which is resolved from the consuming + * project or its shared rig. + */ + typeCheck?: boolean; + /** Report unused inline disable directives. Maps to `--report-unused-disable-directives`. */ + reportUnusedDisableDirectives?: boolean; + /** + * Report unused inline disable directives at the specified severity. Maps to + * `--report-unused-disable-directives-severity`. Mutually exclusive with + * `reportUnusedDisableDirectives`. + */ + reportUnusedDisableDirectivesSeverity?: 'off' | 'warn' | 'error'; + + // ----- Output ----- + + /** + * If specified, a Static Analysis Results Interchange Format (SARIF) log of all findings will be + * written to the provided path, relative to the project root. When set, oxlint is run once with + * `--format=sarif` and the reported diagnostics are extracted from that same SARIF output, so no + * additional oxlint invocation is incurred. + */ + sarifLogPath?: string; +} + +/** + * The shape of a single diagnostic produced by `oxlint --format=json`. + */ +export interface IOxlintDiagnosticSpan { + offset: number; + length: number; + line: number; + column: number; +} + +export interface IOxlintDiagnosticLabel { + label?: string; + span?: IOxlintDiagnosticSpan; +} + +export interface IOxlintDiagnostic { + message: string; + code?: string; + severity: 'error' | 'warning' | 'advice'; + help?: string; + url?: string; + filename: string; + labels?: IOxlintDiagnosticLabel[]; +} + +export interface IOxlintJsonOutput { + diagnostics: IOxlintDiagnostic[]; +} + +// ----- SARIF types (subset needed for diagnostic extraction) ----- + +export interface IOxlintSarifRegion { + startLine?: number; + startColumn?: number; +} + +export interface IOxlintSarifPhysicalLocation { + artifactLocation?: { uri?: string }; + region?: IOxlintSarifRegion; +} + +export interface IOxlintSarifLocation { + physicalLocation?: IOxlintSarifPhysicalLocation; +} + +export interface IOxlintSarifResult { + level?: string; + message?: { text?: string }; + ruleId?: string; + locations?: IOxlintSarifLocation[]; +} + +export interface IOxlintSarifRun { + results?: IOxlintSarifResult[]; +} + +export interface IOxlintSarifLog { + runs?: IOxlintSarifRun[]; +} + +const DEFAULT_PATHS: ReadonlyArray = ['src']; + +// The file extensions that oxlint is able to lint. Changed files with any other extension (for +// example generated ".json" or ".css" assets that appear in a TypeScript program) are skipped. +const LINTABLE_EXTENSIONS: ReadonlySet = new Set([ + '.js', + '.mjs', + '.cjs', + '.jsx', + '.ts', + '.mts', + '.cts', + '.tsx' +]); + +// Declaration file suffixes are excluded, since linting hand-authored or generated type +// declarations rarely produces actionable diagnostics. +const DECLARATION_FILE_SUFFIXES: ReadonlyArray = ['.d.ts', '.d.mts', '.d.cts']; + +// Maps each boolean plugin option to its oxlint command-line flag. +const BOOLEAN_FLAGS: ReadonlyArray<[keyof IOxlintPluginOptions, string]> = [ + ['disableUnicornPlugin', '--disable-unicorn-plugin'], + ['disableOxcPlugin', '--disable-oxc-plugin'], + ['disableTypeScriptPlugin', '--disable-typescript-plugin'], + ['importPlugin', '--import-plugin'], + ['reactPlugin', '--react-plugin'], + ['jsdocPlugin', '--jsdoc-plugin'], + ['jestPlugin', '--jest-plugin'], + ['vitestPlugin', '--vitest-plugin'], + ['jsxA11yPlugin', '--jsx-a11y-plugin'], + ['nextjsPlugin', '--nextjs-plugin'], + ['reactPerfPlugin', '--react-perf-plugin'], + ['promisePlugin', '--promise-plugin'], + ['nodePlugin', '--node-plugin'], + ['vuePlugin', '--vue-plugin'], + ['noIgnore', '--no-ignore'], + ['quiet', '--quiet'], + ['denyWarnings', '--deny-warnings'], + ['noErrorOnUnmatchedPattern', '--no-error-on-unmatched-pattern'], + ['disableNestedConfig', '--disable-nested-config'], + ['typeAware', '--type-aware'], + ['typeCheck', '--type-check'], + ['reportUnusedDisableDirectives', '--report-unused-disable-directives'] +]; + +/** + * Builds the oxlint command-line arguments shared by every invocation, excluding `--format`, + * fix-related flags, and the positional paths. + */ +export function buildCommonArgs(options: IOxlintPluginOptions | undefined): string[] { + const args: string[] = []; + if (!options) { + return args; + } + + const { + configFilePath, + tsconfigFilePath, + allow, + warn, + deny, + ignorePath, + ignorePattern, + maxWarnings, + threads, + reportUnusedDisableDirectivesSeverity + } = options; + + if (configFilePath) { + args.push('--config', configFilePath); + } + if (tsconfigFilePath) { + args.push('--tsconfig', tsconfigFilePath); + } + + for (const rule of allow ?? []) { + args.push('--allow', rule); + } + for (const rule of warn ?? []) { + args.push('--warn', rule); + } + for (const rule of deny ?? []) { + args.push('--deny', rule); + } + + for (const [optionName, flag] of BOOLEAN_FLAGS) { + if (options[optionName]) { + args.push(flag); + } + } + + if (ignorePath) { + args.push('--ignore-path', ignorePath); + } + for (const pattern of ignorePattern ?? []) { + args.push('--ignore-pattern', pattern); + } + if (maxWarnings !== undefined) { + args.push('--max-warnings', `${maxWarnings}`); + } + if (threads !== undefined) { + args.push('--threads', `${threads}`); + } + if (reportUnusedDisableDirectivesSeverity) { + args.push('--report-unused-disable-directives-severity', reportUnusedDisableDirectivesSeverity); + } + + return args; +} + +/** + * Builds the fix-related oxlint arguments. Returns an empty array when fixing is disabled. + */ +export function buildFixArgs(fix: boolean, options: IOxlintPluginOptions | undefined): string[] { + if (!fix) { + return []; + } + const args: string[] = ['--fix']; + if (options?.fixSuggestions) { + args.push('--fix-suggestions'); + } + if (options?.fixDangerously) { + args.push('--fix-dangerously'); + } + return args; +} + +/** + * Resolves the list of positional paths that oxlint should lint, defaulting to ["src"]. + */ +export function resolveLintPaths(options: IOxlintPluginOptions | undefined): ReadonlyArray { + return options?.paths && options.paths.length > 0 ? options.paths : DEFAULT_PATHS; +} + +/** + * Given a collection of absolute file paths (typically the changed source files reported by the + * TypeScript plugin), returns the subset that oxlint should lint, expressed as paths relative to + * the build folder. Files outside the build folder, inside "node_modules", declaration files, or + * files without a lintable JavaScript/TypeScript extension are excluded. The result is sorted and + * de-duplicated for deterministic output. + */ +export function filterChangedFilePaths( + changedFilePaths: Iterable, + buildFolderPath: string +): string[] { + const relativePaths: Set = new Set(); + for (const absolutePath of changedFilePaths) { + const relativePath: string = path.relative(buildFolderPath, absolutePath); + + // Intentionally skip (not an error): the TypeScript program legitimately reports files outside + // the project, e.g. shared "lib.*.d.ts", hoisted "@types", or cross-project references. On + // Windows "path.relative" also returns an absolute path across drives. None are lintable here. + if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + continue; + } + + // Skip dependencies + if (relativePath.match(/(^|[\\/])node_modules[\\/]/)) { + continue; + } + + // Skip declaration files + const lowerCasePath: string = absolutePath.toLowerCase(); + if (DECLARATION_FILE_SUFFIXES.some((suffix) => lowerCasePath.endsWith(suffix))) { + continue; + } + + // Skip non-lintable files + if (!LINTABLE_EXTENSIONS.has(path.extname(absolutePath).toLowerCase())) { + continue; + } + + relativePaths.add(relativePath); + } + + return Array.from(relativePaths).sort(); +} + +/** + * Formats an oxlint diagnostic into a human-readable message, prefixing the rule code when present. + */ +export function formatDiagnosticMessage(diagnostic: IOxlintDiagnostic): string { + const { code, message } = diagnostic; + return code !== undefined ? `(${code}) ${message}` : message; +} + +/** + * Converts an oxlint diagnostic into a Heft {@link FileError}. + */ +export function createFileErrorForDiagnostic( + diagnostic: IOxlintDiagnostic, + buildFolderPath: string +): FileError { + const span: IOxlintDiagnosticSpan | undefined = diagnostic.labels?.[0]?.span; + return new FileError(formatDiagnosticMessage(diagnostic), { + absolutePath: path.resolve(buildFolderPath, diagnostic.filename), + projectFolder: buildFolderPath, + line: span?.line, + column: span?.column + }); +} + +/** + * Extracts diagnostics from an oxlint SARIF log, converting each SARIF result into the common + * {@link IOxlintDiagnostic} format used for error reporting. + */ +export function extractDiagnosticsFromSarif(sarifLog: IOxlintSarifLog): IOxlintDiagnostic[] { + const diagnostics: IOxlintDiagnostic[] = []; + + for (const run of sarifLog.runs ?? []) { + for (const result of run.results ?? []) { + const location: IOxlintSarifLocation | undefined = result.locations?.[0]; + const physicalLocation: IOxlintSarifPhysicalLocation | undefined = location?.physicalLocation; + const uri: string = physicalLocation?.artifactLocation?.uri ?? ''; + const region: IOxlintSarifRegion | undefined = physicalLocation?.region; + + let severity: 'error' | 'warning' | 'advice'; + switch (result.level) { + case 'error': + severity = 'error'; + break; + case 'note': + severity = 'advice'; + break; + default: + severity = 'warning'; + break; + } + + const diagnostic: IOxlintDiagnostic = { + message: result.message?.text ?? '', + code: result.ruleId, + severity, + filename: uri, + labels: region + ? [{ span: { offset: 0, length: 0, line: region.startLine ?? 0, column: region.startColumn ?? 0 } }] + : undefined + }; + + diagnostics.push(diagnostic); + } + } + + return diagnostics; +} + +/** + * The maximum length (in characters) permitted for a single oxlint command line. + * + * @remarks + * Windows caps the command line passed to `CreateProcess` at 32767 characters. We stay well under + * that limit to leave room for the Node executable path, the oxlint bin path, and any argument + * quoting overhead applied by the operating system. + */ +export const MAX_COMMAND_LINE_LENGTH: number = 30000; + +/** + * Splits the positional lint paths into batches such that each resulting oxlint command line stays + * within {@link MAX_COMMAND_LINE_LENGTH}. This avoids exceeding the operating system's command-line + * length limit (notably on Windows, where a large set of changed files could otherwise overflow the + * ~32 KiB `CreateProcess` limit). + * + * @param prefixArgs - The command-line tokens that precede the paths on every invocation (for + * example the Node executable, the oxlint bin path, and all non-path oxlint arguments). + * @param lintPaths - The positional file or folder paths to lint. + * @param maxCommandLineLength - The maximum permitted command-line length. + * @returns One or more batches of lint paths. A single path that on its own exceeds the limit is + * still returned in its own batch, since it cannot be split any further. Always returns at least + * one batch so that callers can iterate uniformly. + */ +export function batchLintPaths( + prefixArgs: ReadonlyArray, + lintPaths: ReadonlyArray, + maxCommandLineLength: number = MAX_COMMAND_LINE_LENGTH +): string[][] { + // Length contributed by the fixed prefix, counting a separating space before each token. + let prefixLength: number = 0; + for (const arg of prefixArgs) { + prefixLength += arg.length + 1; + } + + const batches: string[][] = []; + let currentBatch: string[] = []; + let currentLength: number = prefixLength; + + for (const lintPath of lintPaths) { + const pathLength: number = lintPath.length + 1; + if (currentBatch.length > 0 && currentLength + pathLength > maxCommandLineLength) { + batches.push(currentBatch); + currentBatch = []; + currentLength = prefixLength; + } + currentBatch.push(lintPath); + currentLength += pathLength; + } + + if (currentBatch.length > 0) { + batches.push(currentBatch); + } + + if (batches.length === 0) { + batches.push([]); + } + + return batches; +} + +/** + * Merges the raw SARIF log output produced by one or more oxlint invocations into a single SARIF + * document. + * + * @remarks + * When linting is split into multiple batches (see {@link batchLintPaths}), each batch produces its + * own SARIF log. This concatenates the `runs` from every batch into the first log so that all of the + * findings are preserved in one file. When only a single log is provided, its original text is + * returned unchanged so that the output is byte-for-byte identical to what oxlint emitted. + * + * @param rawSarifLogs - The raw (unparsed) SARIF stdout captured from each oxlint invocation. + * @returns The merged SARIF log serialized as a string. + */ +export function mergeSarifLogs(rawSarifLogs: ReadonlyArray): string { + if (rawSarifLogs.length === 1) { + return rawSarifLogs[0]; + } + + const base: { runs?: unknown[] } = JSON.parse(rawSarifLogs[0]); + base.runs = base.runs ?? []; + for (let i: number = 1; i < rawSarifLogs.length; ++i) { + const next: { runs?: unknown[] } = JSON.parse(rawSarifLogs[i]); + if (next.runs) { + base.runs.push(...next.runs); + } + } + + return JSON.stringify(base, undefined, 2); +} diff --git a/heft-plugins/heft-oxlint-plugin/src/OxlintPlugin.ts b/heft-plugins/heft-oxlint-plugin/src/OxlintPlugin.ts new file mode 100644 index 00000000000..7f0095a8091 --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/src/OxlintPlugin.ts @@ -0,0 +1,370 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; +import type { ChildProcess } from 'node:child_process'; + +import type { HeftConfiguration, IHeftTaskSession, IHeftTaskPlugin, IScopedLogger } from '@rushstack/heft'; +import type { + TypeScriptPluginName, + IChangedFilesHookOptions, + ITypeScriptPluginAccessor +} from '@rushstack/heft-typescript-plugin'; +import { + AlreadyReportedError, + Executable, + FileSystem, + Import, + JsonFile, + type IWaitForExitResult +} from '@rushstack/node-core-library'; + +import { + buildCommonArgs, + buildFixArgs, + resolveLintPaths, + filterChangedFilePaths, + createFileErrorForDiagnostic, + extractDiagnosticsFromSarif, + batchLintPaths, + mergeSarifLogs, + type IOxlintPluginOptions, + type IOxlintJsonOutput, + type IOxlintSarifLog, + type IOxlintDiagnostic +} from './OxlintHelpers'; + +export type { + IOxlintPluginOptions, + IOxlintDiagnostic, + IOxlintDiagnosticLabel, + IOxlintDiagnosticSpan, + IOxlintJsonOutput +} from './OxlintHelpers'; + +const PLUGIN_NAME: 'oxlint-plugin' = 'oxlint-plugin'; +const TYPESCRIPT_PLUGIN_PACKAGE_NAME: '@rushstack/heft-typescript-plugin' = + '@rushstack/heft-typescript-plugin'; +const TYPESCRIPT_PLUGIN_NAME: typeof TypeScriptPluginName = 'typescript-plugin'; +const FIX_PARAMETER_NAME: string = '--fix'; + +// oxlint delegates type-aware rules (--type-aware / --type-check) to the native "tsgolint" binary +// from the "oxlint-tsgolint" package. It honors OXLINT_TSGOLINT_PATH, letting us point it at a copy +// resolved via the rig package resolver (installed in the project or a shared rig). +const TSGOLINT_PACKAGE_NAME: 'oxlint-tsgolint' = 'oxlint-tsgolint'; +const TSGOLINT_NATIVE_PACKAGE_SCOPE: '@oxlint-tsgolint' = '@oxlint-tsgolint'; +const OXLINT_TSGOLINT_PATH_ENV_VARIABLE_NAME: 'OXLINT_TSGOLINT_PATH' = 'OXLINT_TSGOLINT_PATH'; + +interface IOxlintInvocation { + binPath: string; + buildFolderPath: string; + args: string[]; + logger: IScopedLogger; + environment?: NodeJS.ProcessEnv; +} + +function checkFix(taskSession: IHeftTaskSession, pluginOptions?: IOxlintPluginOptions): boolean { + let fix: boolean = + !!pluginOptions?.alwaysFix || taskSession.parameters.getFlagParameter(FIX_PARAMETER_NAME).value; + if (fix && taskSession.parameters.production) { + // Write this as a standard output message since we don't want to throw errors when running in + // production mode and "alwaysFix" is specified in the plugin options + taskSession.logger.terminal.writeLine( + 'Fix mode has been disabled since Heft is running in production mode' + ); + fix = false; + } + return fix; +} + +export default class OxlintPlugin implements IHeftTaskPlugin { + public apply( + taskSession: IHeftTaskSession, + heftConfiguration: HeftConfiguration, + pluginOptions?: IOxlintPluginOptions + ): void { + // Disable linting in watch mode. Some lint rules require the context of multiple files, which + // may not be available in watch mode. + if (taskSession.parameters.watch) { + let warningPrinted: boolean = false; + taskSession.hooks.run.tapPromise(PLUGIN_NAME, async () => { + if (warningPrinted) { + return; + } + taskSession.logger.terminal.writeWarningLine("Linting isn't currently supported in watch mode"); + warningPrinted = true; + }); + return; + } + + // Mirror the behavior of the ESLint plugin: when running alongside the TypeScript plugin, only + // lint the files that TypeScript reports as changed so that incremental builds stay fast. When + // run standalone (no TypeScript phase), fall back to linting the configured paths. + let inTypeScriptPhase: boolean = false; + let lintEverything: boolean = false; + const changedFilePaths: Set = new Set(); + + taskSession.requestAccessToPluginByName( + TYPESCRIPT_PLUGIN_PACKAGE_NAME, + TYPESCRIPT_PLUGIN_NAME, + (accessor: ITypeScriptPluginAccessor) => { + inTypeScriptPhase = true; + accessor.onChangedFilesHook.tap(PLUGIN_NAME, (options: IChangedFilesHookOptions) => { + if (options.changedFiles) { + for (const changedFile of options.changedFiles) { + changedFilePaths.add(changedFile.fileName); + } + } else { + // An undefined "changedFiles" set indicates a full (non-incremental) build, so lint + // the configured paths in their entirety. + lintEverything = true; + } + }); + } + ); + + taskSession.hooks.run.tapPromise(PLUGIN_NAME, async () => { + let lintPaths: ReadonlyArray; + if (inTypeScriptPhase && !lintEverything) { + lintPaths = filterChangedFilePaths(changedFilePaths, heftConfiguration.buildFolderPath); + if (lintPaths.length === 0) { + taskSession.logger.terminal.writeVerboseLine('No changed files to lint'); + return; + } + } else { + lintPaths = resolveLintPaths(pluginOptions); + } + + await this.#runOxlintAsync(taskSession, heftConfiguration, pluginOptions, lintPaths); + }); + } + + async #runOxlintAsync( + taskSession: IHeftTaskSession, + heftConfiguration: HeftConfiguration, + pluginOptions: IOxlintPluginOptions | undefined, + lintPaths: ReadonlyArray + ): Promise { + const logger: IScopedLogger = taskSession.logger; + const buildFolderPath: string = heftConfiguration.buildFolderPath; + + const binPath: string = await this.#getOxlintBinPathAsync(heftConfiguration, logger); + + const fix: boolean = checkFix(taskSession, pluginOptions); + const commonArgs: string[] = buildCommonArgs(pluginOptions); + const fixArgs: string[] = buildFixArgs(fix, pluginOptions); + const sarifLogPath: string | undefined = pluginOptions?.sarifLogPath; + + // Type-aware rules require the external tsgolint binary. Like the ESLint plugin, resolve it from + // the project (or its rig) rather than bundling it, and point oxlint at it via an environment + // variable so the tooling can live in a shared rig. + let environment: NodeJS.ProcessEnv | undefined; + if (pluginOptions?.typeAware || pluginOptions?.typeCheck) { + const tsgolintBinaryPath: string = await this.#getTsgolintBinaryPathAsync(heftConfiguration, logger); + environment = { + ...process.env, + [OXLINT_TSGOLINT_PATH_ENV_VARIABLE_NAME]: tsgolintBinaryPath + }; + } + + // When a SARIF log is requested, run oxlint with SARIF output. The SARIF payload contains all of + // the diagnostics we need, so we extract them from it rather than paying the full lint cost a + // second time. Otherwise, use JSON output, which is a more compact format for diagnostic + // reporting. + const format: string = sarifLogPath ? 'sarif' : 'json'; + const fixedArgs: string[] = [...commonArgs, ...fixArgs, `--format=${format}`]; + + // Split the lint paths into batches so that a project with a very large number of changed files + // does not exceed the operating system's command-line length limit (notably ~32 KiB on Windows). + // In the common case this yields a single batch and behaves exactly like a single oxlint run. + const batches: string[][] = batchLintPaths([process.execPath, binPath, ...fixedArgs], lintPaths); + if (batches.length > 1) { + logger.terminal.writeVerboseLine( + `Linting ${lintPaths.length} paths across ${batches.length} oxlint invocations to stay ` + + 'within the command-line length limit' + ); + } + + const diagnostics: IOxlintDiagnostic[] = []; + const sarifBatchOutputs: string[] = []; + + for (const batch of batches) { + const result: IWaitForExitResult = await this.#spawnOxlintAsync({ + binPath, + buildFolderPath, + args: [...fixedArgs, ...batch], + logger, + environment + }); + + const stdout: string = (result.stdout ?? '').trim(); + if (!stdout) { + // oxlint produced no payload. If it also failed, surface stderr to aid debugging. + if (result.exitCode !== 0 && result.stderr) { + logger.emitError(new Error(`oxlint failed:\n${result.stderr.trim()}`)); + } + continue; + } + + if (sarifLogPath) { + const parsed: IOxlintSarifLog | undefined = this.#parseOxlintOutput( + stdout, + 'SARIF', + result.stderr, + logger + ); + if (parsed) { + sarifBatchOutputs.push(stdout); + diagnostics.push(...extractDiagnosticsFromSarif(parsed)); + } + } else { + const parsed: IOxlintJsonOutput | undefined = this.#parseOxlintOutput( + stdout, + 'JSON', + result.stderr, + logger + ); + if (parsed) { + diagnostics.push(...(parsed.diagnostics ?? [])); + } + } + } + + this.#emitDiagnostics(diagnostics, buildFolderPath, logger); + + // oxlint can emit SARIF to stdout but does not support writing directly to a file, so persist the + // captured output (merged across batches when necessary) ourselves. + if (sarifLogPath && sarifBatchOutputs.length > 0) { + const absoluteSarifLogPath: string = path.resolve(buildFolderPath, sarifLogPath); + await FileSystem.writeFileAsync(absoluteSarifLogPath, mergeSarifLogs(sarifBatchOutputs), { + ensureFolderExists: true + }); + logger.terminal.writeVerboseLine(`Wrote oxlint SARIF log to: ${absoluteSarifLogPath}`); + } + + // We rely on the diagnostics reported above to emit errors to the logger. If they did, we throw an + // AlreadyReportedError to indicate that the task failed without re-reporting the errors. + if (logger.hasErrors) { + throw new AlreadyReportedError(); + } + } + + async #getOxlintBinPathAsync(heftConfiguration: HeftConfiguration, logger: IScopedLogger): Promise { + // Resolve the oxlint package using the rig package resolver, which allows the linter to be + // installed in the project's rig rather than directly in the project, then read its "bin" entry. + const packageFolder: string = await heftConfiguration.rigPackageResolver.resolvePackageAsync( + 'oxlint', + logger.terminal + ); + const packageJsonPath: string = `${packageFolder}/package.json`; + const packageJson: { bin?: string | Record } = await JsonFile.loadAsync(packageJsonPath); + + let relativeBinPath: string | undefined; + if (typeof packageJson.bin === 'string') { + relativeBinPath = packageJson.bin; + } else if (packageJson.bin) { + relativeBinPath = packageJson.bin.oxlint; + } + + if (!relativeBinPath) { + throw new Error('Unable to determine the oxlint binary path from its package.json "bin" field.'); + } + + const binPath: string = path.resolve(packageFolder, relativeBinPath); + logger.terminal.writeVerboseLine(`Resolved oxlint binary: ${binPath}`); + return binPath; + } + + async #getTsgolintBinaryPathAsync( + heftConfiguration: HeftConfiguration, + logger: IScopedLogger + ): Promise { + // Resolve "oxlint-tsgolint" via the rig package resolver (project or rig). It provides the native + // binary through optional dependencies named "@oxlint-tsgolint/-". + const packageFolder: string = await heftConfiguration.rigPackageResolver.resolvePackageAsync( + TSGOLINT_PACKAGE_NAME, + logger.terminal + ); + + const nativePackageName: string = `${TSGOLINT_NATIVE_PACKAGE_SCOPE}/${process.platform}-${process.arch}`; + const binaryName: string = process.platform === 'win32' ? 'tsgolint.exe' : 'tsgolint'; + + let nativePackageJsonPath: string; + try { + // Resolve the native package's "package.json" (the binary itself is extensionless and not a + // resolvable module) and derive the binary path from its folder. + nativePackageJsonPath = await Import.resolveModuleAsync({ + modulePath: `${nativePackageName}/package.json`, + baseFolderPath: packageFolder + }); + } catch (error) { + throw new Error( + `Unable to resolve the tsgolint native binary package "${nativePackageName}" from ` + + `"${packageFolder}". Ensure the "${TSGOLINT_PACKAGE_NAME}" package is installed for this ` + + `platform so that type-aware linting can run. (${(error as Error).message})` + ); + } + + const binaryPath: string = `${path.dirname(nativePackageJsonPath)}/${binaryName}`; + if (!(await FileSystem.existsAsync(binaryPath))) { + throw new Error( + `The tsgolint native binary was not found at the expected location "${binaryPath}". Ensure the ` + + `"${TSGOLINT_PACKAGE_NAME}" package is correctly installed for this platform.` + ); + } + + logger.terminal.writeVerboseLine(`Resolved tsgolint binary: ${binaryPath}`); + return binaryPath; + } + + async #spawnOxlintAsync(invocation: IOxlintInvocation): Promise> { + const { binPath, buildFolderPath, args, logger, environment } = invocation; + + // The oxlint "bin" launcher is an ESM Node script, so it must be invoked via Node directly. + logger.terminal.writeVerboseLine(`Running oxlint: node ${binPath} ${args.join(' ')}`); + const childProcess: ChildProcess = Executable.spawn(process.execPath, [binPath, ...args], { + currentWorkingDirectory: buildFolderPath, + stdio: ['ignore', 'pipe', 'pipe'], + environment + }); + + return await Executable.waitForExitAsync(childProcess, { encoding: 'utf8' }); + } + + #parseOxlintOutput( + stdout: string, + formatLabel: string, + stderr: string | undefined, + logger: IScopedLogger + ): T | undefined { + try { + return JSON.parse(stdout) as T; + } catch (error) { + logger.emitError( + new Error(`Unable to parse oxlint ${formatLabel} output: ${(error as Error).message}`) + ); + if (stderr) { + logger.terminal.writeVerboseLine(stderr.trim()); + } + return undefined; + } + } + + #emitDiagnostics( + diagnostics: ReadonlyArray, + buildFolderPath: string, + logger: IScopedLogger + ): void { + for (const diagnostic of diagnostics) { + const fileError: ReturnType = createFileErrorForDiagnostic( + diagnostic, + buildFolderPath + ); + if (diagnostic.severity === 'error') { + logger.emitError(fileError); + } else { + logger.emitWarning(fileError); + } + } + } +} diff --git a/heft-plugins/heft-oxlint-plugin/src/schemas/heft-oxlint-plugin.schema.json b/heft-plugins/heft-oxlint-plugin/src/schemas/heft-oxlint-plugin.schema.json new file mode 100644 index 00000000000..71f8c0565ea --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/src/schemas/heft-oxlint-plugin.schema.json @@ -0,0 +1,184 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Heft oxlint Plugin Options Configuration", + "description": "This schema describes the \"options\" field that can be specified in heft.json when loading \"@rushstack/heft-oxlint-plugin\".", + "type": "object", + + "additionalProperties": false, + + "properties": { + "configFilePath": { + "title": "Config File Path", + "description": "An optional path (relative to the project root) to an oxlint configuration file (e.g. \".oxlintrc.json\"). Maps to oxlint's --config. If omitted, oxlint will search for a configuration file using its default resolution.", + "type": "string" + }, + + "tsconfigFilePath": { + "title": "TypeScript Config File Path", + "description": "Override the TypeScript config used for import resolution. Maps to oxlint's --tsconfig.", + "type": "string" + }, + + "paths": { + "title": "Paths", + "description": "The list of file or folder paths (relative to the project root) that oxlint should lint. Defaults to [\"src\"].", + "type": "array", + "items": { "type": "string" } + }, + + "allow": { + "title": "Allow", + "description": "Allow (suppress) the listed rules or categories. Maps to oxlint's --allow (-A).", + "type": "array", + "items": { "type": "string" } + }, + + "warn": { + "title": "Warn", + "description": "Warn on the listed rules or categories. Maps to oxlint's --warn (-W).", + "type": "array", + "items": { "type": "string" } + }, + + "deny": { + "title": "Deny", + "description": "Deny (error on) the listed rules or categories. Maps to oxlint's --deny (-D).", + "type": "array", + "items": { "type": "string" } + }, + + "disableUnicornPlugin": { + "description": "Disable the unicorn plugin, which is on by default. Maps to --disable-unicorn-plugin.", + "type": "boolean" + }, + "disableOxcPlugin": { + "description": "Disable oxc unique rules, which are on by default. Maps to --disable-oxc-plugin.", + "type": "boolean" + }, + "disableTypeScriptPlugin": { + "description": "Disable the TypeScript plugin, which is on by default. Maps to --disable-typescript-plugin.", + "type": "boolean" + }, + "importPlugin": { + "description": "Enable the import plugin. Maps to --import-plugin.", + "type": "boolean" + }, + "reactPlugin": { + "description": "Enable the react plugin. Maps to --react-plugin.", + "type": "boolean" + }, + "jsdocPlugin": { + "description": "Enable the jsdoc plugin. Maps to --jsdoc-plugin.", + "type": "boolean" + }, + "jestPlugin": { + "description": "Enable the Jest plugin. Maps to --jest-plugin.", + "type": "boolean" + }, + "vitestPlugin": { + "description": "Enable the Vitest plugin. Maps to --vitest-plugin.", + "type": "boolean" + }, + "jsxA11yPlugin": { + "description": "Enable the JSX-a11y plugin. Maps to --jsx-a11y-plugin.", + "type": "boolean" + }, + "nextjsPlugin": { + "description": "Enable the Next.js plugin. Maps to --nextjs-plugin.", + "type": "boolean" + }, + "reactPerfPlugin": { + "description": "Enable the React performance plugin. Maps to --react-perf-plugin.", + "type": "boolean" + }, + "promisePlugin": { + "description": "Enable the promise plugin. Maps to --promise-plugin.", + "type": "boolean" + }, + "nodePlugin": { + "description": "Enable the node plugin. Maps to --node-plugin.", + "type": "boolean" + }, + "vuePlugin": { + "description": "Enable the vue plugin. Maps to --vue-plugin.", + "type": "boolean" + }, + + "alwaysFix": { + "title": "Always Fix", + "description": "If set to true, fix all encountered rule violations where the violated rule provides a fixer, regardless of if the \"--fix\" command-line argument is provided. Maps to oxlint's --fix. When running in production mode, fixes will be disabled regardless of this setting.", + "type": "boolean" + }, + "fixSuggestions": { + "description": "Apply auto-fixable suggestions. Maps to --fix-suggestions. Disabled in production mode.", + "type": "boolean" + }, + "fixDangerously": { + "description": "Apply dangerous fixes and suggestions. Maps to --fix-dangerously. Disabled in production mode.", + "type": "boolean" + }, + + "ignorePath": { + "description": "Specify the file to use as your .eslintignore. Maps to --ignore-path.", + "type": "string" + }, + "ignorePattern": { + "description": "Patterns of files to ignore. Maps to --ignore-pattern.", + "type": "array", + "items": { "type": "string" } + }, + "noIgnore": { + "description": "Disable excluding files from ignore files/patterns. Maps to --no-ignore.", + "type": "boolean" + }, + + "quiet": { + "description": "Disable reporting on warnings; only errors are reported. Maps to --quiet.", + "type": "boolean" + }, + "denyWarnings": { + "description": "Ensure warnings produce a non-zero exit code. Maps to --deny-warnings.", + "type": "boolean" + }, + "maxWarnings": { + "description": "Warning threshold that forces an error exit status when exceeded. Maps to --max-warnings.", + "type": "integer" + }, + + "threads": { + "description": "Number of threads to use. Set to 1 to use a single CPU core. Maps to --threads.", + "type": "integer" + }, + "noErrorOnUnmatchedPattern": { + "description": "Do not error when no files are selected for linting. Maps to --no-error-on-unmatched-pattern.", + "type": "boolean" + }, + "disableNestedConfig": { + "description": "Disable automatic loading of nested configuration files. Maps to --disable-nested-config.", + "type": "boolean" + }, + "typeAware": { + "description": "Enable rules that require type information. Maps to --type-aware. Requires the oxlint-tsgolint package, which is resolved from the consuming project or its shared rig.", + "type": "boolean" + }, + "typeCheck": { + "description": "Enable experimental type checking (includes TypeScript compiler diagnostics). Maps to --type-check. Requires the oxlint-tsgolint package, which is resolved from the consuming project or its shared rig.", + "type": "boolean" + }, + "reportUnusedDisableDirectives": { + "description": "Report unused inline disable directives. Maps to --report-unused-disable-directives.", + "type": "boolean" + }, + "reportUnusedDisableDirectivesSeverity": { + "description": "Report unused inline disable directives at the specified severity. Maps to --report-unused-disable-directives-severity. Mutually exclusive with reportUnusedDisableDirectives.", + "type": "string", + "enum": ["off", "warn", "error"] + }, + + "sarifLogPath": { + "title": "SARIF Log Path", + "description": "If specified, a log of all findings will be emitted in the Static Analysis Results Interchange Format (https://sarifweb.azurewebsites.net/) at the provided path, relative to the project root. When set, oxlint is run once with --format=sarif and the reported diagnostics are extracted from that same SARIF output, so no additional oxlint invocation is incurred.", + "type": "string" + } + } +} diff --git a/heft-plugins/heft-oxlint-plugin/src/test/OxlintHelpers.test.ts b/heft-plugins/heft-oxlint-plugin/src/test/OxlintHelpers.test.ts new file mode 100644 index 00000000000..c9e261875c6 --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/src/test/OxlintHelpers.test.ts @@ -0,0 +1,354 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { Path } from '@rushstack/node-core-library'; + +import { + buildCommonArgs, + buildFixArgs, + resolveLintPaths, + filterChangedFilePaths, + formatDiagnosticMessage, + createFileErrorForDiagnostic, + extractDiagnosticsFromSarif, + batchLintPaths, + mergeSarifLogs, + type IOxlintPluginOptions, + type IOxlintDiagnostic, + type IOxlintSarifLog +} from '../OxlintHelpers'; + +describe(buildCommonArgs.name, () => { + it('returns no arguments when options are undefined', () => { + expect(buildCommonArgs(undefined)).toEqual([]); + }); + + it('returns no arguments when options are empty', () => { + expect(buildCommonArgs({})).toEqual([]); + }); + + it('maps config and tsconfig paths', () => { + const options: IOxlintPluginOptions = { + configFilePath: '.oxlintrc.json', + tsconfigFilePath: 'tsconfig.json' + }; + expect(buildCommonArgs(options)).toEqual(['--config', '.oxlintrc.json', '--tsconfig', 'tsconfig.json']); + }); + + it('maps allow, warn, and deny lists in order', () => { + const options: IOxlintPluginOptions = { + allow: ['no-debugger'], + warn: ['no-console', 'correctness'], + deny: ['no-unused-vars'] + }; + expect(buildCommonArgs(options)).toEqual([ + '--allow', + 'no-debugger', + '--warn', + 'no-console', + '--warn', + 'correctness', + '--deny', + 'no-unused-vars' + ]); + }); + + it('maps boolean flags only when truthy', () => { + const options: IOxlintPluginOptions = { + disableTypeScriptPlugin: true, + importPlugin: true, + reactPlugin: false, + quiet: true + }; + expect(buildCommonArgs(options)).toEqual(['--disable-typescript-plugin', '--import-plugin', '--quiet']); + }); + + it('maps numeric and severity options', () => { + const options: IOxlintPluginOptions = { + maxWarnings: 5, + threads: 1, + reportUnusedDisableDirectivesSeverity: 'error' + }; + expect(buildCommonArgs(options)).toEqual([ + '--max-warnings', + '5', + '--threads', + '1', + '--report-unused-disable-directives-severity', + 'error' + ]); + }); + + it('emits --max-warnings 0 when maxWarnings is 0', () => { + expect(buildCommonArgs({ maxWarnings: 0 })).toEqual(['--max-warnings', '0']); + }); + + it('maps ignore options', () => { + const options: IOxlintPluginOptions = { + ignorePath: '.eslintignore', + ignorePattern: ['**/dist/**', '**/temp/**'], + noIgnore: true + }; + expect(buildCommonArgs(options)).toEqual([ + '--no-ignore', + '--ignore-path', + '.eslintignore', + '--ignore-pattern', + '**/dist/**', + '--ignore-pattern', + '**/temp/**' + ]); + }); +}); + +describe(buildFixArgs.name, () => { + it('returns no arguments when fix is disabled', () => { + expect(buildFixArgs(false, { fixSuggestions: true, fixDangerously: true })).toEqual([]); + }); + + it('returns --fix when fix is enabled with no extra options', () => { + expect(buildFixArgs(true, undefined)).toEqual(['--fix']); + }); + + it('appends suggestion and dangerous flags when enabled', () => { + expect(buildFixArgs(true, { fixSuggestions: true, fixDangerously: true })).toEqual([ + '--fix', + '--fix-suggestions', + '--fix-dangerously' + ]); + }); +}); + +describe(resolveLintPaths.name, () => { + it('defaults to ["src"] when no paths are provided', () => { + expect(resolveLintPaths(undefined)).toEqual(['src']); + expect(resolveLintPaths({})).toEqual(['src']); + expect(resolveLintPaths({ paths: [] })).toEqual(['src']); + }); + + it('uses custom paths when provided', () => { + expect(resolveLintPaths({ paths: ['lib', 'scripts'] })).toEqual(['lib', 'scripts']); + }); +}); + +describe(filterChangedFilePaths.name, () => { + const buildFolderPath: string = '/repo/project'; + + it('returns an empty array when nothing is provided', () => { + expect(filterChangedFilePaths([], buildFolderPath)).toEqual([]); + }); + + it('keeps lintable files inside the build folder as sorted relative paths', () => { + const changed: string[] = [ + `${buildFolderPath}/src/b.ts`, + `${buildFolderPath}/src/a.tsx`, + `${buildFolderPath}/src/nested/c.js` + ]; + expect(filterChangedFilePaths(changed, buildFolderPath).map(Path.convertToSlashes)).toEqual([ + 'src/a.tsx', + 'src/b.ts', + 'src/nested/c.js' + ]); + }); + + it('excludes files outside the build folder', () => { + const changed: string[] = [`/repo/other/src/a.ts`, `${buildFolderPath}/../sibling/b.ts`]; + expect(filterChangedFilePaths(changed, buildFolderPath)).toEqual([]); + }); + + it('excludes node_modules, declaration files, and non-lintable extensions', () => { + const changed: string[] = [ + `${buildFolderPath}/node_modules/pkg/index.ts`, + `${buildFolderPath}/src/types.d.ts`, + `${buildFolderPath}/src/styles.css`, + `${buildFolderPath}/src/data.json`, + `${buildFolderPath}/src/keep.ts` + ]; + expect(filterChangedFilePaths(changed, buildFolderPath).map(Path.convertToSlashes)).toEqual([ + 'src/keep.ts' + ]); + }); + + it('de-duplicates repeated paths', () => { + const changed: string[] = [`${buildFolderPath}/src/a.ts`, `${buildFolderPath}/src/a.ts`]; + expect(filterChangedFilePaths(changed, buildFolderPath).map(Path.convertToSlashes)).toEqual(['src/a.ts']); + }); +}); + +describe(formatDiagnosticMessage.name, () => { + it('prefixes the rule code when present', () => { + const diagnostic: IOxlintDiagnostic = { + message: "'x' is never used", + code: 'eslint(no-unused-vars)', + severity: 'error', + filename: 'src/file.ts' + }; + expect(formatDiagnosticMessage(diagnostic)).toBe("(eslint(no-unused-vars)) 'x' is never used"); + }); + + it('returns the message unchanged when no code is present', () => { + const diagnostic: IOxlintDiagnostic = { + message: 'something went wrong', + severity: 'warning', + filename: 'src/file.ts' + }; + expect(formatDiagnosticMessage(diagnostic)).toBe('something went wrong'); + }); +}); + +describe(createFileErrorForDiagnostic.name, () => { + const buildFolderPath: string = '/repo/project'; + + it('resolves the absolute path and uses line/column from the first label span', () => { + const diagnostic: IOxlintDiagnostic = { + message: "'x' is never used", + code: 'eslint(no-unused-vars)', + severity: 'error', + filename: 'src/file.ts', + labels: [ + { + span: { offset: 10, length: 1, line: 3, column: 7 } + } + ] + }; + const fileError: ReturnType = createFileErrorForDiagnostic( + diagnostic, + buildFolderPath + ); + expect(fileError.absolutePath).toBe(path.resolve(buildFolderPath, 'src/file.ts')); + expect(fileError.line).toBe(3); + expect(fileError.column).toBe(7); + expect(fileError.message).toBe("(eslint(no-unused-vars)) 'x' is never used"); + }); + + it('omits line/column when no labels are present', () => { + const diagnostic: IOxlintDiagnostic = { + message: 'broken', + severity: 'warning', + filename: 'src/other.ts' + }; + const fileError: ReturnType = createFileErrorForDiagnostic( + diagnostic, + buildFolderPath + ); + expect(fileError.absolutePath).toBe(path.resolve(buildFolderPath, 'src/other.ts')); + expect(fileError.line).toBeUndefined(); + expect(fileError.column).toBeUndefined(); + }); +}); + +describe(extractDiagnosticsFromSarif.name, () => { + it('returns an empty array for an empty log', () => { + expect(extractDiagnosticsFromSarif({})).toEqual([]); + expect(extractDiagnosticsFromSarif({ runs: [] })).toEqual([]); + expect(extractDiagnosticsFromSarif({ runs: [{ results: [] }] })).toEqual([]); + }); + + it('maps SARIF levels to diagnostic severities and reads location data', () => { + const sarifLog: IOxlintSarifLog = { + runs: [ + { + results: [ + { + level: 'error', + ruleId: 'no-unused-vars', + message: { text: "'x' is never used" }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/a.ts' }, + region: { startLine: 3, startColumn: 7 } + } + } + ] + }, + { + level: 'warning', + message: { text: 'be careful' }, + locations: [{ physicalLocation: { artifactLocation: { uri: 'src/b.ts' } } }] + }, + { + level: 'note', + message: { text: 'just advice' } + } + ] + } + ] + }; + + const diagnostics: IOxlintDiagnostic[] = extractDiagnosticsFromSarif(sarifLog); + expect(diagnostics).toHaveLength(3); + + expect(diagnostics[0]).toMatchObject({ + severity: 'error', + code: 'no-unused-vars', + message: "'x' is never used", + filename: 'src/a.ts' + }); + expect(diagnostics[0].labels?.[0]?.span).toMatchObject({ line: 3, column: 7 }); + + expect(diagnostics[1]).toMatchObject({ + severity: 'warning', + message: 'be careful', + filename: 'src/b.ts' + }); + expect(diagnostics[2]).toMatchObject({ severity: 'advice', message: 'just advice', filename: '' }); + }); +}); + +describe(batchLintPaths.name, () => { + it('returns a single batch when everything fits', () => { + const paths: string[] = ['src/a.ts', 'src/b.ts', 'src/c.ts']; + expect(batchLintPaths(['node', 'oxlint', '--format=json'], paths)).toEqual([paths]); + }); + + it('always returns at least one batch, even with no paths', () => { + expect(batchLintPaths(['node', 'oxlint'], [])).toEqual([[]]); + }); + + it('splits paths into multiple batches when the command line would be too long', () => { + const paths: string[] = ['aaaa', 'bbbb', 'cccc', 'dddd']; + // prefix "node oxlint" contributes 5 + 7 = 12; each 4-char path contributes 5. + // With a limit of 22, prefix(12) + one path(5) = 17 fits, two paths = 22 fits, three = 27 does not. + const batches: string[][] = batchLintPaths(['node', 'oxlint'], paths, 22); + expect(batches).toEqual([ + ['aaaa', 'bbbb'], + ['cccc', 'dddd'] + ]); + }); + + it('places an oversized single path in its own batch rather than dropping it', () => { + const longPath: string = 'x'.repeat(100); + const batches: string[][] = batchLintPaths(['node'], ['a', longPath, 'b'], 20); + expect(batches).toEqual([['a'], [longPath], ['b']]); + }); + + it('preserves every path across the produced batches', () => { + const paths: string[] = []; + for (let index: number = 0; index < 50; ++index) { + paths.push(`src/file-${index}.ts`); + } + const batches: string[][] = batchLintPaths(['node', 'oxlint', '--format=sarif'], paths, 80); + expect(batches.flat()).toEqual(paths); + expect(batches.length).toBeGreaterThan(1); + }); +}); + +describe(mergeSarifLogs.name, () => { + it('returns the original text unchanged for a single log', () => { + const raw: string = '{"runs":[{"results":[]}]}'; + expect(mergeSarifLogs([raw])).toBe(raw); + }); + + it('concatenates the runs from multiple logs', () => { + const first: string = JSON.stringify({ $schema: 'sarif', version: '2.1.0', runs: [{ results: [1] }] }); + const second: string = JSON.stringify({ version: '2.1.0', runs: [{ results: [2] }] }); + const merged: IOxlintSarifLog & { $schema?: string } = JSON.parse(mergeSarifLogs([first, second])); + expect(merged.$schema).toBe('sarif'); + expect(merged.runs).toHaveLength(2); + expect(merged.runs?.[0]?.results).toEqual([1]); + expect(merged.runs?.[1]?.results).toEqual([2]); + }); +}); diff --git a/heft-plugins/heft-oxlint-plugin/tsconfig.json b/heft-plugins/heft-oxlint-plugin/tsconfig.json new file mode 100644 index 00000000000..dac21d04081 --- /dev/null +++ b/heft-plugins/heft-oxlint-plugin/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json" +} diff --git a/rush.json b/rush.json index 622021e313e..ceb1cc5dd93 100644 --- a/rush.json +++ b/rush.json @@ -1126,6 +1126,12 @@ "reviewCategory": "libraries", "shouldPublish": true }, + { + "packageName": "@rushstack/heft-oxlint-plugin", + "projectFolder": "heft-plugins/heft-oxlint-plugin", + "reviewCategory": "libraries", + "shouldPublish": true + }, { "packageName": "@rushstack/heft-localization-typings-plugin", "projectFolder": "heft-plugins/heft-localization-typings-plugin",