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 @@
+
> [!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"
}
]
}