diff --git a/.github/workflows/benchmark-cli.yml b/.github/workflows/benchmark-cli.yml new file mode 100644 index 000000000..c470cc1fd --- /dev/null +++ b/.github/workflows/benchmark-cli.yml @@ -0,0 +1,46 @@ +name: Benchmark CLI + +on: + workflow_call: + +jobs: + benchmark-cli: + name: Run CLI benchmarks + runs-on: ubuntu-22.04 + permissions: + contents: read + id-token: write + env: + GOMAXPROCS: 8 + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: true + fetch-depth: 1 + + - name: Setup Go + uses: ./.github/actions/setup-go + with: + go-version: '1.26.0' + cache-name: benchmark-cli + + - name: Setup Node.js + uses: ./.github/actions/setup-node + + - name: Build JS package + run: pnpm --filter @rslint/core run build + + - name: Run CLI benchmarks + uses: CodSpeedHQ/action@d872884a306dd4853acf0f584f4b706cf0cc72a2 + timeout-minutes: 30 + env: + RAYON_NUM_THREADS: 1 + CODSPEED_EXPERIMENTAL_FAIR_SCHED: true + GH_MATRIX: '${{ toJson(matrix) }}' + GH_STRATEGY: '${{ toJson(strategy) }}' + with: + mode: walltime + run: pnpm run bench:cli + runner-version: 4.13.0 + go-runner-version: 1.1.0 diff --git a/.github/workflows/benchmark-go.yml b/.github/workflows/benchmark-go.yml new file mode 100644 index 000000000..964d737f5 --- /dev/null +++ b/.github/workflows/benchmark-go.yml @@ -0,0 +1,33 @@ +name: Benchmark Go + +on: + workflow_call: + +jobs: + benchmark-go: + name: Run Go benchmarks + runs-on: ubuntu-22.04 + permissions: + contents: read + id-token: write + env: + GOMAXPROCS: 8 + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: true + fetch-depth: 1 + + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.26.0' + cache: false + + - name: Run benchmarks + uses: CodSpeedHQ/action@d872884a306dd4853acf0f584f4b706cf0cc72a2 + with: + mode: walltime + run: go test -bench=. -benchtime=5s ./tests/bench-go/ + go-runner-version: 1.1.0 diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 000000000..71251a608 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,24 @@ +name: Benchmarks + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + benchmark-go: + permissions: + contents: read + id-token: write + uses: ./.github/workflows/benchmark-go.yml + + benchmark-cli: + permissions: + contents: read + id-token: write + uses: ./.github/workflows/benchmark-cli.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e22e0634..a662976f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -319,6 +319,21 @@ jobs: - name: Run tests run: cargo test --verbose + + benchmark-go: + if: ${{ startsWith(github.ref_name, 'chore/release-') || startsWith(github.head_ref, 'chore/release-') }} + permissions: + contents: read + id-token: write + uses: ./.github/workflows/benchmark-go.yml + + benchmark-cli: + if: ${{ startsWith(github.ref_name, 'chore/release-') || startsWith(github.head_ref, 'chore/release-') }} + permissions: + contents: read + id-token: write + uses: ./.github/workflows/benchmark-cli.yml + done: needs: - test-go diff --git a/.gitignore b/.gitignore index 9dd0d5073..45d429d73 100644 --- a/.gitignore +++ b/.gitignore @@ -156,6 +156,7 @@ npm/tsgo/*/local ## vscode settings .vscode/settings.json + # go cache packages/rslint/pkg/ @@ -163,4 +164,4 @@ packages/rslint/pkg/ target/ # generated rule manifest -website/generated/ \ No newline at end of file +website/generated/ diff --git a/README.md b/README.md index 10fe860ac..3dcf714fe 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ npm version downloads license + CodSpeed

> [!NOTE] diff --git a/package.json b/package.json index 8dc367700..723fd57a6 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "build": "pnpm -r --filter=!@typescript/api --filter=!@typescript/ast --filter=@rslint/test-tools... --filter=rslint... build", "build:npm": "zx scripts/build-npm.mjs", "build:website": "pnpm --filter @rslint/core run build:js && pnpm --filter=!@typescript/api --filter=!@typescript/ast --filter=!@rslint/core --filter @rslint/website... -r build", + "bench:cli": "pnpm --filter rslint-bench-cli run bench", + "bench:go": "go test -bench=. -benchtime=5s ./tests/bench-go/", "check-spell": "pnpx cspell lint --no-progress --show-context", "version": "zx scripts/version.mjs", "release": "pnpm publish -r --no-git-checks", diff --git a/packages/rslint/tests/api.test.mjs b/packages/rslint/tests/api.test.mjs index b8c93336c..efc6abaca 100644 --- a/packages/rslint/tests/api.test.mjs +++ b/packages/rslint/tests/api.test.mjs @@ -39,6 +39,25 @@ describe('lint api', async (t) => { }); expect(diags).toMatchSnapshot(); }); + + test('explicit files filter limits lint scope', async () => { + const config = path.resolve(import.meta.dirname, '../fixtures/rslint.json'); + const targetFile = path.resolve(cwd, 'src/index.ts'); + const diags = await lint({ + config, + files: [targetFile], + ruleOptions: { + '@typescript-eslint/no-unsafe-member-access': 'error', + }, + workingDirectory: cwd, + }); + + expect(diags.fileCount).toBe(1); + expect(diags.diagnostics.length).toBeGreaterThan(0); + expect(new Set(diags.diagnostics.map((diag) => diag.filePath))).toEqual( + new Set(['src/index.ts']), + ); + }); }); describe('applyFixes api', async (t) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 980eebbb6..bc67b97ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,6 +274,21 @@ importers: specifier: ^1.0.12 version: 1.0.12 + tests/bench-cli: + devDependencies: + '@codspeed/tinybench-plugin': + specifier: 5.2.0 + version: 5.2.0(tinybench@6.0.0) + '@rslint/core': + specifier: workspace:* + version: link:../../packages/rslint + '@types/node': + specifier: 24.0.14 + version: 24.0.14 + tinybench: + specifier: 6.0.0 + version: 6.0.0 + typescript-go/_packages/api: dependencies: '@types/node': @@ -531,6 +546,14 @@ packages: '@bufbuild/protobuf@2.6.3': resolution: {integrity: sha512-w/gJKME9mYN7ZoUAmSMAWXk4hkVpxRKvEJCb3dV5g9wwWdxTJJ0ayOJAVcNxtdqaxDyFuC0uz4RSGVacJ030PQ==} + '@codspeed/core@5.2.0': + resolution: {integrity: sha512-CmDhpWjcOJg2iBOQ/BmBnSBq8qxlM3r4h8uvYDkoUaba+EKRT3T73BZtKuml/48jZMsB+4/FG2UbTBinDWtuvw==} + + '@codspeed/tinybench-plugin@5.2.0': + resolution: {integrity: sha512-LCmMFON3hdIRqiHC3W8oR0783cecRgA8x7cWMTnC9DgkIuyMrreHgQexnUGV3zsHgB084EXj/iPrWxR914/8Ng==} + peerDependencies: + tinybench: '>=4.0.1' + '@emnapi/core@1.4.5': resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} @@ -2476,6 +2499,9 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + axios@1.15.0: + resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + azure-devops-node-api@12.5.0: resolution: {integrity: sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==} @@ -3080,6 +3106,10 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + find-up@6.3.0: + resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -3103,6 +3133,15 @@ packages: debug: optional: true + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -3111,6 +3150,10 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + framer-motion@12.23.24: resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==} peerDependencies: @@ -3681,6 +3724,10 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} @@ -4084,6 +4131,10 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-sarif-builder@3.2.0: resolution: {integrity: sha512-kVIOdynrF2CRodHZeP/97Rh1syTUHBNiw17hUCIVhlhEsWlfJm19MuO56s4MdKbr22xWx6mzMnNAgXzVlIYM9Q==} engines: {node: '>=18'} @@ -4144,10 +4195,18 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-map@7.0.3: resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==} engines: {node: '>=18'} @@ -4185,6 +4244,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -4265,6 +4328,10 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -4828,6 +4895,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stack-trace@1.0.0-pre2: + resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==} + engines: {node: '>=16'} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -5330,6 +5401,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5487,6 +5562,23 @@ snapshots: '@bufbuild/protobuf@2.6.3': {} + '@codspeed/core@5.2.0': + dependencies: + axios: 1.15.0 + find-up: 6.3.0 + form-data: 4.0.5 + node-gyp-build: 4.8.4 + transitivePeerDependencies: + - debug + + '@codspeed/tinybench-plugin@5.2.0(tinybench@6.0.0)': + dependencies: + '@codspeed/core': 5.2.0 + stack-trace: 1.0.0-pre2 + tinybench: 6.0.0 + transitivePeerDependencies: + - debug + '@emnapi/core@1.4.5': dependencies: '@emnapi/wasi-threads': 1.0.4 @@ -7448,6 +7540,14 @@ snapshots: asynckit@0.4.0: {} + axios@1.15.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + azure-devops-node-api@12.5.0: dependencies: tunnel: 0.0.6 @@ -8071,9 +8171,9 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 file-entry-cache@8.0.0: dependencies: @@ -8088,6 +8188,11 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-up@6.3.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + flat-cache@4.0.1: dependencies: flatted: 3.3.3 @@ -8101,6 +8206,8 @@ snapshots: follow-redirects@1.15.9: {} + follow-redirects@1.16.0: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -8114,6 +8221,14 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + framer-motion@12.23.24(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: motion-dom: 12.23.23 @@ -8740,6 +8855,10 @@ snapshots: dependencies: p-locate: 5.0.0 + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + lodash-es@4.18.1: {} lodash.includes@4.3.0: {} @@ -9439,6 +9558,8 @@ snapshots: node-addon-api@7.1.1: optional: true + node-gyp-build@4.8.4: {} + node-sarif-builder@3.2.0: dependencies: '@types/sarif': 2.1.7 @@ -9524,10 +9645,18 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + p-map@7.0.3: {} package-json-from-dist@1.0.1: {} @@ -9573,6 +9702,8 @@ snapshots: path-exists@4.0.0: {} + path-exists@5.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -9648,6 +9779,8 @@ snapshots: property-information@7.1.0: {} + proxy-from-env@2.1.0: {} + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -10254,6 +10387,8 @@ snapshots: sprintf-js@1.0.3: {} + stack-trace@1.0.0-pre2: {} + stackframe@1.3.4: {} stdin-discarder@0.2.2: {} @@ -10425,8 +10560,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinypool@1.1.1: {} @@ -10749,6 +10884,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + zwitch@2.0.4: {} zx@8.8.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9d123ba86..694f3b587 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - 'packages/*' + - 'tests/bench-cli' - 'npm/rslint/*' - 'npm/tsgo/*' - 'website' diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 0f5e339c6..c89782da9 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -11,6 +11,7 @@ assertee autofixers auvred bazz +benchtime bigints binaryexpression bleh @@ -26,6 +27,8 @@ co-locate colocate constantness corge +codspeed +CODSPEED cpuprof dagre dirxml diff --git a/tests/bench-cli/fixtures/vscode-rslint.config.mjs b/tests/bench-cli/fixtures/vscode-rslint.config.mjs new file mode 100644 index 000000000..78e006903 --- /dev/null +++ b/tests/bench-cli/fixtures/vscode-rslint.config.mjs @@ -0,0 +1,71 @@ +export default [ + { + ignores: ['node_modules/**', '.git/**'], + }, + { + rules: { + 'for-direction': 'error', + 'no-async-promise-executor': 'error', + 'no-caller': 'error', + 'no-class-assign': 'error', + 'no-compare-neg-zero': 'error', + 'no-cond-assign': 'error', + 'no-const-assign': 'error', + 'no-constant-binary-expression': 'error', + 'no-constant-condition': 'error', + 'no-control-regex': 'error', + 'no-debugger': 'error', + 'no-delete-var': 'error', + 'no-dupe-class-members': 'error', + 'no-dupe-else-if': 'error', + 'no-dupe-keys': 'error', + 'no-duplicate-case': 'error', + 'no-empty-character-class': 'error', + 'no-empty-pattern': 'error', + 'no-empty-static-block': 'error', + 'no-eval': 'error', + 'no-ex-assign': 'error', + 'no-extra-boolean-cast': 'error', + 'no-func-assign': 'error', + 'no-global-assign': 'error', + 'no-import-assign': 'error', + 'no-invalid-regexp': 'error', + 'no-irregular-whitespace': 'error', + 'no-loss-of-precision': 'error', + 'no-new-native-nonconstructor': 'error', + 'no-nonoctal-decimal-escape': 'error', + 'no-obj-calls': 'error', + 'no-self-assign': 'error', + 'no-setter-return': 'error', + 'no-shadow-restricted-names': 'error', + 'no-sparse-arrays': 'error', + 'no-this-before-super': 'error', + 'no-unsafe-finally': 'error', + 'no-unsafe-negation': 'error', + 'no-unsafe-optional-chaining': 'error', + 'no-unused-labels': 'error', + 'no-unused-private-class-members': 'error', + 'no-unused-vars': 'error', + 'no-useless-backreference': 'error', + 'no-useless-catch': 'error', + 'no-useless-escape': 'error', + 'no-useless-rename': 'error', + 'no-with': 'error', + 'require-yield': 'error', + 'use-isnan': 'error', + 'valid-typeof': 'error', + '@typescript-eslint/no-duplicate-enum-values': 'error', + '@typescript-eslint/no-extra-non-null-assertion': 'error', + '@typescript-eslint/no-misused-new': 'error', + '@typescript-eslint/no-non-null-asserted-optional-chain': 'error', + '@typescript-eslint/no-this-alias': 'error', + '@typescript-eslint/no-unnecessary-parameter-property-assignment': + 'error', + '@typescript-eslint/no-unsafe-declaration-merging': 'error', + '@typescript-eslint/no-useless-empty-export': 'error', + '@typescript-eslint/no-wrapper-object-types': 'error', + '@typescript-eslint/prefer-as-const': 'error', + '@typescript-eslint/triple-slash-reference': 'error', + }, + }, +]; diff --git a/tests/bench-cli/package.json b/tests/bench-cli/package.json new file mode 100644 index 000000000..a4dfa35e5 --- /dev/null +++ b/tests/bench-cli/package.json @@ -0,0 +1,18 @@ +{ + "name": "rslint-bench-cli", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "init:vscode": "bash ./scripts/init.sh", + "build:bench": "pnpm --filter @rslint/core run build:js && tsgo -p tsconfig.build.json --incremental false", + "bench:cli": "pnpm run init:vscode && pnpm run build:bench && node dist/cli.bench.js", + "bench": "pnpm run bench:cli" + }, + "devDependencies": { + "@codspeed/tinybench-plugin": "5.2.0", + "@rslint/core": "workspace:*", + "@types/node": "24.0.14", + "tinybench": "6.0.0" + } +} diff --git a/tests/bench-cli/scripts/exec-file-sync-preload.cjs b/tests/bench-cli/scripts/exec-file-sync-preload.cjs new file mode 100644 index 000000000..b89d1dac0 --- /dev/null +++ b/tests/bench-cli/scripts/exec-file-sync-preload.cjs @@ -0,0 +1,36 @@ +const fs = require('node:fs'); +const childProcess = require('node:child_process'); +const { syncBuiltinESMExports } = require('node:module'); +const { performance } = require('node:perf_hooks'); + +const stampFdRaw = process.env.RSLINT_BENCH_STAMP_FD; +const stampFd = Number.parseInt(stampFdRaw ?? '', 10); + +if (Number.isNaN(stampFd)) { + return; +} + +const originalExecFileSync = childProcess.execFileSync; +let stamped = false; + +childProcess.execFileSync = function patchedExecFileSync(file, args, options) { + if (!stamped) { + stamped = true; + try { + fs.writeSync( + stampFd, + JSON.stringify({ + interceptedAtMs: performance.timeOrigin + performance.now(), + file: typeof file === 'string' ? file : null, + }) + '\n', + ); + } catch { + // Swallow pipe write errors so benchmark instrumentation never changes + // CLI behavior. + } + } + + return originalExecFileSync.call(this, file, args, options); +}; + +syncBuiltinESMExports(); diff --git a/tests/bench-cli/scripts/init.sh b/tests/bench-cli/scripts/init.sh new file mode 100644 index 000000000..de8111737 --- /dev/null +++ b/tests/bench-cli/scripts/init.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." && pwd)" +TMP_DIR="${TMPDIR:-/tmp}/rslint-bench" +VSCODE_DIR="${TMP_DIR}/vscode" +VSCODE_REPO_URL="https://github.com/microsoft/vscode.git" +VSCODE_COMMIT="41dd792b5e652393e7787322889ed5fdc58bd75b" +CONFIG_TEMPLATE="${REPO_ROOT}/tests/bench-cli/fixtures/vscode-rslint.config.mjs" + +echo "============================================" +echo "Initializing VS Code benchmark fixture" +echo "============================================" +echo "" + +mkdir -p "${TMP_DIR}" + +if [ -d "${VSCODE_DIR}" ]; then + echo "VS Code directory already exists, skipping clone..." +elif [ ! -d "${VSCODE_DIR}/.git" ]; then + echo "Cloning VS Code repository..." + git clone --depth=1 "${VSCODE_REPO_URL}" "${VSCODE_DIR}" +else + echo "VS Code repository already exists, reusing..." +fi + +if [ -d "${VSCODE_DIR}/.git" ]; then + CURRENT_COMMIT="$(git -C "${VSCODE_DIR}" rev-parse HEAD 2>/dev/null || true)" +else + CURRENT_COMMIT="" +fi + +if [ -d "${VSCODE_DIR}/.git" ] && [ "${CURRENT_COMMIT}" != "${VSCODE_COMMIT}" ]; then + echo "Checking out VS Code commit ${VSCODE_COMMIT}..." + git -C "${VSCODE_DIR}" fetch --depth=1 --no-tags origin "${VSCODE_COMMIT}" + git -C "${VSCODE_DIR}" checkout --detach FETCH_HEAD +fi + +# Place benchmark config at the default discovery location so CLI can lint +# without passing an explicit --config flag. +cp "${CONFIG_TEMPLATE}" "${VSCODE_DIR}/rslint.config.mjs" +rm -f "${VSCODE_DIR}/.rslint.bench.config.mjs" + +echo "" +echo "VS Code benchmark fixture is ready:" +echo " ${VSCODE_DIR}" diff --git a/tests/bench-cli/src/cli.bench.ts b/tests/bench-cli/src/cli.bench.ts new file mode 100644 index 000000000..f6de70cc4 --- /dev/null +++ b/tests/bench-cli/src/cli.bench.ts @@ -0,0 +1,127 @@ +import { spawnSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { + addCodspeedCompatibleTask, + type BenchTaskDurationOverride, + createCodspeedCompatibleBench, +} from './utils/bench-runtime.js'; +import { assertExists } from './utils/fs.js'; +import { + STAMP_FD, + readPreBinaryLatencyNs, + summarizeSamples, +} from './utils/pre-binary-stamp.js'; + +// This benchmark reports two tasks to tinybench/CodSpeed: +// 1) `cli@vscode`: parent start -> CLI process completion +// 2) `cli@vscode_before_go_exec`: parent start -> first Go binary invocation +const repoRoot = path.resolve(import.meta.dirname, '../../../'); +const cliEntrypoint = path.join(repoRoot, 'packages/rslint/bin/rslint.cjs'); +const vscodeRepoDir = path.join(os.tmpdir(), 'rslint-bench', 'vscode'); +const benchmarkTaskName = 'cli@vscode'; +const preBinaryBenchmarkName = 'cli@vscode_before_go_exec'; +const execFileSyncPreloadPath = path.resolve( + import.meta.dirname, + '../scripts/exec-file-sync-preload.cjs', +); + +const cliArgs = ['--format', 'jsonline', '--quiet', '.'] as const; +const cliSamplesNs: number[] = []; +const preBinarySamplesNs: number[] = []; +const pendingPreBinaryDurationsNs: number[] = []; +const bench = createCodspeedCompatibleBench(); + +/** + * Runs one CLI invocation and returns both durations from the same start point. + * + * Timing model used here: + * - `startedAtMs` is captured in the parent process right before `spawnSync`. + * - `completedAtMs` is captured right after the CLI process finishes in parent. + * - preload writes `interceptedAtMs` when child process first calls `execFileSync`. + * - total latency ns: `(completedAtMs - startedAtMs) * 1e6`. + * - before-go latency ns: `readPreBinaryLatencyNs()` => `(interceptedAtMs - startedAtMs) * 1e6`. + * + * Returned values are used to feed both benchmark tasks without running CLI twice. + */ +async function runCLI(): Promise<{ totalNs: number; beforeGoNs: number }> { + const startedAtMs = performance.timeOrigin + performance.now(); + + // Keep stdout/stderr detached from benchmark transport so wall time reflects + // lint execution instead of output buffering costs. + const result = spawnSync( + process.execPath, + ['--require', execFileSyncPreloadPath, cliEntrypoint, ...cliArgs], + { + cwd: vscodeRepoDir, + env: { + ...process.env, + RSLINT_BENCH_STAMP_FD: String(STAMP_FD), + }, + stdio: ['ignore', 'ignore', 'ignore', 'pipe'], + }, + ); + + if (result.error) { + throw result.error; + } + if (result.signal != null) { + throw new Error(`Benchmark CLI exited with signal ${result.signal}`); + } + if (typeof result.status !== 'number') { + throw new Error('Benchmark CLI did not report an exit status'); + } + + const completedAtMs = performance.timeOrigin + performance.now(); + const totalNs = Math.round((completedAtMs - startedAtMs) * 1_000_000); + const beforeGoNs = readPreBinaryLatencyNs( + startedAtMs, + result.output?.[STAMP_FD] ?? null, + ); + + return { + totalNs, + beforeGoNs, + }; +} + +function toOverriddenDuration(ns: number): BenchTaskDurationOverride { + return { + overriddenDuration: ns / 1_000_000, + }; +} + +await Promise.all([ + assertExists(cliEntrypoint), + assertExists(vscodeRepoDir), + assertExists(execFileSyncPreloadPath), +]); + +addCodspeedCompatibleTask(bench, benchmarkTaskName, async () => { + const sample = await runCLI(); + cliSamplesNs.push(sample.totalNs); + preBinarySamplesNs.push(sample.beforeGoNs); + pendingPreBinaryDurationsNs.push(sample.beforeGoNs); + + return toOverriddenDuration(sample.totalNs); +}); +addCodspeedCompatibleTask(bench, preBinaryBenchmarkName, () => { + const sample = pendingPreBinaryDurationsNs.shift(); + if (sample == null) { + throw new Error( + `${preBinaryBenchmarkName} has no paired sample from ${benchmarkTaskName}. ` + + 'This benchmark requires iteration-based runs (set RSLINT_BENCH_TIME_MS=0).', + ); + } + + return toOverriddenDuration(sample); +}); + +console.error( + `[bench] ${benchmarkTaskName}: running ${bench.iterations} iteration(s) against ${vscodeRepoDir}. Results print after all iterations finish.`, +); +await bench.run(); +console.error(`[bench] ${benchmarkTaskName}: completed`); +/* rslint-disable */ +console.table([summarizeSamples(benchmarkTaskName, cliSamplesNs)]); +console.table([summarizeSamples(preBinaryBenchmarkName, preBinarySamplesNs)]); diff --git a/tests/bench-cli/src/utils/bench-runtime.ts b/tests/bench-cli/src/utils/bench-runtime.ts new file mode 100644 index 000000000..e2e27c427 --- /dev/null +++ b/tests/bench-cli/src/utils/bench-runtime.ts @@ -0,0 +1,73 @@ +import { withCodSpeed } from '@codspeed/tinybench-plugin'; +import { Bench } from 'tinybench'; + +export type BenchTaskDurationOverride = { + overriddenDuration: number; +}; + +type BenchTaskFnResult = void | BenchTaskDurationOverride; + +export function readIntEnv(name: string, fallback: number): number { + const value = process.env[name]; + if (!value) { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +function createBenchmarkOptions() { + return { + iterations: readIntEnv('RSLINT_BENCH_ITERATIONS', 10), + time: readIntEnv('RSLINT_BENCH_TIME_MS', 0), + // CodSpeed plugin v5.2.0 expects latency samples on each task result. + retainSamples: true, + warmup: false, + }; +} + +export function createCodspeedCompatibleBench(): Bench { + const bench = new Bench({ + ...createBenchmarkOptions(), + throws: true, + }); + + // tinybench@6 exposes runtime options on the instance. CodSpeed plugin + // v5.2.0 still reads `bench.opts`, so provide a compatibility getter. + if (Reflect.get(bench, 'opts') == null) { + Object.defineProperty(bench, 'opts', { + configurable: true, + get() { + return { + iterations: bench.iterations, + time: bench.time, + warmup: bench.warmup, + warmupIterations: bench.warmupIterations, + warmupTime: bench.warmupTime, + throws: bench.throws, + }; + }, + }); + } + + return withCodSpeed(bench); +} + +export function addCodspeedCompatibleTask( + bench: Bench, + name: string, + fn: () => BenchTaskFnResult | Promise, +): void { + bench.add(name, fn); + const task = bench.getTask(name); + + // tinybench@6 keeps task fn in private state, while CodSpeed simulation mode + // still reads `task.fn` / `task.fnOpts`. + if (task) { + Reflect.set(task, 'fn', fn); + if (Reflect.get(task, 'fnOpts') == null) { + Reflect.set(task, 'fnOpts', {}); + } + } +} diff --git a/tests/bench-cli/src/utils/fs.ts b/tests/bench-cli/src/utils/fs.ts new file mode 100644 index 000000000..2d995786c --- /dev/null +++ b/tests/bench-cli/src/utils/fs.ts @@ -0,0 +1,9 @@ +import { stat } from 'node:fs/promises'; + +export async function assertExists(filePath: string): Promise { + try { + await stat(filePath); + } catch (error) { + throw new Error(`Missing benchmark fixture: ${filePath}`, { cause: error }); + } +} diff --git a/tests/bench-cli/src/utils/pre-binary-stamp.ts b/tests/bench-cli/src/utils/pre-binary-stamp.ts new file mode 100644 index 000000000..05e3b8920 --- /dev/null +++ b/tests/bench-cli/src/utils/pre-binary-stamp.ts @@ -0,0 +1,97 @@ +import path from 'node:path'; + +export const STAMP_FD = 3; + +type ExecFileSyncStamp = { + interceptedAtMs?: number; + file?: string | null; +}; + +function parseExecFileSyncStamp(output: string | Buffer | null): { + payload: ExecFileSyncStamp; + rawOutput: string; +} { + if (output == null) { + throw new Error('Missing execFileSync stamp output'); + } + + const rawOutput = + typeof output === 'string' ? output : output.toString('utf8'); + const lines = rawOutput + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (lines.length === 0) { + throw new Error('Empty execFileSync stamp output'); + } + + return { + payload: JSON.parse(lines[lines.length - 1]) as ExecFileSyncStamp, + rawOutput, + }; +} + +export function readPreBinaryLatencyNs( + startedAtMs: number, + output: string | Buffer | null, +): number { + // "before_go_exec" latency is computed against the parent timestamp: + // startedAtMs (parent pre-spawn) -> interceptedAtMs (child first execFileSync). + const { payload, rawOutput } = parseExecFileSyncStamp(output); + + if (typeof payload.interceptedAtMs !== 'number') { + throw new Error(`Invalid execFileSync stamp payload: ${rawOutput}`); + } + + const expectedBinaryName = + process.platform === 'win32' ? 'rslint.exe' : 'rslint'; + if ( + typeof payload.file !== 'string' || + path.basename(payload.file) !== expectedBinaryName + ) { + throw new Error( + `Unexpected execFileSync target in stamp payload: ${rawOutput}`, + ); + } + + return Math.round((payload.interceptedAtMs - startedAtMs) * 1_000_000); +} + +export function summarizeSamples( + taskName: string, + samples: number[], +): Record { + if (samples.length === 0) { + throw new Error(`No samples collected for ${taskName}`); + } + + const sorted = [...samples].sort((left, right) => left - right); + const total = samples.reduce((sum, sample) => sum + sample, 0); + const middle = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 + ? Math.round((sorted[middle - 1] + sorted[middle]) / 2) + : sorted[middle]; + const avgNs = Math.round(total / samples.length); + const throughputAvg = Number((1_000_000_000 / avgNs).toFixed(6)); + const throughputMed = Number((1_000_000_000 / median).toFixed(6)); + + // Keep schema aligned with tinybench.table() output fields for side-by-side + // comparison in benchmark logs. + // + // Metric definitions: + // - Latency avg (ns): arithmetic mean of sample latency in nanoseconds. + // - Latency med (ns): median latency in nanoseconds (robust to outliers). + // - Throughput avg (ops/s): 1e9 / Latency avg, estimated operations per second. + // - Throughput med (ops/s): 1e9 / Latency med, median-based ops/s estimate. + // - Samples: number of benchmark samples included in this summary. + return { + 'Task name': taskName, + 'Latency avg (ns)': avgNs, + 'Latency med (ns)': median, + 'Throughput avg (ops/s)': throughputAvg, + 'Throughput med (ops/s)': throughputMed, + Samples: samples.length, + }; +} diff --git a/tests/bench-cli/tsconfig.build.json b/tests/bench-cli/tsconfig.build.json new file mode 100644 index 000000000..51ca36cf0 --- /dev/null +++ b/tests/bench-cli/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "composite": false, + "declaration": false, + "declarationMap": false, + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + } +} diff --git a/tests/bench-cli/tsconfig.json b/tests/bench-cli/tsconfig.json new file mode 100644 index 000000000..fe86fc50e --- /dev/null +++ b/tests/bench-cli/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "customConditions": ["@typescript/source"], + "noEmit": true, + "rootDir": ".", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/tests/bench-go/linter_bench_test.go b/tests/bench-go/linter_bench_test.go new file mode 100644 index 000000000..139fc9ccc --- /dev/null +++ b/tests/bench-go/linter_bench_test.go @@ -0,0 +1,296 @@ +package benchmark + +import ( + "context" + "os" + "path/filepath" + "strconv" + "sync/atomic" + "testing" + + "github.com/microsoft/typescript-go/shim/ast" + "github.com/microsoft/typescript-go/shim/bundled" + "github.com/microsoft/typescript-go/shim/compiler" + "github.com/microsoft/typescript-go/shim/tspath" + "github.com/microsoft/typescript-go/shim/vfs/cachedvfs" + "github.com/microsoft/typescript-go/shim/vfs/osvfs" + "github.com/web-infra-dev/rslint/internal/linter" + "github.com/web-infra-dev/rslint/internal/rule" + "github.com/web-infra-dev/rslint/internal/utils" +) + +var ( + benchmarkSyntaxSink atomic.Int64 + benchmarkTypeSink atomic.Int64 + benchmarkSemanticSink int +) + +func BenchmarkLinterSyntaxRules(b *testing.B) { + program := createBenchmarkProgramInDir(b, filepath.Join(b.TempDir(), "syntax"), 32, false) + rules := benchmarkSyntaxRules() + getRules := func(*ast.SourceFile) []linter.ConfiguredRule { return rules } + onDiag := func(rule.RuleDiagnostic) {} + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + linter.RunLinterInProgram( + program, + nil, + nil, + utils.ExcludePaths, + getRules, + false, + onDiag, + nil, + nil, + ) + } +} + +func BenchmarkLinterTypeAwareRules(b *testing.B) { + program := createBenchmarkProgramInDir(b, filepath.Join(b.TempDir(), "type-aware"), 32, false) + rules := benchmarkTypeAwareRules() + typeInfoFiles := benchmarkTypeInfoFiles(program) + getRules := func(*ast.SourceFile) []linter.ConfiguredRule { return rules } + onDiag := func(rule.RuleDiagnostic) {} + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + linter.RunLinterInProgram( + program, + nil, + nil, + utils.ExcludePaths, + getRules, + false, + onDiag, + typeInfoFiles, + nil, + ) + } +} + +func BenchmarkLinterSemanticDiagnostics(b *testing.B) { + projectDir := createBenchmarkProjectInDir(b, filepath.Join(b.TempDir(), "semantic"), 32, true) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + // Reload the program outside the timed region so each iteration measures + // a fresh semantic pass instead of checker-side diagnostic caches. + b.StopTimer() + program := loadBenchmarkProgramInDir(b, projectDir) + sourceFiles := benchmarkRootSourceFiles(b, program) + b.StartTimer() + + total := 0 + for _, sourceFile := range sourceFiles { + total += len(program.GetSemanticDiagnostics(ctx, sourceFile)) + } + benchmarkSemanticSink = total + } +} + +func benchmarkSyntaxRules() []linter.ConfiguredRule { + return []linter.ConfiguredRule{ + { + Name: "bench-syntax-vars", + Severity: rule.SeverityWarning, + Run: func(ctx rule.RuleContext) rule.RuleListeners { + return rule.RuleListeners{ + ast.KindVariableDeclarationList: func(node *ast.Node) { + if node.Flags&ast.NodeFlagsBlockScoped == 0 { + benchmarkSyntaxSink.Add(1) + } + }, + ast.KindBinaryExpression: func(node *ast.Node) { + bin := node.AsBinaryExpression() + if bin == nil { + return + } + op := bin.OperatorToken.Kind + if op == ast.KindEqualsEqualsToken || op == ast.KindExclamationEqualsToken { + benchmarkSyntaxSink.Add(1) + } + }, + } + }, + }, + { + Name: "bench-syntax-control-flow", + Severity: rule.SeverityWarning, + Run: func(ctx rule.RuleContext) rule.RuleListeners { + return rule.RuleListeners{ + ast.KindDebuggerStatement: func(node *ast.Node) { + benchmarkSyntaxSink.Add(1) + }, + ast.KindForStatement: func(node *ast.Node) { + benchmarkSyntaxSink.Add(1) + }, + ast.KindSwitchStatement: func(node *ast.Node) { + benchmarkSyntaxSink.Add(1) + }, + } + }, + }, + } +} + +func benchmarkTypeAwareRules() []linter.ConfiguredRule { + return []linter.ConfiguredRule{ + { + Name: "bench-type-identifiers", + Severity: rule.SeverityWarning, + RequiresTypeInfo: true, + Run: func(ctx rule.RuleContext) rule.RuleListeners { + return rule.RuleListeners{ + ast.KindIdentifier: func(node *ast.Node) { + if ctx.TypeChecker == nil { + return + } + if ctx.TypeChecker.GetSymbolAtLocation(node) != nil { + benchmarkTypeSink.Add(1) + } + }, + } + }, + }, + { + Name: "bench-type-expressions", + Severity: rule.SeverityWarning, + RequiresTypeInfo: true, + Run: func(ctx rule.RuleContext) rule.RuleListeners { + return rule.RuleListeners{ + ast.KindPropertyAccessExpression: func(node *ast.Node) { + if ctx.TypeChecker == nil { + return + } + if ctx.TypeChecker.GetTypeAtLocation(node) != nil { + benchmarkTypeSink.Add(1) + } + }, + ast.KindCallExpression: func(node *ast.Node) { + if ctx.TypeChecker == nil { + return + } + callExpr := node.AsCallExpression() + if callExpr == nil { + return + } + if ctx.TypeChecker.GetTypeAtLocation(callExpr.Expression) != nil { + benchmarkTypeSink.Add(1) + } + }, + } + }, + }, + } +} + +func createBenchmarkProgramInDir(b *testing.B, configDir string, fileCount int, injectTypeError bool) *compiler.Program { + b.Helper() + + createBenchmarkProjectInDir(b, configDir, fileCount, injectTypeError) + return loadBenchmarkProgramInDir(b, configDir) +} + +func createBenchmarkProjectInDir(b *testing.B, configDir string, fileCount int, injectTypeError bool) string { + b.Helper() + + srcDir := filepath.Join(configDir, "src") + if err := os.MkdirAll(srcDir, 0o755); err != nil { + b.Fatalf("failed to create benchmark source dir %s: %v", srcDir, err) + } + + for i := range fileCount { + filePath := filepath.Join(srcDir, "file"+strconv.Itoa(i)+".ts") + if err := os.WriteFile(filePath, []byte(benchmarkFileContents(i, injectTypeError)), 0o644); err != nil { + b.Fatalf("failed to write %s: %v", filePath, err) + } + } + + tsconfig := `{"compilerOptions":{"strict":true,"target":"esnext","module":"esnext"},"include":["src/**/*.ts"]}` + if err := os.WriteFile(filepath.Join(configDir, "tsconfig.json"), []byte(tsconfig), 0o644); err != nil { + b.Fatalf("failed to write tsconfig.json: %v", err) + } + + return configDir +} + +func loadBenchmarkProgramInDir(b *testing.B, configDir string) *compiler.Program { + b.Helper() + + fs := bundled.WrapFS(cachedvfs.From(osvfs.FS())) + host := utils.CreateCompilerHost(configDir, fs) + program, err := utils.CreateProgram(true, fs, configDir, "tsconfig.json", host) + if err != nil { + b.Fatalf("failed to create program: %v", err) + } + + return program +} + +func benchmarkTypeInfoFiles(program *compiler.Program) map[string]struct{} { + typeInfoFiles := make(map[string]struct{}, len(program.CommandLine().FileNames())) + for _, fileName := range program.CommandLine().FileNames() { + typeInfoFiles[fileName] = struct{}{} + } + return typeInfoFiles +} + +func benchmarkRootSourceFiles(b *testing.B, program *compiler.Program) []*ast.SourceFile { + b.Helper() + + rootFiles := program.CommandLine().FileNames() + sourceFiles := make([]*ast.SourceFile, 0, len(rootFiles)) + for _, fileName := range rootFiles { + sourceFile := program.GetSourceFile(tspath.NormalizePath(fileName)) + if sourceFile == nil { + b.Fatalf("source file %s not found in program", fileName) + } + sourceFiles = append(sourceFiles, sourceFile) + } + return sourceFiles +} + +func benchmarkFileContents(index int, injectTypeError bool) string { + suffix := strconv.Itoa(index) + typeErrorLine := "" + if injectTypeError { + typeErrorLine = " const brokenValue: number = 'not-a-number';\n" + } + + return "type Status" + suffix + " = 'open' | 'closed';\n" + + "interface User" + suffix + " { id: number; name: string; nested?: { value: number } }\n" + + "const records" + suffix + ": User" + suffix + "[] = [];\n" + + "export async function process" + suffix + "(input: string, users: User" + suffix + "[]): Promise {\n" + + " var total = 0;\n" + + " for (var i = 0; i < users.length; i++) {\n" + + " const user = users[i];\n" + + " if (input == '') {\n" + + " debugger;\n" + + " }\n" + + " switch (user.id) {\n" + + " case 1:\n" + + " total += user.id;\n" + + " break;\n" + + " default:\n" + + " total += user.nested?.value ?? 0;\n" + + " }\n" + + " const payload: any = { value: Promise.resolve(user.name) };\n" + + " const resolved = await payload.value;\n" + + " if (resolved != null) {\n" + + " total += resolved.length;\n" + + " }\n" + + typeErrorLine + + " }\n" + + " records" + suffix + ".push(...users);\n" + + " return total + records" + suffix + ".length;\n" + + "}\n" +} diff --git a/tsconfig.json b/tsconfig.json index 706aa92ce..e291109f1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "./packages/vscode-extension" + }, + { + "path": "./tests/bench-cli" } ] }