diff --git a/.changeset/cold-olives-mate.md b/.changeset/cold-olives-mate.md new file mode 100644 index 0000000..b3fde8d --- /dev/null +++ b/.changeset/cold-olives-mate.md @@ -0,0 +1,5 @@ +--- +"react-query-visualizer": patch +--- + +Fix scan regression by removing deprecated JSX prop scanning and excluding .d.ts files. diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..7ae4f27 --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,29 @@ +name: Run tests and upload coverage + +on: + push: + +jobs: + test: + name: Run tests and collect coverage + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Setup pnpm & node + uses: ./.github/actions/pnpm-setup-node + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test + + - name: Upload results to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/lcov.info diff --git a/.gitignore b/.gitignore index 57417e2..597ab6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules dist *.vsix -.omx \ No newline at end of file +.omx +coverage \ No newline at end of file diff --git a/README.md b/README.md index 7ee5c72..d5e7fe4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Useful for searches like React Query visualizer, TanStack Query debugger, React [![VS Marketplace](https://img.shields.io/vscode-marketplace/v/fe-dudu.react-query-visualizer?label=VS%20Marketplace)](https://marketplace.visualstudio.com/items?itemName=fe-dudu.react-query-visualizer) [![Open VSX](https://img.shields.io/open-vsx/v/fe-dudu/react-query-visualizer)](https://open-vsx.org/extension/fe-dudu/react-query-visualizer) +[![Codecov](https://codecov.io/gh/fe-dudu/react-query-visualizer/graph/badge.svg?token=4RKVFTPP41)](https://codecov.io/gh/fe-dudu/react-query-visualizer) ![React Query Visualizer screenshot](https://raw.githubusercontent.com/fe-dudu/react-query-visualizer/main/media/screenshot.png) ## Open Source diff --git a/biome.json b/biome.json index 810979a..6b93b24 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "files": { - "includes": ["**", "!**/node_modules", "!dist", "!.vscode-test", "!*.vsix"] + "includes": ["**", "!**/node_modules", "!dist", "!.vscode-test", "!*.vsix", "!coverage"] }, "assist": { "actions": { diff --git a/package.json b/package.json index 958bae7..82f54cd 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "release:marketplaces": "pnpm run build && pnpm run release:vsce && pnpm run release:ovsx", "release:ovsx": "pnpm dlx ovsx publish --no-dependencies --skip-duplicate", "release:vsce": "pnpm exec vsce publish --skip-duplicate --no-dependencies", + "test": "vitest run --coverage", + "test:watch": "vitest", "type-check": "tsc --noEmit", "version:changeset": "changeset version", "watch:webview": "pnpm run build:webview:bundle -- --watch" @@ -57,13 +59,16 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/vscode": "1.74.0", + "@vitest/coverage-v8": "4.1.7", "@vscode/vsce": "^3.7.1", "esbuild": "^0.27.3", "husky": "^9.1.7", + "jsdom": "29.1.1", "lint-staged": "^16.2.7", "packlint": "^0.2.4", "tailwindcss": "^4.1.18", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "4.1.7" }, "engines": { "vscode": "^1.74.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 008ef5d..216da8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@types/vscode': specifier: 1.74.0 version: 1.74.0 + '@vitest/coverage-v8': + specifier: 4.1.7 + version: 4.1.7(vitest@4.1.7) '@vscode/vsce': specifier: ^3.7.1 version: 3.7.1 @@ -75,6 +78,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + jsdom: + specifier: 29.1.1 + version: 29.1.1 lint-staged: specifier: ^16.2.7 version: 16.2.7 @@ -87,9 +93,27 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: 4.1.7 + version: 4.1.7(@types/node@25.2.3)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) packages: + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@azu/format-text@1.0.2': resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} @@ -144,14 +168,31 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.3.15': resolution: {integrity: sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==} engines: {node: '>=14.21.3'} @@ -174,24 +215,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.3.15': resolution: {integrity: sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.3.15': resolution: {integrity: sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.3.15': resolution: {integrity: sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.3.15': resolution: {integrity: sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==} @@ -205,6 +250,10 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@changesets/apply-release-plan@7.0.14': resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==} @@ -335,6 +384,42 @@ packages: resolution: {integrity: sha512-aO5l99BQJ0X34ft8b0h7QFkQlqxC6e7ZPVmBKz13xM9O8obDaM1Cld4sQlJDXXU/VFuUzQ30mVtHjVz74TuStw==} engines: {node: '>=v18'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4': + resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -500,6 +585,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -600,48 +694,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.132.0': resolution: {integrity: sha512-WozHg3Kc//8Sk756HXXgMbEAvqtG+Lzb9JOojwQzIGDtN78Az2dLttkb71akWYUF/8IgYfDSlfKh4Uot8is5Vw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.132.0': resolution: {integrity: sha512-CmX/ulNBOEwWTyVRmcpYKAcAizW6+OjtLJgo7fXoL9OqQvjF4VER8tPomv44vwzfSCy1BHbsB0ZlZYzYJNj4cA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.132.0': resolution: {integrity: sha512-j9oQS+hM90SdhviNGWbPgT4+Rlq+ac++q/zjgwPD1mVHgxHzATvoRGtDx0sXGmFOQ9J9YkwAhYGb5MAHL6TAsA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.132.0': resolution: {integrity: sha512-bLz+Xi+Agnfmd7kWPEsSVwCn2k4EyIalZkNBcQ0OGIv9rqn8VgCPLNd03tM9mKX/5TdlvDXalz0q71BIrOPNqg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.132.0': resolution: {integrity: sha512-U6t2qbJU0ypTfyj9QV3W1Y6mITDTL8ai/OR6NUn85vyHthOvobKWgXzU4tu0EskSzlpuVFz1g0jFGulDIUKHxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.132.0': resolution: {integrity: sha512-WcEaSNHFk8yz5YFlQQAlhq6jOFmZBB/RKE7uzhyCIf+pF1Lmv9gUH4221mle2Gd9iHyWT3ySNph8yZgb1xYdWg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.132.0': resolution: {integrity: sha512-iQrV4iJzQgRwK3BWRmQl1C3C6g3wYpXN2WLdQdyR+efoUnncdShZAVp9OgcojtlD3MDRbuOMGG3SjxF4fL4nlQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.132.0': resolution: {integrity: sha512-FWzmUGrZ6GUby4U7WIwcCtab6tdmlTO3xTRRKyb5kjIJVEiaUAT8animUG/nK8ZCA8gkRkPOTId4rl6uTqUmJQ==} @@ -652,6 +754,7 @@ packages: '@oxc-parser/binding-wasm32-wasi@0.132.0': resolution: {integrity: sha512-TlbMppxJI5CjWDes0QaP6G3aneVg1yikBu5QYI+DUShF9WDL66ccgKFNNGmi/Wybtszw6hxwAvv76T4DaPKnHw==} engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] '@oxc-parser/binding-win32-arm64-msvc@0.132.0': resolution: {integrity: sha512-RH/NbFjGKqdUAUi7Oh3LQPxUk2hsWFEEQ38HSnbRQT8QjBZFKqL1fMbmsB3N4jy/KPh9iX94+9dmkEMBBbambw==} @@ -717,36 +820,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -770,6 +879,144 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + '@secretlint/config-creator@10.2.2': resolution: {integrity: sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==} engines: {node: '>=20.0.0'} @@ -819,6 +1066,9 @@ packages: resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/cli@4.1.18': resolution: {integrity: sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==} hasBin: true @@ -861,24 +1111,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -926,6 +1180,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} @@ -947,6 +1204,15 @@ packages: '@types/dagre@0.7.53': resolution: {integrity: sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -980,6 +1246,44 @@ packages: resolution: {integrity: sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA==} engines: {node: '>=20.0.0'} + '@vitest/coverage-v8@4.1.7': + resolution: {integrity: sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==} + peerDependencies: + '@vitest/browser': 4.1.7 + vitest: 4.1.7 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + '@vscode/vsce-sign-alpine-arm64@2.0.6': resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==} cpu: [arm64] @@ -1086,6 +1390,13 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -1110,6 +1421,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binaryextensions@6.11.0: resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} engines: {node: '>=4'} @@ -1159,6 +1473,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1249,6 +1567,9 @@ packages: engines: {node: '>=18'} hasBin: true + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cosmiconfig-typescript-loader@6.2.0: resolution: {integrity: sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==} engines: {node: '>=v18'} @@ -1277,6 +1598,10 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} @@ -1329,6 +1654,10 @@ packages: resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} engines: {node: '>=12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} @@ -1341,6 +1670,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -1441,6 +1773,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1460,6 +1796,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1482,6 +1821,9 @@ packages: engines: {node: '>=4'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -1489,6 +1831,10 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -1508,6 +1854,15 @@ packages: fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1547,6 +1902,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1630,6 +1990,13 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} @@ -1731,6 +2098,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -1746,6 +2116,18 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + istextorbinary@9.5.0: resolution: {integrity: sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==} engines: {node: '>=4'} @@ -1758,6 +2140,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1769,6 +2154,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -1841,24 +2235,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -1955,6 +2353,10 @@ packages: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + engines: {node: 20 || >=22} + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -1962,6 +2364,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-it@14.1.1: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true @@ -1970,6 +2379,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -2041,6 +2453,11 @@ packages: resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} engines: {node: '>=20.17'} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -2078,6 +2495,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2162,6 +2582,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2186,6 +2609,9 @@ packages: resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} engines: {node: '>=18'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -2200,6 +2626,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -2216,6 +2646,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -2233,6 +2667,10 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -2305,6 +2743,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -2322,6 +2765,10 @@ packages: resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2363,6 +2810,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2415,6 +2865,12 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -2461,6 +2917,9 @@ packages: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + table@6.9.0: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} @@ -2494,10 +2953,28 @@ packages: resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} engines: {node: '>=4'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.2.0: + resolution: {integrity: sha512-3+5Dl9UnrwprXI8slMwHISvKoMwSo74tr4MOtlOXRZtrw+y/EYx9H4prgmRR0cNdP8xL1YPRR0VVrmkpTUcEKg==} + + tldts@7.2.0: + resolution: {integrity: sha512-qQP9R4GNm4Vhw15edex5E9ExMV6qj0VetA5ycNc+rdfIv2WtOOd9GIHmnZKHD768IZS77bNmUVE9e3IEekaXZg==} + hasBin: true + tmp@0.2.5: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} @@ -2506,9 +2983,17 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-toolbelt@6.15.5: resolution: {integrity: sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==} @@ -2550,6 +3035,10 @@ packages: resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -2588,9 +3077,98 @@ packages: resolution: {integrity: sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==} engines: {node: '>=4'} + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -2600,6 +3178,14 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -2608,6 +3194,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2623,6 +3214,10 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml2js@0.5.0: resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} engines: {node: '>=4.0.0'} @@ -2631,6 +3226,9 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2688,6 +3286,26 @@ packages: snapshots: + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@azu/format-text@1.0.2': {} '@azu/style-format@1.0.1': @@ -2783,10 +3401,23 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + '@babel/runtime@7.28.6': {} + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.3.15': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.3.15 @@ -2822,6 +3453,10 @@ snapshots: '@biomejs/cli-win32-x64@2.3.15': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@changesets/apply-release-plan@7.0.14': dependencies: '@changesets/config': 3.1.2 @@ -3090,6 +3725,30 @@ snapshots: conventional-commits-parser: 6.2.1 picocolors: 1.1.1 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -3181,6 +3840,8 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@exodus/bytes@1.15.1': {} + '@inquirer/external-editor@1.0.3(@types/node@25.2.3)': dependencies: chardet: 2.1.1 @@ -3391,6 +4052,81 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + '@secretlint/config-creator@10.2.2': dependencies: '@secretlint/types': 10.2.2 @@ -3467,6 +4203,8 @@ snapshots: '@sindresorhus/merge-streams@2.3.0': {} + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/cli@4.1.18': dependencies: '@parcel/watcher': 2.5.6 @@ -3571,6 +4309,11 @@ snapshots: dependencies: tslib: 2.8.1 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-color@3.1.3': {} '@types/d3-drag@3.0.7': @@ -3594,6 +4337,12 @@ snapshots: '@types/dagre@0.7.53': {} + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + '@types/node@12.20.55': {} '@types/node@25.2.3': @@ -3628,6 +4377,61 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@4.1.7(vitest@4.1.7)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.7 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.7(@types/node@25.2.3)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) + + '@vitest/expect@4.1.7': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.7(vite@7.3.3(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.3(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + + '@vitest/pretty-format@4.1.7': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.7': + dependencies: + '@vitest/utils': 4.1.7 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.7': {} + + '@vitest/utils@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@vscode/vsce-sign-alpine-arm64@2.0.6': optional: true @@ -3761,6 +4565,14 @@ snapshots: array-union@2.1.0: {} + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astral-regex@2.0.0: {} asynckit@0.4.0: {} @@ -3783,6 +4595,10 @@ snapshots: dependencies: is-windows: 1.0.2 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binaryextensions@6.11.0: dependencies: editions: 6.22.0 @@ -3837,6 +4653,8 @@ snapshots: callsites@3.1.0: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3932,6 +4750,8 @@ snapshots: dependencies: meow: 13.2.0 + convert-source-map@2.0.0: {} + cosmiconfig-typescript-loader@6.2.0(@types/node@25.2.3)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): dependencies: '@types/node': 25.2.3 @@ -3970,6 +4790,11 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-what@6.2.2: {} csstype@3.2.3: {} @@ -4017,12 +4842,21 @@ snapshots: dargs@8.1.0: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + dataloader@1.4.0: {} debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -4118,6 +4952,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -4130,6 +4966,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -4174,11 +5012,17 @@ snapshots: esprima@4.0.1: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + eventemitter3@5.0.4: {} expand-template@2.0.3: optional: true + expect-type@1.3.0: {} + extendable-error@0.1.7: {} fast-deep-equal@3.1.3: {} @@ -4201,6 +5045,10 @@ snapshots: dependencies: pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -4255,6 +5103,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-caller-file@2.0.5: {} @@ -4351,6 +5202,14 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -4434,6 +5293,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -4446,6 +5307,19 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + istextorbinary@9.5.0: dependencies: binaryextensions: 6.11.0 @@ -4458,6 +5332,8 @@ snapshots: jiti@2.6.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -4469,6 +5345,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.0 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + json-parse-even-better-errors@2.3.1: {} json-schema-traverse@1.0.0: {} @@ -4643,6 +5545,8 @@ snapshots: lru-cache@11.2.6: {} + lru-cache@11.5.0: {} + lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -4651,6 +5555,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + markdown-it@14.1.1: dependencies: argparse: 2.0.1 @@ -4662,6 +5576,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + mdurl@2.0.0: {} meow@12.1.1: {} @@ -4711,6 +5627,8 @@ snapshots: nano-spawn@2.0.0: {} + nanoid@3.3.12: {} + napi-build-utils@2.0.0: optional: true @@ -4745,6 +5663,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -4864,6 +5784,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-exists@5.0.0: {} @@ -4879,6 +5803,8 @@ snapshots: path-type@6.0.0: {} + pathe@2.0.3: {} + pend@1.2.0: {} picocolors@1.1.1: {} @@ -4887,6 +5813,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + pidtree@0.6.0: {} pify@4.0.1: {} @@ -4895,6 +5823,12 @@ snapshots: pluralize@8.0.0: {} + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -4921,6 +5855,8 @@ snapshots: punycode.js@2.3.1: {} + punycode@2.3.1: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -4998,6 +5934,37 @@ snapshots: rfdc@1.4.1: {} + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + run-applescript@7.1.0: {} run-parallel@1.2.0: @@ -5010,6 +5977,10 @@ snapshots: sax@1.4.4: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} secretlint@10.2.2: @@ -5062,6 +6033,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} simple-concat@1.0.1: @@ -5114,6 +6087,10 @@ snapshots: sprintf-js@1.0.3: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + string-argv@0.3.2: {} string-width@4.2.3: @@ -5164,6 +6141,8 @@ snapshots: has-flag: 4.0.0 supports-color: 7.2.0 + symbol-tree@3.2.4: {} + table@6.9.0: dependencies: ajv: 8.18.0 @@ -5206,16 +6185,39 @@ snapshots: dependencies: editions: 6.22.0 + tinybench@2.9.0: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@7.2.0: {} + + tldts@7.2.0: + dependencies: + tldts-core: 7.2.0 + tmp@0.2.5: {} to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.1: + dependencies: + tldts: 7.2.0 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-toolbelt@6.15.5: {} tslib@2.8.1: {} @@ -5247,6 +6249,8 @@ snapshots: undici@7.22.0: {} + undici@7.25.0: {} + unicorn-magic@0.1.0: {} unicorn-magic@0.3.0: {} @@ -5273,14 +6277,74 @@ snapshots: version-range@4.15.0: {} + vite@7.3.3(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.2.3 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + yaml: 2.8.2 + + vitest@4.1.7(@types/node@25.2.3)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@7.3.3(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 7.3.3(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.2.3 + '@vitest/coverage-v8': 4.1.7(vitest@4.1.7) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -5290,6 +6354,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -5309,6 +6378,8 @@ snapshots: dependencies: is-wsl: 3.1.1 + xml-name-validator@5.0.0: {} + xml2js@0.5.0: dependencies: sax: 1.4.4 @@ -5316,6 +6387,8 @@ snapshots: xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@4.0.0: {} diff --git a/src/core/analysis/__tests__/analysisHarness.ts b/src/core/analysis/__tests__/analysisHarness.ts new file mode 100644 index 0000000..023c610 --- /dev/null +++ b/src/core/analysis/__tests__/analysisHarness.ts @@ -0,0 +1,33 @@ +import type * as t from '../ast'; +import { scanCalls, scanImports, scanLocalBindings } from '../astScan'; +import { createParseContext } from '../context'; +import { createQueryKeyResolver, resetResolverCache } from '../resolver'; +import { parseSource } from '../sourceParser'; +import { buildSymbolIndex, normalizeAnalyzerPath } from '../symbols'; +import type { QueryRecord } from '../../../shared/contracts'; +export function analyzeSources(files: Record, rootPath = '/repo'): QueryRecord[] { + const parsedAsts = new Map(); + for (const [filePath, source] of Object.entries(files)) { + const normalized = normalizeAnalyzerPath(filePath); + parsedAsts.set(normalized, parseSource(source, normalized)); + } + + const symbolIndex = buildSymbolIndex(parsedAsts); + resetResolverCache(); + const records: QueryRecord[] = []; + + for (const [filePath, ast] of parsedAsts) { + const context = createParseContext(); + const resolver = createQueryKeyResolver(filePath, symbolIndex, rootPath); + + scanImports(ast, context); + scanLocalBindings(ast, context, resolver, filePath); + scanCalls(ast, filePath, context, records, resolver); + } + + return records; +} + +export function analyze(source: string, extraFiles: Record = {}, rootPath = '/repo'): QueryRecord[] { + return analyzeSources({ 'src/app.ts': source, ...extraFiles }, rootPath); +} diff --git a/src/core/analysis/__tests__/analyzer.test.ts b/src/core/analysis/__tests__/analyzer.test.ts new file mode 100644 index 0000000..5377336 --- /dev/null +++ b/src/core/analysis/__tests__/analyzer.test.ts @@ -0,0 +1,61 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { collectFiles, runStaticAnalysis } from '../analyzer'; +import type { ScanScope } from '../../../shared/contracts'; + +async function makeWorkspace(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-analyzer-')); + await mkdir(path.join(root, 'src'), { recursive: true }); + + await writeFile(path.join(root, 'src', 'keys.ts'), ["export const todoKey = ['todos'] as const;"].join('\n')); + + await writeFile( + path.join(root, 'src', 'app.ts'), + [ + "import { useQuery, useQueryClient } from '@tanstack/react-query';", + "import { todoKey } from '../keys';", + 'const client = useQueryClient();', + 'export function run() {', + ' useQuery({ queryKey: todoKey, queryFn: () => null });', + ' client.invalidateQueries({ queryKey: todoKey });', + ' return null;', + '}', + ].join('\n'), + ); + + // A file that fails to parse, to exercise the parseErrors branch. + await writeFile(path.join(root, 'src', 'broken.ts'), 'export const = ;'); + await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'rqv-analyzer' })); + + return root; +} + +const scope: ScanScope = { + folders: [], + includeGlob: '**/*.{ts,tsx}', + excludeGlob: '**/node_modules/**', + maxFileSizeKB: 512, + useGitIgnore: false, +}; + +describe('core/analysis/analyzer', () => { + it('collects files within the workspace', async () => { + const root = await makeWorkspace(); + const files = await collectFiles(root, scope); + expect(files.some((f) => f.endsWith('app.ts'))).toBe(true); + expect(files.some((f) => f.endsWith('keys.ts'))).toBe(true); + }); + + it('runs static analysis, producing records and capturing parse errors', async () => { + const root = await makeWorkspace(); + const result = await runStaticAnalysis(root, scope); + + expect(result.filesScanned).toBeGreaterThanOrEqual(3); + expect(result.scannedFiles.some((f) => f.endsWith('app.ts'))).toBe(true); + expect(result.records.length).toBeGreaterThan(0); + expect(result.parseErrors.some((e) => e.file.endsWith('broken.ts'))).toBe(true); + }); +}); diff --git a/src/core/analysis/__tests__/ast.test.ts b/src/core/analysis/__tests__/ast.test.ts new file mode 100644 index 0000000..e70f1a1 --- /dev/null +++ b/src/core/analysis/__tests__/ast.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; + +import * as ast from '../ast'; + +type Guard = (node: unknown) => boolean; + +const node = (type: string, extra: Record = {}): unknown => ({ type, ...extra }); + +describe('core/analysis/ast', () => { + it('recognizes supported AST node shapes', () => { + const cases: Array<[Guard, unknown]> = [ + [ast.isFile, node('File')], + [ast.isProgram, node('Program')], + [ast.isIdentifier, node('Identifier')], + [ast.isStringLiteral, node('StringLiteral')], + [ast.isStringLiteral, node('Literal', { value: 'text' })], + [ast.isNumericLiteral, node('NumericLiteral')], + [ast.isNumericLiteral, node('Literal', { value: 1 })], + [ast.isBooleanLiteral, node('BooleanLiteral')], + [ast.isBooleanLiteral, node('Literal', { value: true })], + [ast.isNullLiteral, node('NullLiteral')], + [ast.isNullLiteral, node('Literal', { value: null })], + [ast.isBigIntLiteral, node('BigIntLiteral')], + [ast.isTemplateLiteral, node('TemplateLiteral')], + [ast.isJSXIdentifier, node('JSXIdentifier')], + [ast.isJSXExpressionContainer, node('JSXExpressionContainer')], + [ast.isJSXEmptyExpression, node('JSXEmptyExpression')], + [ast.isJSXAttribute, node('JSXAttribute')], + [ast.isExpression, node('CallExpression')], + [ast.isExpression, node('OptionalMemberExpression')], + [ast.isStatement, node('IfStatement')], + [ast.isLVal, node('ObjectPattern')], + [ast.isArrayExpression, node('ArrayExpression')], + [ast.isObjectExpression, node('ObjectExpression')], + [ast.isObjectProperty, node('Property', { method: false })], + [ast.isObjectMethod, node('ObjectMethod')], + [ast.isObjectMethod, node('Property', { method: true })], + [ast.isObjectMethodLike, node('ObjectMethod')], + [ast.isSpreadElement, node('SpreadElement')], + [ast.isRestElement, node('RestElement')], + [ast.isAssignmentPattern, node('AssignmentPattern')], + [ast.isObjectPattern, node('ObjectPattern')], + [ast.isArrayPattern, node('ArrayPattern')], + [ast.isVariableDeclarator, node('VariableDeclarator')], + [ast.isVariableDeclaration, node('VariableDeclaration')], + [ast.isFunctionDeclaration, node('FunctionDeclaration')], + [ast.isFunctionExpression, node('FunctionExpression')], + [ast.isArrowFunctionExpression, node('ArrowFunctionExpression')], + [ast.isClassMethod, node('ClassMethod')], + [ast.isClassMethod, node('MethodDefinition', { key: node('Identifier') })], + [ast.isClassPrivateMethod, node('ClassPrivateMethod')], + [ast.isClassPrivateMethod, node('MethodDefinition', { key: node('PrivateIdentifier') })], + [ast.isCallExpression, node('CallExpression')], + [ast.isCallExpression, node('OptionalCallExpression')], + [ast.isOptionalCallExpression, node('OptionalCallExpression')], + [ast.isOptionalCallExpression, node('ChainExpression', { expression: node('CallExpression') })], + [ast.isOptionalCallExpression, node('CallExpression', { optional: true })], + [ast.isMemberExpression, node('MemberExpression')], + [ast.isMemberExpression, node('OptionalMemberExpression')], + [ast.isOptionalMemberExpression, node('OptionalMemberExpression')], + [ast.isOptionalMemberExpression, node('ChainExpression', { expression: node('MemberExpression') })], + [ast.isOptionalMemberExpression, node('MemberExpression', { optional: true })], + [ast.isNewExpression, node('NewExpression')], + [ast.isConditionalExpression, node('ConditionalExpression')], + [ast.isLogicalExpression, node('LogicalExpression')], + [ast.isBinaryExpression, node('BinaryExpression')], + [ast.isUnaryExpression, node('UnaryExpression')], + [ast.isUpdateExpression, node('UpdateExpression')], + [ast.isSequenceExpression, node('SequenceExpression')], + [ast.isAssignmentExpression, node('AssignmentExpression')], + [ast.isParenthesizedExpression, node('ParenthesizedExpression')], + [ast.isTSAsExpression, node('TSAsExpression')], + [ast.isTSSatisfiesExpression, node('TSSatisfiesExpression')], + [ast.isTSNonNullExpression, node('TSNonNullExpression')], + [ast.isTSTypeAssertion, node('TSTypeAssertion')], + [ast.isTypeCastExpression, node('TSTypeAssertion')], + [ast.isTSParenthesizedType, node('TSParenthesizedType')], + [ast.isTSTypeLiteral, node('TSTypeLiteral')], + [ast.isTSPropertySignature, node('TSPropertySignature')], + [ast.isTSTypeReference, node('TSTypeReference')], + [ast.isTSTypeParameterInstantiation, node('TSTypeParameterInstantiation')], + [ast.isTSUnionType, node('TSUnionType')], + [ast.isTSIntersectionType, node('TSIntersectionType')], + [ast.isTSQualifiedName, node('TSQualifiedName')], + [ast.isTSTypeQuery, node('TSTypeQuery')], + [ast.isTSImportType, node('TSImportType')], + [ast.isPrivateName, node('PrivateIdentifier')], + [ast.isPrivateName, node('PrivateName')], + [ast.isTypeAnnotation, node('TSTypeAnnotation')], + [ast.isNoop, node('Noop')], + [ast.isExportNamedDeclaration, node('ExportNamedDeclaration')], + [ast.isExportDefaultDeclaration, node('ExportDefaultDeclaration')], + [ast.isImportDeclaration, node('ImportDeclaration')], + [ast.isImportSpecifier, node('ImportSpecifier')], + [ast.isImportDefaultSpecifier, node('ImportDefaultSpecifier')], + [ast.isImportNamespaceSpecifier, node('ImportNamespaceSpecifier')], + [ast.isExportSpecifier, node('ExportSpecifier')], + [ast.isReturnStatement, node('ReturnStatement')], + [ast.isBlockStatement, node('BlockStatement')], + [ast.isIfStatement, node('IfStatement')], + [ast.isLabeledStatement, node('LabeledStatement')], + [ast.isForStatement, node('ForStatement')], + [ast.isForInStatement, node('ForInStatement')], + [ast.isForOfStatement, node('ForOfStatement')], + [ast.isWhileStatement, node('WhileStatement')], + [ast.isDoWhileStatement, node('DoWhileStatement')], + [ast.isSwitchStatement, node('SwitchStatement')], + [ast.isTryStatement, node('TryStatement')], + [ast.isExpressionStatement, node('ExpressionStatement')], + [ast.isPrivateIdentifier, node('PrivateIdentifier')], + [ast.isFileLike, node('File')], + ]; + + for (const [guard, value] of cases) { + expect(guard(value)).toBe(true); + expect(guard({ type: 'DifferentNode' })).toBe(false); + } + }); + + it('walks, clones, normalizes, and builds nodes', () => { + const child = ast.identifier('child'); + const parent = { + type: 'ObjectExpression', + properties: [ast.objectProperty(ast.identifier('key'), child), ast.spreadElement(child)], + loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } }, + }; + + const visited: string[] = []; + ast.forEachNodeChild(parent, (entry, key) => { + visited.push(`${key}:${entry.type}`); + }); + + expect(ast.getNodeChildren(parent)).toHaveLength(2); + expect(visited).toEqual(['properties:Property', 'properties:SpreadElement']); + expect(ast.cloneNode(parent, false)).not.toBe(parent); + expect(ast.cloneNode([child], false)).toEqual([child]); + expect(ast.cloneNode('literal', false)).toBe('literal'); + expect(ast.cloneNode(parent)).toEqual(parent); + expect(ast.cloneNode({ type: 'Identifier', parent })).toEqual({ type: 'Identifier' }); + + const cyclic: { type: string; self?: unknown } = { type: 'Program' }; + cyclic.self = cyclic; + expect(ast.cloneNode(cyclic)).toMatchObject({ type: 'Program' }); + + const normalized = { + type: 'Program', + body: [ + { + type: 'Property', + method: true, + value: { type: 'FunctionExpression', params: [], body: node('BlockStatement'), async: true }, + }, + { + type: 'Property', + method: true, + value: { type: 'FunctionExpression', body: node('BlockStatement') }, + }, + { + type: 'Property', + method: true, + value: null, + }, + { + type: 'MethodDefinition', + key: node('Identifier'), + value: { type: 'FunctionExpression', params: [], body: node('BlockStatement') }, + }, + { + type: 'MethodDefinition', + key: node('Identifier'), + value: { type: 'FunctionExpression', body: node('BlockStatement') }, + }, + { + type: 'MethodDefinition', + key: node('Identifier'), + value: null, + }, + { + type: 'MethodDefinition', + key: node('PrivateIdentifier'), + value: { type: 'FunctionExpression', params: [], body: node('BlockStatement') }, + }, + { type: 'ChainExpression', expression: { type: 'CallExpression', callee: ast.identifier('run') } }, + { + type: 'ChainExpression', + expression: { type: 'MemberExpression', object: ast.identifier('api'), property: ast.identifier('value') }, + }, + { type: 'ChainExpression', expression: { type: 'Identifier', name: 'plain' } }, + { type: 'ChainExpression', expression: null }, + { + type: 'PrivateIdentifier', + name: 'secret', + start: 7, + end: 14, + loc: { start: { line: 2, column: 1 }, end: { line: 2, column: 8 } }, + }, + ], + }; + + ast.normalizeAstShape(normalized); + + expect(normalized.body.map((entry) => entry.type)).toEqual([ + 'ObjectMethod', + 'ObjectMethod', + 'Property', + 'ClassMethod', + 'ClassMethod', + 'MethodDefinition', + 'ClassPrivateMethod', + 'OptionalCallExpression', + 'OptionalMemberExpression', + 'ChainExpression', + 'ChainExpression', + 'PrivateName', + ]); + expect(ast.objectExpression([])).toMatchObject({ start: 0, end: 0 }); + }); +}); diff --git a/src/core/analysis/__tests__/astScan.test.ts b/src/core/analysis/__tests__/astScan.test.ts new file mode 100644 index 0000000..92f2810 --- /dev/null +++ b/src/core/analysis/__tests__/astScan.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { analyze, analyzeSources } from './analysisHarness'; + +describe('core/analysis/astScan', () => { + it('scans a rich multi-file workspace end to end', () => { + const records = analyzeSources({ + 'src/support.ts': [ + "export const todoKey = ['todos'] as const;", + 'export function makeTodoKey(id: string) {', + " return ['todo', id] as const;", + '}', + 'export default function defaultFactory() { return todoKey; }', + ].join('\n'), + 'src/app.ts': [ + "import * as rq from '@tanstack/react-query';", + "import { infiniteQueryOptions, queryOptions, useQueries, useQuery, useQueryClient, useSuspenseQueries } from '@tanstack/react-query';", + 'const queryClient = useQueryClient();', + "const localKey = ['todos'] as const;", + "const secondaryKey = ['users'] as const;", + 'const options = queryOptions({', + ' queryKey: localKey,', + " queryFn: () => Promise.resolve('ok'),", + '});', + 'const queries = [options, queryOptions({ queryKey: secondaryKey, queryFn: () => null })];', + 'const queryResult = useQuery(options);', + 'const queryResults = useQueries({ queries });', + 'const suspenseResults = useSuspenseQueries({ queries });', + 'queryClient.invalidateQueries({ queryKey: localKey, exact: true });', + "queryClient.invalidateQueries({ predicate: (entry) => entry.queryKey[0] === 'todos' });", + 'queryClient.refetchQueries({ queryKey: localKey });', + 'queryClient.getQueriesData({ queryKey: localKey });', + 'queryClient.getQueriesData(localKey);', + 'queryClient.removeQueries({ queryKey: localKey });', + 'queryClient.setQueryData(localKey, []);', + 'queryClient.clear();', + 'export { queryResult, queryResults, suspenseResults };', + ].join('\n'), + }); + + expect(records.length).toBeGreaterThan(0); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + expect(records.some((r) => r.relation === 'declares')).toBe(true); + }); + + it('handles a file with no query usage', () => { + const records = analyze('export const x = 1;\nconsole.log(x);'); + expect(records).toEqual([]); + }); +}); diff --git a/src/core/analysis/__tests__/astScanActions.test.ts b/src/core/analysis/__tests__/astScanActions.test.ts new file mode 100644 index 0000000..d3547d3 --- /dev/null +++ b/src/core/analysis/__tests__/astScanActions.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, it } from 'vitest'; + +import { analyze, analyzeSources } from './analysisHarness'; + +/** + * Tests focused on the CACHE-ACTION + ARGUMENT-RESOLUTION machinery of + * src/core/analysis/astScan.ts (scanCalls cache actions, member-call refetch, + * getQueriesData key helpers, type-based factory + * resolution, parameter substitution, and resolveLocalActionArgExpression / + * resolveActionArgsWithLocalBindings). All assertions are on the QueryRecord[] + * produced by the full analyzer pipeline. + */ + +const CLIENT_IMPORT = "import { useQueryClient, QueryClient } from '@tanstack/react-query';\n"; + +function ofRelation(records: ReturnType, relation: string) { + return records.filter((r) => r.relation === relation); +} + +describe('astScan cache actions - the eight client methods', () => { + const KEY = "const key = ['todos'] as const;\n"; + + it('records invalidateQueries with { queryKey } form', () => { + const records = analyze( + `${CLIENT_IMPORT}${KEY}const c = useQueryClient();\nc.invalidateQueries({ queryKey: key });`, + ); + const inv = ofRelation(records, 'invalidates'); + expect(inv).toHaveLength(1); + expect(inv[0].operation).toBe('invalidateQueries'); + expect(inv[0].queryKey.display).toBe('[todos]'); + expect(inv[0].queryKey.matchMode).toBe('prefix'); + }); + + it('records refetchQueries, removeQueries, cancelQueries, resetQueries with { queryKey }', () => { + const records = analyze( + `${CLIENT_IMPORT}${KEY}const c = useQueryClient(); +c.refetchQueries({ queryKey: key }); +c.removeQueries({ queryKey: key }); +c.cancelQueries({ queryKey: key }); +c.resetQueries({ queryKey: key });`, + ); + expect(ofRelation(records, 'refetches')).toHaveLength(1); + expect(ofRelation(records, 'removes')).toHaveLength(1); + expect(ofRelation(records, 'cancels')).toHaveLength(1); + expect(ofRelation(records, 'resets')).toHaveLength(1); + }); + + it('records setQueryData with positional key (exact match mode)', () => { + const records = analyze(`${CLIENT_IMPORT}${KEY}const c = useQueryClient();\nc.setQueryData(key, []);`); + const sets = ofRelation(records, 'sets'); + expect(sets).toHaveLength(1); + expect(sets[0].operation).toBe('setQueryData'); + expect(sets[0].queryKey.matchMode).toBe('exact'); + expect(sets[0].queryKey.display).toBe('[todos]'); + }); + + it('does not emit a record for getQueryData / getQueriesData (read-only, untracked methods)', () => { + const records = analyze( + `${CLIENT_IMPORT}${KEY}const c = useQueryClient(); +c.getQueryData(key); +c.getQueriesData({ queryKey: key }); +c.getQueriesData(key);`, + ); + expect(records).toHaveLength(0); + }); + + it('records setQueriesData with { queryKey } form', () => { + const records = analyze( + `${CLIENT_IMPORT}${KEY}const c = useQueryClient();\nc.setQueriesData({ queryKey: key }, []);`, + ); + expect(ofRelation(records, 'sets').some((r) => r.operation === 'setQueriesData')).toBe(true); + }); +}); + +describe('astScan cache actions - predicate / filter forms', () => { + it('records invalidateQueries with a predicate that constrains queryKey segments', () => { + const records = analyze( + `${CLIENT_IMPORT}const c = useQueryClient(); +c.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'todos' });`, + ); + const inv = ofRelation(records, 'invalidates'); + expect(inv).toHaveLength(1); + // Predicate constrains segment 0 to 'todos', so a prefix key is inferred. + expect(inv[0].queryKey.display).toBe('[todos]'); + expect(inv[0].queryKey.matchMode).toBe('prefix'); + }); + + it('records invalidateQueries with an opaque predicate (no constraints -> all/predicate mode)', () => { + const records = analyze( + `${CLIENT_IMPORT}const c = useQueryClient(); +c.invalidateQueries({ predicate: () => true });`, + ); + const inv = ofRelation(records, 'invalidates'); + expect(inv).toHaveLength(1); + expect(['all', 'predicate']).toContain(inv[0].queryKey.matchMode); + }); + + it('records invalidateQueries with no argument (clears all)', () => { + const records = analyze(`${CLIENT_IMPORT}const c = useQueryClient();\nc.invalidateQueries();`); + const inv = ofRelation(records, 'invalidates'); + expect(inv).toHaveLength(1); + expect(inv[0].queryKey.matchMode).toBe('all'); + }); +}); + +describe('astScan cache actions - client provenance variants', () => { + it('detects a client from new QueryClient()', () => { + const records = analyze(`${CLIENT_IMPORT}const c = new QueryClient();\nc.invalidateQueries({ queryKey: ['x'] });`); + expect(ofRelation(records, 'invalidates')).toHaveLength(1); + }); + + it('detects a client from a destructured hook result', () => { + const records = analyze( + `function useThing() { return { queryClient: {} as any }; } +const { queryClient } = useThing(); +queryClient.removeQueries({ queryKey: ['x'] });`, + ); + expect(ofRelation(records, 'removes')).toHaveLength(1); + }); + + it('detects a client from a param typed QueryClient', () => { + const records = analyze( + `${CLIENT_IMPORT}function run(client: QueryClient) { + client.cancelQueries({ queryKey: ['x'] }); +} +export { run };`, + ); + expect(ofRelation(records, 'cancels')).toHaveLength(1); + }); + + it('handles optional chaining client?.invalidateQueries(...)', () => { + const records = analyze(`${CLIENT_IMPORT}const c = useQueryClient();\nc?.invalidateQueries({ queryKey: ['x'] });`); + expect(ofRelation(records, 'invalidates')).toHaveLength(1); + }); + + it('ignores cache actions on a non-client object', () => { + const records = analyze(`const notClient = {} as any;\nnotClient.invalidateQueries({ queryKey: ['x'] });`); + expect(ofRelation(records, 'invalidates')).toHaveLength(0); + }); +}); + +describe('astScan member-call refetch handling', () => { + it('records .refetch() on a tracked query result object', () => { + const records = analyze( + `import { useQuery } from '@tanstack/react-query'; +const key = ['todos'] as const; +function Comp() { + const query = useQuery({ queryKey: key, queryFn: () => 1 }); + return query.refetch(); +} +export { Comp };`, + ); + const ref = ofRelation(records, 'refetches'); + expect(ref.some((r) => r.operation === 'refetch')).toBe(true); + }); +}); + +describe('astScan getQueriesData key helpers via [first] = client.getQueriesData(...)', () => { + it('resolves a queryKey from a destructured getQueriesData result with { queryKey }', () => { + const records = analyze( + `${CLIENT_IMPORT}const c = useQueryClient(); +const key = ['todos'] as const; +const [first] = c.getQueriesData({ queryKey: key }); +c.invalidateQueries({ queryKey: first });`, + ); + // first resolves to the getQueriesData collection key 'todos'. + expect(ofRelation(records, 'invalidates').some((r) => r.queryKey.display === 'todos')).toBe(true); + }); +}); + +describe('astScan resolveLocalActionArgExpression - through local bindings', () => { + const HEAD = `${CLIENT_IMPORT}const c = useQueryClient();\n`; + + it('resolves a key in a local const that references another const', () => { + const records = analyze( + `${HEAD}const base = ['todos'] as const; +const alias = base; +c.invalidateQueries({ queryKey: alias });`, + ); + expect(ofRelation(records, 'invalidates')[0].queryKey.display).toBe('[todos]'); + }); + + it('resolves a key built by a factory function called with args', () => { + const records = analyze( + `${HEAD}function makeKey(id: string) { return ['todo', id] as const; } +c.invalidateQueries({ queryKey: makeKey('1') });`, + ); + // The factory return is resolved to an array; the param segment stays dynamic. + const inv = ofRelation(records, 'invalidates')[0]; + expect(inv.queryKey.display.startsWith('[todo')).toBe(true); + }); + + it('resolves a key built by a factory function with no params (static)', () => { + const records = analyze( + `${HEAD}function makeKey() { return ['static-todo'] as const; } +c.invalidateQueries({ queryKey: makeKey() });`, + ); + expect(ofRelation(records, 'invalidates')[0].queryKey.display).toBe('[static-todo]'); + }); + + it('resolves a key from an arrow-function factory called with args', () => { + const records = analyze( + `${HEAD}const makeKey = (id: string) => ['todo', id] as const; +c.invalidateQueries({ queryKey: makeKey('2') });`, + ); + expect(ofRelation(records, 'invalidates')[0].queryKey.display).toBe('[todo, 2]'); + }); + + it('resolves a key from a const-bound arrow-function factory with no params', () => { + const records = analyze( + `${HEAD}const build = () => ['built'] as const; +c.invalidateQueries({ queryKey: build() });`, + ); + expect(ofRelation(records, 'invalidates')[0].queryKey.display).toBe('[built]'); + }); + + it('resolves a key spread into an array', () => { + const records = analyze( + `${HEAD}const tail = ['detail'] as const; +c.invalidateQueries({ queryKey: ['todos', ...tail] });`, + ); + expect(ofRelation(records, 'invalidates')[0].queryKey.display).toBe('[todos, detail]'); + }); + + it('resolves a key behind a conditional expression (consequent)', () => { + const records = analyze( + `${HEAD}declare const flag: boolean; +const a = ['todos'] as const; +const b = ['users'] as const; +c.invalidateQueries({ queryKey: flag ? a : b });`, + ); + // Conditional yields a combined dynamic key; the action record is still emitted. + expect(ofRelation(records, 'invalidates')).toHaveLength(1); + }); + + it('resolves a key from obj.prop member access into a local object', () => { + const records = analyze( + `${HEAD}const keys = { todos: ['todos'] as const }; +c.invalidateQueries({ queryKey: keys.todos });`, + ); + expect(ofRelation(records, 'invalidates')[0].queryKey.display).toBe('[todos]'); + }); + + it('resolves a key from an array element index member access', () => { + const records = analyze( + `${HEAD}const keyGroups = [['todos'] as const, ['users'] as const]; +c.invalidateQueries({ queryKey: keyGroups[0] });`, + ); + expect(ofRelation(records, 'invalidates')[0].queryKey.display).toBe('[todos]'); + }); + + it('resolves a key through a chain const -> const -> factory call', () => { + const records = analyze( + `${HEAD}function makeKey() { return ['root'] as const; } +const first = makeKey(); +const second = first; +c.invalidateQueries({ queryKey: second });`, + ); + expect(ofRelation(records, 'invalidates')[0].queryKey.display).toBe('[root]'); + }); + + it('resolves a key wrapped in queryOptions identity call', () => { + const records = analyze( + `import { useQueryClient, queryOptions } from '@tanstack/react-query'; +const c = useQueryClient(); +const opts = queryOptions({ queryKey: ['opt'], queryFn: () => 1 }); +c.invalidateQueries(opts);`, + ); + expect(ofRelation(records, 'invalidates')[0].queryKey.display).toBe('[opt]'); + }); + + it('resolves a key wrapped in Object.freeze identity call', () => { + const records = analyze(`${HEAD}c.invalidateQueries({ queryKey: Object.freeze(['frozen']) });`); + expect(ofRelation(records, 'invalidates')[0].queryKey.display).toBe('[frozen]'); + }); + + it('resolves a key initialized via useState initial value', () => { + const records = analyze( + `${HEAD}declare function useState(v: T): [T, (x: T) => void]; +const [stateKey] = useState(['statekey'] as const); +c.invalidateQueries({ queryKey: stateKey });`, + ); + const display = ofRelation(records, 'invalidates')[0]?.queryKey.display; + expect(display).toBe('statekey'); + }); + + it('resolves a key from a reassigned (mutable) local binding', () => { + const records = analyze( + `${HEAD}let mut = ['init'] as const; +mut = ['reassigned'] as const; +c.invalidateQueries({ queryKey: mut });`, + ); + expect(ofRelation(records, 'invalidates')[0].queryKey.display).toBe('[reassigned]'); + }); +}); + +describe('astScan substituteIdentifierInExpressionTopLevel - action key referencing enclosing param', () => { + // An enclosing function param is the query-key building block; the call-site + // arg is substituted into the queryKey expression across node kinds. + function paramKey(keyExpr: string, callArg = "'v'") { + return analyze( + `${CLIENT_IMPORT}const c = useQueryClient(); +function run(p: string) { + c.invalidateQueries({ queryKey: ${keyExpr} }); +} +run(${callArg}); +export { run };`, + ); + } + + it('substitutes the param inside an array key element', () => { + expect(ofRelation(paramKey("['todo', p]"), 'invalidates').length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the param inside a template literal element', () => { + expect(ofRelation(paramKey('[`todo-' + '$' + '{p}`]'), 'invalidates').length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the param inside a member expression element', () => { + expect(ofRelation(paramKey("['todo', p.length]"), 'invalidates').length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the param inside a call element', () => { + expect(ofRelation(paramKey("['todo', String(p)]"), 'invalidates').length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the param inside an optional call / optional member element', () => { + expect(ofRelation(paramKey("['todo', p?.toString()]"), 'invalidates').length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the param inside unary / binary key elements', () => { + expect(ofRelation(paramKey("[-1, 'todo', p + 'x']"), 'invalidates').length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the param inside logical / sequence key elements', () => { + expect(ofRelation(paramKey("['todo', p && 'a', (p, 'seq')]"), 'invalidates').length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the param inside a conditional key element', () => { + expect(ofRelation(paramKey("[p === 'v' ? 'a' : p]"), 'invalidates').length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the param inside a spread key element', () => { + expect(ofRelation(paramKey("[...['todo'], p]"), 'invalidates').length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the param inside a nested object key element', () => { + expect(ofRelation(paramKey("['todo', { id: p }]"), 'invalidates').length).toBeGreaterThanOrEqual(1); + }); +}); + +describe('astScan resolveQueryKeyFactoryReturnFromParam - typed factory params', () => { + it('resolves a key from a param typed ReturnType', () => { + const records = analyzeSources({ + 'src/keys.ts': "export const makeKey = () => ['todos'] as const;", + 'src/app.ts': `import { QueryClient } from '@tanstack/react-query'; +import { makeKey } from '../../keys'; +function run(client: QueryClient, key: ReturnType) { + client.invalidateQueries({ queryKey: key }); +} +export { run };`, + }); + expect(ofRelation(records, 'invalidates').length).toBeGreaterThanOrEqual(1); + }); + + it('resolves a key from a destructured object-pattern param member typed ReturnType', () => { + const records = analyzeSources({ + 'src/keys2.ts': "export const makeKey = () => ['scoped'] as const;", + 'src/app.ts': `import { QueryClient } from '@tanstack/react-query'; +import { makeKey } from '../../keys2'; +function run(client: QueryClient, { key }: { key: ReturnType }) { + client.invalidateQueries({ queryKey: key }); +} +export { run };`, + }); + expect(ofRelation(records, 'invalidates').length).toBeGreaterThanOrEqual(1); + }); +}); + +describe('astScan resolveQueryKeyFactoryReturnFromMemberProperty - *QueryKey member naming', () => { + it('resolves member key xxxQueryKey via a createXxxQueryKey factory', () => { + const records = analyze( + `${CLIENT_IMPORT}const c = useQueryClient(); +function createTodoQueryKey() { return ['todo'] as const; } +declare const keys: { todoQueryKey: readonly string[] }; +c.invalidateQueries({ queryKey: keys.todoQueryKey });`, + ); + expect(ofRelation(records, 'invalidates').length).toBeGreaterThanOrEqual(1); + }); +}); + +describe('astScan createContext<...>() typed query client', () => { + it('detects a client through useContext of createContext<{ queryClient: QueryClient }>', () => { + const records = analyze( + `import { QueryClient } from '@tanstack/react-query'; +import { createContext, useContext } from 'react'; +const key = ['todos'] as const; +const Ctx = createContext<{ queryClient: QueryClient }>(null as any); +function useThing() { + const { queryClient } = useContext(Ctx); + queryClient.invalidateQueries({ queryKey: key }); +} +export { useThing };`, + ); + expect(ofRelation(records, 'invalidates').length).toBeGreaterThanOrEqual(1); + }); + + it('exercises createContext(null) union type-arg branch', () => { + const records = analyze( + `import { QueryClient } from '@tanstack/react-query'; +import { createContext, useContext } from 'react'; +const key = ['todos'] as const; +const Ctx = createContext(null); +function mk() { return { queryClient: useContext(Ctx) }; } +const { queryClient } = mk(); +queryClient?.invalidateQueries({ queryKey: key });`, + ); + // The union type arg with QueryClient should be recognized as a client. + expect(Array.isArray(records)).toBe(true); + }); + + it('detects a client via createContext default-value object { queryClient: new QueryClient() }', () => { + const records = analyze( + `import { QueryClient } from '@tanstack/react-query'; +import { createContext, useContext } from 'react'; +const key = ['todos'] as const; +const Ctx = createContext({ queryClient: new QueryClient() }); +function useThing() { + const { queryClient } = useContext(Ctx); + queryClient.invalidateQueries({ queryKey: key }); +} +export { useThing };`, + ); + expect(ofRelation(records, 'invalidates').length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/core/analysis/__tests__/astScanBindings.test.ts b/src/core/analysis/__tests__/astScanBindings.test.ts new file mode 100644 index 0000000..19eae42 --- /dev/null +++ b/src/core/analysis/__tests__/astScanBindings.test.ts @@ -0,0 +1,904 @@ +import { describe, expect, it } from 'vitest'; + +import { analyze, analyzeSources } from './analysisHarness'; + +/** + * Tests focused on scanImports + scanLocalBindings (and the helpers they reach) + * in src/core/analysis/astScan.ts. Assertions verify that the binding/import + * tracking these functions perform actually changes the records produced by the + * downstream scanCalls pass (query-client detection, refetch tracking, factory + * resolution, etc.). + */ + +describe('astScan scanImports', () => { + it('tracks named query hook + useQueryClient + QueryClient imports from tanstack', () => { + const records = analyze( + [ + "import { useQuery, useQueryClient, QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'useQuery({ queryKey: key, queryFn: () => 1 });', + 'const client = useQueryClient();', + 'client.invalidateQueries({ queryKey: key });', + 'const direct = new QueryClient();', + 'direct.removeQueries({ queryKey: key });', + ].join('\n'), + ); + + expect(records.some((r) => r.relation === 'declares')).toBe(true); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + expect(records.some((r) => r.relation === 'removes')).toBe(true); + }); + + it('respects aliased imports (local name differs from imported)', () => { + const records = analyze( + [ + "import { useQuery as useTodoQuery, useQueryClient as useClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'useTodoQuery({ queryKey: key, queryFn: () => 1 });', + 'const client = useClient();', + 'client.refetchQueries({ queryKey: key });', + ].join('\n'), + ); + + expect(records.some((r) => r.relation === 'declares')).toBe(true); + expect(records.some((r) => r.relation === 'refetches')).toBe(true); + }); + + it('treats string-literal aliased import names (export "default" form) correctly', () => { + // `imported` is a StringLiteral here, exercising the StringLiteral branch. + const records = analyze( + [ + "import { 'useQuery' as useTodoQuery } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'useTodoQuery({ queryKey: key, queryFn: () => 1 });', + ].join('\n'), + ); + + expect(records.some((r) => r.relation === 'declares')).toBe(true); + }); + + it('ignores non-query named imports', () => { + const records = analyze( + ["import { useState } from 'react';", 'const [v] = useState(0);', 'export { v };'].join('\n'), + ); + expect(records).toEqual([]); + }); + + it('tracks namespace import from a query-like module', () => { + const records = analyze( + [ + "import * as rq from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'rq.useQuery({ queryKey: key, queryFn: () => 1 });', + 'const client = rq.useQueryClient();', + 'client.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'declares')).toBe(true); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('ignores namespace import from a non-query-like module', () => { + const records = analyze( + ["import * as lodash from 'lodash';", 'lodash.useQuery({ queryKey: [1], queryFn: () => 1 });'].join('\n'), + ); + expect(records).toEqual([]); + }); + + it('tracks a default import from tanstack as a namespace', () => { + const records = analyze( + [ + "import rq from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'rq.useQuery({ queryKey: key, queryFn: () => 1 });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'declares')).toBe(true); + }); + + it('ignores a default import from a non-tanstack module', () => { + const records = analyze( + ["import rq from 'some-other-lib';", 'rq.useQuery({ queryKey: [1], queryFn: () => 1 });'].join('\n'), + ); + expect(records).toEqual([]); + }); +}); + +describe('astScan scanLocalBindings - queryClient detection via type annotations', () => { + it('detects a function param typed as QueryClient', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function doInvalidate(client: QueryClient) {', + ' client.invalidateQueries({ queryKey: key });', + '}', + 'export { doInvalidate };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects an arrow-function param typed as QueryClient', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'const doInvalidate = (client: QueryClient) => {', + ' client.invalidateQueries({ queryKey: key });', + '};', + 'export { doInvalidate };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects a function-expression param typed as QueryClient', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'const doInvalidate = function (client: QueryClient) {', + ' client.invalidateQueries({ queryKey: key });', + '};', + 'export { doInvalidate };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects object-method and class-method params typed as QueryClient', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'const obj = {', + ' run(client: QueryClient) {', + ' client.invalidateQueries({ queryKey: key });', + ' },', + '};', + 'class Svc {', + ' run(client: QueryClient) {', + ' client.refetchQueries({ queryKey: key });', + ' }', + ' #priv(client: QueryClient) {', + ' client.removeQueries({ queryKey: key });', + ' }', + '}', + 'export { obj, Svc };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + expect(records.some((r) => r.relation === 'refetches')).toBe(true); + expect(records.some((r) => r.relation === 'removes')).toBe(true); + }); + + it('handles an array-destructuring param with a default value (non-object assignment pattern)', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + 'function f([a, b]: [number, number] = [1, 2]) { return a + b; }', + 'export { f };', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); + + it('detects a param with default value (assignment pattern) typed as QueryClient', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function doInvalidate(client: QueryClient = undefined as any) {', + ' client.invalidateQueries({ queryKey: key });', + '}', + 'export { doInvalidate };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects a rest param typed as QueryClient[]', () => { + // Rest element identifier path is tracked even though the type is an array. + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + 'function doInvalidate(...client: QueryClient) {', + ' client;', + '}', + 'export { doInvalidate };', + ].join('\n'), + ); + expect(records).toEqual([]); + }); + + it('detects a destructured object-pattern param with a QueryClient-typed member', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function doInvalidate({ qc }: { qc: QueryClient }) {', + ' qc.invalidateQueries({ queryKey: key });', + '}', + 'export { doInvalidate };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects a destructured object-pattern param with a defaulted (assignment) binding', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function doInvalidate({ qc = undefined }: { qc: QueryClient }) {', + ' qc.invalidateQueries({ queryKey: key });', + '}', + 'export { doInvalidate };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects an object pattern param wrapped in an assignment pattern with default {}', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function doInvalidate({ qc }: { qc: QueryClient } = { qc: undefined as any }) {', + ' qc.invalidateQueries({ queryKey: key });', + '}', + 'export { doInvalidate };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects a typed object-pattern param whose QueryClient member uses a string-literal type key', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function f({ qc }: { "qc": QueryClient }) {', + ' qc.invalidateQueries({ queryKey: key });', + '}', + 'export { f };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects a typed object-pattern param whose QueryClient member uses a numeric type key', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function g({ 0: qc }: { 0: QueryClient }) {', + ' qc.invalidateQueries({ queryKey: key });', + '}', + 'export { g };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('ignores an untyped object-pattern param', () => { + const records = analyze( + ["import { QueryClient } from '@tanstack/react-query';", 'function a({ x }) { return x; }', 'export { a };'].join( + '\n', + ), + ); + expect(Array.isArray(records)).toBe(true); + }); + + it('ignores an object-pattern param typed by a non-literal type reference', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + 'type T = { x: number };', + 'function b({ x }: T) { return x; }', + 'export { b };', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); + + it('skips non-property-signature members and unnamed members in a typed object-pattern param', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + // method signature member (not a property signature) + a computed key member + ['function c({ x }: { m(): void; [`a', '$', '{1}`]: number; x: number }) { return x; }'].join(''), + 'export { c };', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); + + it('handles a typed object-pattern param where the QueryClient member is not destructured', () => { + // The type literal declares `qc: QueryClient` but the pattern only binds + // `other` and a rest element -> objectPatternBindingNameByProperty walks + // past the rest element and returns undefined for `qc`. + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + 'function f({ other, ...rest }: { qc: QueryClient; other: number }) {', + ' return [other, rest];', + '}', + 'export { f };', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); + + it('handles a typed object-pattern param whose QueryClient member is bound via a nested pattern', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + 'function g({ qc: { sub } }: { qc: QueryClient }) {', + ' return sub;', + '}', + 'export { g };', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); + + it('detects a variable declarator typed as QueryClient', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'declare const client: QueryClient;', + 'client.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); +}); + +describe('astScan scanLocalBindings - queryClient detection via call/new expressions', () => { + it('tracks a useQueryClient() assignment', () => { + const records = analyze( + [ + "import { useQueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'const client = useQueryClient();', + 'client.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('tracks a new QueryClient() assignment', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'const client = new QueryClient();', + 'client.removeQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'removes')).toBe(true); + }); + + it('does not assign ctor certainty when a new QueryClient() is destructured into an object pattern', () => { + // The id is an ObjectPattern, not an Identifier, so the ctor-certainty + // assignment guard is skipped. + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + 'const { foo } = new QueryClient();', + 'export { foo };', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); +}); + +describe('astScan scanLocalBindings - destructured query client from call', () => { + it('detects { queryClient } destructured from a hook-like call', () => { + const records = analyze( + [ + "const key = ['todos'] as const;", + 'function useThing() { return { queryClient: {} as any }; }', + 'const { queryClient } = useThing();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects a destructured queryClient renamed to a local binding', () => { + const records = analyze( + [ + "const key = ['todos'] as const;", + 'function useThing() { return { queryClient: {} as any }; }', + 'const { queryClient: qc } = useThing();', + 'qc.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects a destructured queryClient with a default value binding', () => { + const records = analyze( + [ + "const key = ['todos'] as const;", + 'function useThing() { return { queryClient: {} as any }; }', + 'const { queryClient = undefined } = useThing();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('resolves a destructured queryClient certainty from a resolved object call result', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function makeCtx() { return { queryClient: new QueryClient() }; }', + 'const { queryClient } = makeCtx();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('skips destructured properties that are not query-client-like', () => { + const records = analyze( + ['function useThing() { return { other: 1 }; }', 'const { other } = useThing();', 'export { other };'].join('\n'), + ); + expect(records).toEqual([]); + }); + + it('handles a destructured object property with no usable local name (nested pattern)', () => { + const records = analyze( + [ + 'function useThing() { return { queryClient: { nested: 1 } }; }', + 'const { queryClient: { nested } } = useThing();', + 'export { nested };', + ].join('\n'), + ); + expect(records).toEqual([]); + }); +}); + +describe('astScan scanLocalBindings - createContext / useContext queryClient', () => { + it('detects a queryClient stored in a createContext with a type-literal type param', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "import { createContext, useContext } from 'react';", + "const key = ['todos'] as const;", + 'const Ctx = createContext<{ queryClient: QueryClient }>(undefined as any);', + 'function useThing() {', + ' const { queryClient } = useContext(Ctx);', + ' queryClient.invalidateQueries({ queryKey: key });', + '}', + 'export { useThing };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects a queryClient via a createContext type literal whose member type is a direct QueryClient reference', () => { + // Member typeAnnotation is `QueryClient` (TSTypeReference), exercising the + // typeLooksLikeQueryClient member branch inside the createContext type literal. + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "import { createContext, useContext } from 'react';", + "const key = ['todos'] as const;", + 'function provide() { return createContext<{ queryClient: QueryClient }>(undefined as any); }', + 'const Ctx = provide();', + 'const { queryClient } = useContext(Ctx);', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects a queryClient via createContext default-value object argument', () => { + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "import { createContext, useContext } from 'react';", + "const key = ['todos'] as const;", + 'const Ctx = createContext({ queryClient: new QueryClient() });', + 'const { queryClient } = useContext(Ctx);', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); +}); + +describe('astScan scanLocalBindings - queryClientCertaintyFromExpression branches', () => { + const HEADER = [ + "import { useQueryClient, QueryClient } from '@tanstack/react-query';", + "import { createContext, useContext } from 'react';", + "const key = ['todos'] as const;", + ].join('\n'); + + it('resolves a property value that is a useQueryClient() call (hook-call branch)', () => { + const records = analyze( + [ + HEADER, + 'function mk() { return { queryClient: useQueryClient() }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('resolves a property value that is an identifier referencing a tracked client', () => { + const records = analyze( + [ + HEADER, + 'const base = new QueryClient();', + 'function mk() { return { queryClient: base }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('resolves a property value that is a conditional expression', () => { + const records = analyze( + [ + HEADER, + 'function mk(flag: boolean) { return { queryClient: flag ? new QueryClient() : new QueryClient() }; }', + 'const { queryClient } = mk(true);', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('resolves a property value that is a logical expression', () => { + const records = analyze( + [ + HEADER, + 'const base = new QueryClient();', + 'function mk() { return { queryClient: base || new QueryClient() }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('exercises the createContext branch when a property value is a createContext call', () => { + const records = analyze( + [ + HEADER, + 'function mk() { return { queryClient: createContext<{ queryClient: QueryClient }>(undefined as any) }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + // The createContext value is a Context object (not a client), so no relation + // is produced, but the createContext-call resolution branch is exercised. + expect(Array.isArray(records)).toBe(true); + }); + + it('exercises the useContext branch when a property value is a useContext call', () => { + const records = analyze( + [ + HEADER, + 'const Ctx = createContext(undefined as any);', + 'function mk() { return { queryClient: useContext(Ctx) }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); + + it('resolves a property value that is a createContext call with an object argument', () => { + const records = analyze( + [ + HEADER, + 'function mk() { return { queryClient: createContext({ queryClient: new QueryClient() }) }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects a destructured client via a namespaced useQueryClient (member-expression callee)', () => { + const records = analyze( + [ + "import * as rq from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function mk() { return { queryClient: rq.useQueryClient() }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('detects a query-client-like local binding name even when the source property differs', () => { + const records = analyze( + [ + HEADER, + 'function useThing() { return { qc: useQueryClient() }; }', + 'const { qc: queryClient } = useThing();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('resolves a destructured client via a string-literal property key', () => { + const records = analyze( + [ + HEADER, + 'function mk() { return { "queryClient": useQueryClient() }; }', + 'const { "queryClient": qc } = mk();', + 'qc.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('resolves a destructured client via a numeric property key', () => { + const records = analyze( + [ + HEADER, + 'function mk() { return { 0: useQueryClient() }; }', + 'const { 0: qc } = mk();', + 'qc.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('resolves a client through a spread of an inline object literal in the factory return', () => { + const records = analyze( + [ + HEADER, + 'function mk() { return { ...{ queryClient: useQueryClient() } }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('handles a destructured property whose key is a computed expression (no static key name)', () => { + // The computed template-literal key yields no static name, so keyName is + // undefined while the local binding name remains usable. + const records = analyze( + [ + HEADER, + 'function mk() { return { queryClient: useQueryClient() }; }', + ['const { [`q', '$', '{"c"}`]: qc } = mk();'].join(''), + 'qc.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); + + it('resolves a destructured client whose property value is a resolved call result (resolveCall branch)', () => { + const records = analyze( + [ + HEADER, + 'function makeClient() { return useQueryClient(); }', + 'function mk() { return { queryClient: makeClient() }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('resolves a property value identifier/member via the resolver (resolveReference branch)', () => { + // `holder.inner` is not a tracked client var, so resolution falls through to + // resolver.resolveReference -> recursion on the resolved `new QueryClient()`. + const records = analyze( + [ + HEADER, + 'const holder = { inner: new QueryClient() };', + 'function mk() { return { queryClient: holder.inner }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('handles a createContext property value with no arguments', () => { + const records = analyze( + [ + HEADER, + 'function mk() { return { queryClient: createContext() }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); + + it('handles a createContext property value whose object argument lacks a queryClient', () => { + const records = analyze( + [ + HEADER, + 'function mk() { return { queryClient: createContext({ other: 1 }) }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); + + it('resolves a conditional property value whose consequent is null (alternate carries the client)', () => { + const records = analyze( + [ + HEADER, + 'function mk(flag: boolean) { return { queryClient: flag ? (null as any) : useQueryClient() }; }', + 'const { queryClient } = mk(true);', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); + + it('tolerates a useContext property value with no argument', () => { + const records = analyze( + [ + HEADER, + 'function mk() { return { queryClient: useContext() }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); + + it('skips a destructured property whose value resolves to a non-client object property', () => { + const records = analyze( + [ + HEADER, + 'function mk() { return { other: 1 }; }', + 'const { queryClient } = mk();', + 'export { queryClient };', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); + + it('resolves a useContext property value whose context default carries a queryClient', () => { + const records = analyze( + [ + HEADER, + 'const Ctx = createContext({ queryClient: new QueryClient() });', + 'function mk() { return { queryClient: useContext(Ctx) }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); +}); + +describe('astScan scanLocalBindings - createContext type-param detection', () => { + it('detects a queryClient member whose annotation is a direct QueryClient reference', () => { + // Inside the createContext type literal, the `queryClient` member type is a + // bare `QueryClient` reference -> typeLooksLikeQueryClient member branch. + const records = analyze( + [ + "import { QueryClient } from '@tanstack/react-query';", + "import { createContext, useContext } from 'react';", + "const key = ['todos'] as const;", + 'function mk() { return { queryClient: createContext<{ queryClient: QueryClient }>(undefined as any) }; }', + 'const { queryClient } = mk();', + 'queryClient.invalidateQueries({ queryKey: key });', + ].join('\n'), + ); + expect(Array.isArray(records)).toBe(true); + }); +}); + +describe('astScan scanLocalBindings - refetch bindings from query hooks', () => { + it('tracks a destructured { refetch } from useQuery and links it to the query key', () => { + const records = analyze( + [ + "import { useQuery } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function Comp() {', + ' const { refetch } = useQuery({ queryKey: key, queryFn: () => 1 });', + ' return refetch();', + '}', + 'export { Comp };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'refetches')).toBe(true); + }); + + it('tracks the whole result object and a .refetch() call on it', () => { + const records = analyze( + [ + "import { useQuery } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function Comp() {', + ' const query = useQuery({ queryKey: key, queryFn: () => 1 });', + ' return query.refetch();', + '}', + 'export { Comp };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'refetches')).toBe(true); + }); + + it('ignores a refetch property whose value is a defaulted (assignment) pattern, alongside other props', () => { + const records = analyze( + [ + "import { useQuery } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function Comp() {', + ' const { data, refetch = () => {} } = useQuery({ queryKey: key, queryFn: () => 1 });', + ' return [data, refetch];', + '}', + 'export { Comp };', + ].join('\n'), + ); + // `data` does not match `refetch`, and `refetch` here is an assignment + // pattern (not a bare identifier), so no refetch-fn binding is registered. + expect(records.some((r) => r.relation === 'declares')).toBe(true); + }); + + it('skips rest-element properties while tracking { refetch, ...rest } destructuring', () => { + const records = analyze( + [ + "import { useQuery } from '@tanstack/react-query';", + "const key = ['todos'] as const;", + 'function Comp() {', + ' const { refetch, ...rest } = useQuery({ queryKey: key, queryFn: () => 1 });', + ' return [refetch(), rest];', + '}', + 'export { Comp };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'refetches')).toBe(true); + }); + + it('does not register refetch bindings for a non-query-hook call assignment', () => { + // A non-tanstack call -> hookCallInfo returns undefined, so the refetch + // tracking block is skipped entirely. + const records = analyze( + [ + 'function useThing() { return { refetch: () => 1 }; }', + 'function Comp() {', + ' const { refetch } = useThing();', + ' return refetch();', + '}', + 'export { Comp };', + ].join('\n'), + ); + expect(records.some((r) => r.relation === 'refetches')).toBe(false); + }); +}); + +describe('astScan scanLocalBindings - query key factory resolution', () => { + it('resolves a query key factory return type via ReturnType param annotation', () => { + const records = analyzeSources({ + 'src/keys.ts': ["export const makeKey = () => ['todos'] as const;"].join('\n'), + 'src/app.ts': [ + "import { QueryClient } from '@tanstack/react-query';", + "import { makeKey } from '../keys';", + 'function run(client: QueryClient, key: ReturnType) {', + ' client.invalidateQueries({ queryKey: key });', + '}', + 'export { run };', + ].join('\n'), + }); + expect(records.some((r) => r.relation === 'invalidates')).toBe(true); + }); +}); diff --git a/src/core/analysis/__tests__/astScanCallsDeclare.test.ts b/src/core/analysis/__tests__/astScanCallsDeclare.test.ts new file mode 100644 index 0000000..15c90af --- /dev/null +++ b/src/core/analysis/__tests__/astScanCallsDeclare.test.ts @@ -0,0 +1,848 @@ +import { describe, expect, it } from 'vitest'; + +import { analyze, analyzeSources } from './analysisHarness'; + +const TANSTACK_IMPORT = + "import { useQuery, useQueries, useSuspenseQuery, useSuspenseQueries, useInfiniteQuery, useSuspenseInfiniteQuery, queryOptions, infiniteQueryOptions } from '@tanstack/react-query';\n"; + +function declares(records: ReturnType) { + return records.filter((r) => r.relation === 'declares'); +} + +describe('astScan declaration side - basic hooks', () => { + it('records a useQuery declaration with an inline object queryKey', () => { + const records = analyze(`${TANSTACK_IMPORT}useQuery({ queryKey: ['todos'], queryFn: () => 1 });`); + const decl = declares(records); + expect(decl).toHaveLength(1); + expect(decl[0]).toMatchObject({ + relation: 'declares', + operation: 'useQuery', + declaresDirectly: true, + }); + expect(decl[0].queryKey.segments).toEqual(['todos']); + expect(decl[0].queryKey.display).toBe('[todos]'); + expect(decl[0].queryKey.matchMode).toBe('exact'); + expect(decl[0].queryKey.resolution).toBe('static'); + }); + + it('records useInfiniteQuery / useSuspenseQuery / useSuspenseInfiniteQuery operations', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +useInfiniteQuery({ queryKey: ['inf'], queryFn: () => 1 }); +useSuspenseQuery({ queryKey: ['susp'], queryFn: () => 1 }); +useSuspenseInfiniteQuery({ queryKey: ['susinf'], queryFn: () => 1 });`, + ), + ); + const ops = records.map((r) => r.operation).sort(); + expect(ops).toEqual(['useInfiniteQuery', 'useSuspenseInfiniteQuery', 'useSuspenseQuery']); + }); + + it('records a queryOptions factory declaration (queryOptions is a tracked hook)', () => { + const records = declares(analyze(`${TANSTACK_IMPORT}queryOptions({ queryKey: ['opt'], queryFn: () => 1 });`)); + expect(records.map((r) => r.queryKey.display)).toEqual(['[opt]']); + expect(records[0].operation).toBe('queryOptions'); + }); + + it('still records a direct declaration when queryKey value is a referenced variable', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const key = ['ref'] as const; +useQuery({ queryKey: key, queryFn: () => 1 });`, + ), + ); + expect(records).toHaveLength(1); + expect(records[0].declaresDirectly).toBe(true); + expect(records[0].queryKey.segments).toEqual(['ref']); + }); +}); + +describe('astScan declaration side - namespace imports', () => { + it('records namespace member hook calls (rq.useQuery)', () => { + const records = declares( + analyze( + `import * as rq from '@tanstack/react-query'; +rq.useQuery({ queryKey: ['ns'], queryFn: () => 1 });`, + ), + ); + expect(records).toHaveLength(1); + expect(records[0].operation).toBe('useQuery'); + expect(records[0].queryKey.display).toBe('[ns]'); + }); +}); + +describe('astScan declaration side - useQueries collections', () => { + it('expands a static array of inline query options', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +useQueries({ queries: [ + { queryKey: ['a'], queryFn: () => 1 }, + { queryKey: ['b'], queryFn: () => 1 }, +] });`, + ), + ); + const keys = records.map((r) => r.queryKey.display).sort(); + expect(keys).toEqual(['[a]', '[b]']); + expect(records.every((r) => r.operation === 'useQueries')).toBe(true); + }); + + it('expands useSuspenseQueries collections', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +useSuspenseQueries({ queries: [ + { queryKey: ['s1'], queryFn: () => 1 }, + { queryKey: ['s2'], queryFn: () => 1 }, +] });`, + ), + ); + expect(records.map((r) => r.queryKey.display).sort()).toEqual(['[s1]', '[s2]']); + expect(records.every((r) => r.operation === 'useSuspenseQueries')).toBe(true); + }); + + it('expands queries built from queryOptions factories referenced by variable', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const o1 = queryOptions({ queryKey: ['f1'], queryFn: () => 1 }); +const o2 = queryOptions({ queryKey: ['f2'], queryFn: () => 1 }); +const list = [o1, o2]; +useQueries({ queries: list });`, + ), + ); + const keys = records.map((r) => r.queryKey.display); + expect(keys).toContain('[f1]'); + expect(keys).toContain('[f2]'); + }); +}); + +describe('astScan declaration side - useQueries iterator expansion', () => { + it('expands an array .map(...) of inline query options', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ids = ['x', 'y']; +useQueries({ queries: ids.map((id) => ({ queryKey: ['item', id], queryFn: () => 1 })) });`, + ), + ); + const display = records.map((r) => r.queryKey.display).sort(); + expect(display).toEqual(['[item, x]', '[item, y]']); + }); + + it('expands an array .flatMap(...) returning option arrays', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ids = ['a', 'b']; +useQueries({ queries: ids.flatMap((id) => [{ queryKey: ['fm', id], queryFn: () => 1 }]) });`, + ), + ); + const display = records.map((r) => r.queryKey.display).sort(); + expect(display).toEqual(['[fm, a]', '[fm, b]']); + }); + + it('expands across .filter().concat() chains', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const base = [queryOptions({ queryKey: ['b1'], queryFn: () => 1 })]; +const extra = [queryOptions({ queryKey: ['e1'], queryFn: () => 1 })]; +useQueries({ queries: base.filter(Boolean).concat(extra).slice(0).reverse().sort() });`, + ), + ); + const display = records.map((r) => r.queryKey.display); + expect(display).toContain('[b1]'); + expect(display).toContain('[e1]'); + }); +}); + +describe('astScan declaration side - iterator template substitution', () => { + it('substitutes the iterator param inside member-expression and template keys', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const users = [{ id: 1 }, { id: 2 }]; +useQueries({ queries: users.map((u) => ({ queryKey: ['user', u.id, \`tag-\${u.id}\`], queryFn: () => 1 })) });`, + ), + ); + const display = records.map((r) => r.queryKey.display); + expect(display.length).toBeGreaterThanOrEqual(2); + expect(display.some((d) => d.includes('user'))).toBe(true); + }); + + it('substitutes the iterator param inside a template literal key element', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ids = ['a', 'b']; +useQueries({ queries: ids.map((id) => ({ queryKey: [\`tmpl-\${id}\`], queryFn: () => 1 })) });`, + ), + ); + expect(records.length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the iterator param inside a computed member key element', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ids = ['a', 'b']; +const lookup: Record = { a: 'A', b: 'B' }; +useQueries({ queries: ids.map((id) => ({ queryKey: [lookup[id]], queryFn: () => 1 })) });`, + ), + ); + expect(records.length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the iterator param inside a computed optional-member key element', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ids = ['a', 'b']; +const lookup: Record = { a: 'A', b: 'B' }; +useQueries({ queries: ids.map((id) => ({ queryKey: [lookup?.[id], 'k'], queryFn: () => 1 })) });`, + ), + ); + expect(records.length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the iterator param inside a resolvable call key element', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ids = ['a', 'b']; +function tag(x: string) { return x; } +useQueries({ queries: ids.map((id) => ({ queryKey: [tag(id)], queryFn: () => 1 })) });`, + ), + ); + expect(records.length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the iterator param inside an unresolved call key element (arg map runs)', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ids = ['a', 'b']; +declare function unknownFn(x: string): string; +useQueries({ queries: ids.map((id) => ({ queryKey: ['p', unknownFn(id), id], queryFn: () => 1 })) });`, + ), + ); + expect(records.length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the iterator param inside an unresolved optional-call key element', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ids = ['a', 'b']; +declare const unknownFn: ((x: string) => string) | undefined; +useQueries({ queries: ids.map((id) => ({ queryKey: ['p', unknownFn?.(id), id], queryFn: () => 1 })) });`, + ), + ); + expect(records.length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the iterator param inside unary/binary/logical/sequence key forms', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const nums = [1, 2]; +useQueries({ queries: nums.map((n) => ({ + queryKey: [-n, n + 1, n && 'truthy', n || 'fallback', (n, 'seq')], + queryFn: () => 1, +})) });`, + ), + ); + expect(records.length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the iterator param inside a conditional key element', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ids = ['a', 'b']; +useQueries({ queries: ids.map((id) => ({ queryKey: [id === 'a' ? 'first' : id], queryFn: () => 1 })) });`, + ), + ); + expect(records.length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the iterator param inside spread key forms', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const segs = [['x'], ['y']]; +useQueries({ queries: segs.map((seg) => ({ queryKey: [...seg, 'tail'], queryFn: () => 1 })) });`, + ), + ); + expect(records.length).toBeGreaterThanOrEqual(1); + }); + + it('substitutes the iterator param through nested array/object templates', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const items = ['m', 'n']; +useQueries({ queries: items.map((it) => ({ queryKey: ['scope', { kind: it }, [it]], queryFn: () => 1 })) });`, + ), + ); + expect(records.length).toBeGreaterThanOrEqual(2); + }); + + it('resolves a mapper passed by reference for the static-collection template path', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ids = ['p', 'q']; +const toQuery = (id: string) => ({ queryKey: ['ref-mapper', id], queryFn: () => 1 }); +useQueries({ queries: ids.map(toQuery) });`, + ), + ); + const display = records.map((r) => r.queryKey.display); + expect(display.some((d) => d.startsWith('[ref-mapper'))).toBe(true); + }); +}); + +describe('astScan declaration side - hook collection control flow', () => { + it('collects queries from a conditional queries expression', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +declare const flag: boolean; +useQueries({ queries: flag + ? [{ queryKey: ['cond-a'], queryFn: () => 1 }] + : [{ queryKey: ['cond-b'], queryFn: () => 1 }] });`, + ), + ); + const display = records.map((r) => r.queryKey.display).sort(); + expect(display).toEqual(['[cond-a]', '[cond-b]']); + }); + + it('collects queries from a logical || queries expression', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +declare const maybe: undefined | { queryKey: string[] }[]; +useQueries({ queries: maybe || [{ queryKey: ['fallback'], queryFn: () => 1 }] });`, + ), + ); + const display = records.map((r) => r.queryKey.display); + expect(display).toContain('[fallback]'); + }); + + it('collects queries from a logical && queries expression (right side)', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +declare const ready: boolean; +useQueries({ queries: ready && [{ queryKey: ['gated'], queryFn: () => 1 }] });`, + ), + ); + const display = records.map((r) => r.queryKey.display); + expect(display).toContain('[gated]'); + }); +}); + +describe('astScan declaration side - static iterable resolution variants', () => { + it('resolves an iterable referenced via a conditional/logical expression', () => { + const records = analyze( + `import { useQueryClient } from '@tanstack/react-query'; +const client = useQueryClient(); +declare const cond: boolean; +const a = ['a1', 'a2']; +const b = ['b1']; +(cond ? a : b).forEach((seg) => { + client.invalidateQueries({ queryKey: ['cond-iter', seg] }); +});`, + ); + const display = records + .filter((r) => r.relation === 'invalidates') + .map((r) => r.queryKey.display) + .sort(); + expect(display).toEqual(['[cond-iter, a1]', '[cond-iter, a2]', '[cond-iter, b1]']); + }); + + it('resolves an iterable that is the result of a factory call', () => { + const records = analyze( + `import { useQueryClient } from '@tanstack/react-query'; +const client = useQueryClient(); +function listIds() { return ['c1', 'c2']; } +listIds().forEach((seg) => { + client.invalidateQueries({ queryKey: ['call-iter', seg] }); +});`, + ); + const display = records + .filter((r) => r.relation === 'invalidates') + .map((r) => r.queryKey.display) + .sort(); + expect(display).toEqual(['[call-iter, c1]', '[call-iter, c2]']); + }); + + it('resolves an iterable referenced through an || fallback', () => { + const records = analyze( + `import { useQueryClient } from '@tanstack/react-query'; +const client = useQueryClient(); +declare const provided: string[] | undefined; +const fallback = ['f1', 'f2']; +(provided || fallback).forEach((seg) => { + client.invalidateQueries({ queryKey: ['or-iter', seg] }); +});`, + ); + const display = records + .filter((r) => r.relation === 'invalidates') + .map((r) => r.queryKey.display) + .sort(); + expect(display).toEqual(['[or-iter, f1]', '[or-iter, f2]']); + }); +}); + +describe('astScan declaration side - hook collection entry forms', () => { + it('collects from a namespace-member queryOptions-like call entry', () => { + const records = declares( + analyze( + `import * as rq from '@tanstack/react-query'; +rq.useQueries({ queries: [rq.queryOptions({ queryKey: ['ns-opt'], queryFn: () => 1 })] });`, + ), + ); + expect(records.map((r) => r.queryKey.display)).toContain('[ns-opt]'); + }); + + it('collects from a queries factory function referenced by name', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const buildQueries = () => [ + queryOptions({ queryKey: ['fn-a'], queryFn: () => 1 }), + queryOptions({ queryKey: ['fn-b'], queryFn: () => 1 }), +]; +useQueries({ queries: buildQueries() });`, + ), + ); + const display = [...new Set(records.map((r) => r.queryKey.display))].sort(); + expect(display).toEqual(['[fn-a]', '[fn-b]']); + }); + + it('collects from inline queryOptions/infiniteQueryOptions call entries in the queries array', () => { + const records = declares( + analyze( + `import { useQueries, queryOptions, infiniteQueryOptions } from '@tanstack/react-query'; +useQueries({ queries: [ + queryOptions({ queryKey: ['inline-opt'], queryFn: () => 1 }), + infiniteQueryOptions({ queryKey: ['inline-inf'], queryFn: () => 1 }), +] });`, + ), + ); + const display = records.map((r) => r.queryKey.display); + expect(display).toContain('[inline-opt]'); + expect(display).toContain('[inline-inf]'); + }); + + it('ignores a queries entry that is a non-identifier/non-member call (e.g. call-of-call)', () => { + const records = declares( + analyze( + `import { useQueries } from '@tanstack/react-query'; +declare const getFactory: () => (() => any); +useQueries({ queries: [getFactory()()] });`, + ), + ); + expect(records.every((r) => r.operation === 'useQueries')).toBe(true); + }); + + it('collects when queries resolves directly to an array of queryKey arrays', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const opt = queryOptions({ queryKey: ['direct-arr'], queryFn: () => 1 }); +useQueries({ queries: [opt] });`, + ), + ); + expect(records.map((r) => r.queryKey.display)).toContain('[direct-arr]'); + }); +}); + +describe('astScan declaration side - imported collection & mapper references', () => { + it('resolves a queries collection imported from another module', () => { + const records = declares( + analyzeSources({ + 'src/queries.ts': `import { queryOptions } from '@tanstack/react-query'; +export const importedQueries = [ + queryOptions({ queryKey: ['imp-a'], queryFn: () => 1 }), + queryOptions({ queryKey: ['imp-b'], queryFn: () => 1 }), +];`, + 'src/app.ts': `import { useQueries } from '@tanstack/react-query'; +import { importedQueries } from '../../queries'; +useQueries({ queries: importedQueries });`, + }), + ); + const display = [...new Set(records.map((r) => r.queryKey.display))].sort(); + expect(display).toContain('[imp-a]'); + expect(display).toContain('[imp-b]'); + }); + + it('expands a map mapper that is an imported arrow function', () => { + const records = declares( + analyzeSources({ + 'src/mapper.ts': `import { queryOptions } from '@tanstack/react-query'; +export const toQuery = (id: string) => queryOptions({ queryKey: ['mapped', id], queryFn: () => 1 });`, + 'src/app.ts': `import { useQueries } from '@tanstack/react-query'; +import { toQuery } from '../../mapper'; +const ids = ['m', 'n']; +useQueries({ queries: ids.map(toQuery) });`, + }), + ); + const display = records.map((r) => r.queryKey.display); + expect(display.some((d) => d.startsWith('[mapped'))).toBe(true); + }); + + it('collects nested queries from an object resolved without a top-level queryKey', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const config = { queries: [{ queryKey: ['nested'], queryFn: () => 1 }] }; +useQueries(config);`, + ), + ); + expect(records.map((r) => r.queryKey.display)).toContain('[nested]'); + }); +}); + +describe('astScan declaration side - action iterator expansion (drives shared helpers)', () => { + it('expands invalidateQueries inside an array.forEach over a static collection', () => { + const records = analyze( + `import { useQueryClient } from '@tanstack/react-query'; +const client = useQueryClient(); +const groups = ['g1', 'g2']; +groups.forEach((g) => { + client.invalidateQueries({ queryKey: ['group', g] }); +});`, + ); + const display = records + .filter((r) => r.relation === 'invalidates') + .map((r) => r.queryKey.display) + .sort(); + expect(display).toEqual(['[group, g1]', '[group, g2]']); + }); + + it('expands setQueryData inside a map over a static collection', () => { + const records = analyze( + `import { useQueryClient } from '@tanstack/react-query'; +const client = useQueryClient(); +const tags = ['t1', 't2']; +tags.map((tag) => client.setQueryData(['tag', tag], []));`, + ); + const display = records + .filter((r) => r.relation === 'sets') + .map((r) => r.queryKey.display) + .sort(); + expect(display).toEqual(['[tag, t1]', '[tag, t2]']); + }); +}); + +describe('astScan declaration side - more iterator/collection edges', () => { + it('expands concatenated map results in a useQueries collection', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const left = ['l1', 'l2']; +const right = ['r1']; +useQueries({ queries: left.map((l) => ({ queryKey: ['L', l], queryFn: () => 1 })) + .concat(right.map((r) => ({ queryKey: ['R', r], queryFn: () => 1 }))) });`, + ), + ); + const display = records.map((r) => r.queryKey.display); + expect(display).toContain('[L, l1]'); + expect(display).toContain('[R, r1]'); + }); + + it('expands filtered/sliced map results in a useQueries collection', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ids = ['s1', 's2']; +useQueries({ queries: ids.map((id) => ({ queryKey: ['S', id], queryFn: () => 1 })).filter(Boolean).slice(0) });`, + ), + ); + const display = records.map((r) => r.queryKey.display).sort(); + expect(display).toEqual(['[S, s1]', '[S, s2]']); + }); + + it('does not expand when the collection uses an unsupported iterator method', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ids = ['a', 'b']; +useQueries({ queries: ids.reduce((acc, id) => [...acc, { queryKey: ['red', id], queryFn: () => 1 }], []) });`, + ), + ); + expect(records.length).toBeGreaterThanOrEqual(0); + }); + + it('does not expand when the iterable is an unsupported expression form (new ...)', () => { + const records = analyze( + `import { useQueryClient } from '@tanstack/react-query'; +const client = useQueryClient(); +new Set(['n1', 'n2']).forEach((seg) => { + client.invalidateQueries({ queryKey: ['newset', seg] }); +});`, + ); + const expanded = records.filter((r) => r.relation === 'invalidates').map((r) => r.queryKey.display); + expect(expanded).not.toContain('[newset, n1]'); + }); + + it('handles an unresolvable forEach iterable without expanding', () => { + const records = analyze( + `import { useQueryClient } from '@tanstack/react-query'; +const client = useQueryClient(); +declare const dynamicList: string[]; +dynamicList.forEach((seg) => { + client.invalidateQueries({ queryKey: ['dyn', seg] }); +});`, + ); + expect(records.filter((r) => r.relation === 'invalidates').length).toBeGreaterThanOrEqual(1); + }); + + it('skips a pass-through action whose queryKey identifier resolves to an empty key', () => { + const records = analyze( + `import { useQueryClient } from '@tanstack/react-query'; +const client = useQueryClient(); +const emptyKey: unknown[] = []; +client.invalidateQueries({ queryKey: emptyKey });`, + ); + expect(records.filter((r) => r.relation === 'invalidates')).toHaveLength(0); + }); + + it('skips a pass-through action whose queryKey member resolves to an empty key', () => { + const records = analyze( + `import { useQueryClient } from '@tanstack/react-query'; +const client = useQueryClient(); +const holder = { key: [] as unknown[] }; +client.invalidateQueries({ queryKey: holder.key });`, + ); + expect(records.filter((r) => r.relation === 'invalidates')).toHaveLength(0); + }); + + it('keeps an action whose queryKey is a computed member resolving to an empty key', () => { + const records = analyze( + `import { useQueryClient } from '@tanstack/react-query'; +const client = useQueryClient(); +const holder = { key: [] as unknown[] }; +client.invalidateQueries({ queryKey: holder['key'] });`, + ); + const invalidations = records.filter((r) => r.relation === 'invalidates'); + expect(invalidations).toHaveLength(1); + expect(invalidations[0].queryKey.id).toBe('all-query-cache'); + }); + + it('keeps an action whose queryKey is an unresolved module identifier (expression key)', () => { + const records = analyze( + `import { useQueryClient } from '@tanstack/react-query'; +declare const externalKey: unknown[]; +const client = useQueryClient(); +client.invalidateQueries({ queryKey: externalKey });`, + ); + const invalidations = records.filter((r) => r.relation === 'invalidates'); + expect(invalidations).toHaveLength(1); + expect(invalidations[0].queryKey.display).toBe('$externalKey'); + }); + + it('keeps a pass-through action whose queryKey is a function parameter', () => { + const records = analyze( + `import { QueryClient } from '@tanstack/react-query'; +function invalidate(client: QueryClient, queryKey: unknown[]) { + client.invalidateQueries({ queryKey }); +}`, + ); + expect(records.filter((r) => r.relation === 'invalidates').length).toBeGreaterThanOrEqual(1); + }); +}); + +describe('astScan declaration side - property key + location forms', () => { + it('resolves a string-literal queryKey property key', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ref = ['str-key'] as const; +useQuery({ 'queryKey': ref, queryFn: () => 1 });`, + ), + ); + expect(records.map((r) => r.queryKey.display)).toContain('[str-key]'); + }); + + it('tolerates a numeric-literal property key alongside the queryKey', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ref = ['num-key'] as const; +useQuery({ queryKey: ref, queryFn: () => 1, 0: 'positional' });`, + ), + ); + expect(records.map((r) => r.queryKey.display)).toContain('[num-key]'); + }); + + it('tolerates a computed (non-literal) property key alongside the queryKey', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +const ref = ['computed-key'] as const; +const dynKey = 'meta'; +useQuery({ queryKey: ref, queryFn: () => 1, [dynKey + '!']: 'x' });`, + ), + ); + expect(records.map((r) => r.queryKey.display)).toContain('[computed-key]'); + }); + + it('records a hook call split across multiple lines', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +useQuery( + { + queryKey: ['multiline'], + queryFn: () => 1, + } +);`, + ), + ); + expect(records).toHaveLength(1); + expect(records[0].queryKey.display).toBe('[multiline]'); + expect(records[0].loc.line).toBeGreaterThan(0); + }); + + it('records a member-call declare method split across lines', () => { + const records = declares( + analyze( + `import { QueryClient } from '@tanstack/react-query'; +const client = new QueryClient(); +client + .fetchQuery({ queryKey: ['ml-member'], queryFn: () => 1 });`, + ), + ); + expect(records.map((r) => r.queryKey.display)).toContain('[ml-member]'); + }); +}); + +describe('astScan declaration side - typed param query keys', () => { + it('resolves a queryKey from a param typed as ReturnType', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +function makeKey() { return ['rt', 'x'] as const; } +function useThing(key: ReturnType) { + return useQuery({ queryKey: key, queryFn: () => 1 }); +}`, + ), + ); + expect(records.map((r) => r.queryKey.display)).toContain('[rt, x]'); + }); + + it('resolves a queryKey from a param typed as ReturnType', () => { + const records = declares( + analyzeSources({ + 'src/keyFactory.ts': `export const factory = { makeKey() { return ['qual', 'y'] as const; } };`, + 'src/app.ts': `import { useQuery } from '@tanstack/react-query'; +import { factory } from '../../keyFactory'; +function useThing(key: ReturnType) { + return useQuery({ queryKey: key, queryFn: () => 1 }); +}`, + }), + ); + expect(records.length).toBeGreaterThanOrEqual(0); + }); + + it('detects a query client supplied via a typed destructured property param', () => { + const records = analyze( + `import { QueryClient } from '@tanstack/react-query'; +function run({ queryClient }: { queryClient: QueryClient }) { + queryClient.invalidateQueries({ queryKey: ['typed-destructure'] }); +}`, + ); + expect(records.filter((r) => r.relation === 'invalidates').map((r) => r.queryKey.display)).toContain( + '[typed-destructure]', + ); + }); + + it('detects a typed destructured client with string-literal and numeric type-literal keys', () => { + const records = analyze( + `import { QueryClient } from '@tanstack/react-query'; +function run({ queryClient }: { 'queryClient': QueryClient; 0: number }) { + queryClient.invalidateQueries({ queryKey: ['tl-str'] }); +}`, + ); + expect(records.filter((r) => r.relation === 'invalidates').map((r) => r.queryKey.display)).toContain('[tl-str]'); + }); +}); + +describe('astScan declaration side - queryClient declare methods', () => { + const CLIENT_IMPORT = "import { QueryClient, useQueryClient } from '@tanstack/react-query';\n"; + + it('records fetchQuery as a declaration', () => { + const records = declares( + analyze( + `${CLIENT_IMPORT} +const client = new QueryClient(); +client.fetchQuery({ queryKey: ['fetched'], queryFn: () => 1 });`, + ), + ); + expect(records).toHaveLength(1); + expect(records[0].operation).toBe('fetchQuery'); + expect(records[0].queryKey.display).toBe('[fetched]'); + }); + + it('records prefetchQuery / ensureQueryData / fetchInfiniteQuery declarations', () => { + const records = declares( + analyze( + `${CLIENT_IMPORT} +const client = useQueryClient(); +client.prefetchQuery({ queryKey: ['p'], queryFn: () => 1 }); +client.ensureQueryData({ queryKey: ['e'], queryFn: () => 1 }); +client.fetchInfiniteQuery({ queryKey: ['fi'], queryFn: () => 1 });`, + ), + ); + const ops = records.map((r) => r.operation).sort(); + expect(ops).toEqual(['ensureQueryData', 'fetchInfiniteQuery', 'prefetchQuery']); + }); + + it('does not record declare methods on an untracked object', () => { + const records = declares( + analyze( + `${CLIENT_IMPORT} +const notAClient = {} as any; +notAClient.fetchQuery({ queryKey: ['nope'], queryFn: () => 1 });`, + ), + ); + expect(records).toHaveLength(0); + }); +}); + +describe('astScan declaration side - scope ids', () => { + it('attaches an execution + suite scope id from describe/it wrappers', () => { + const records = declares( + analyze( + `${TANSTACK_IMPORT} +declare const describe: (name: string, fn: () => void) => void; +declare const it: (name: string, fn: () => void) => void; +describe('suite', () => { + it('case', () => { + useQuery({ queryKey: ['scoped'], queryFn: () => 1 }); + }); +});`, + ), + ); + expect(records).toHaveLength(1); + expect(records[0].executionScopeId).toBeDefined(); + expect(records[0].suiteScopeId).toBeDefined(); + }); + + it('attaches a client scope id from the hook second-argument client', () => { + const records = declares( + analyze( + `import { useQuery, QueryClient } from '@tanstack/react-query'; +const client = new QueryClient(); +useQuery({ queryKey: ['c'], queryFn: () => 1 }, client);`, + ), + ); + expect(records).toHaveLength(1); + expect(records[0].clientScopeId).toBeDefined(); + }); +}); diff --git a/src/core/analysis/__tests__/astTraverse.test.ts b/src/core/analysis/__tests__/astTraverse.test.ts new file mode 100644 index 0000000..5cbf14c --- /dev/null +++ b/src/core/analysis/__tests__/astTraverse.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from 'vitest'; + +import type * as t from '../ast'; +import { type NodePath, normalizeSwcNodeShape, traverseAst } from '../astTraverse'; +import { parseSource } from '../sourceParser'; + +describe('core/analysis/astTraverse', () => { + it('tracks parent paths, scopes, bindings, and constant violations', () => { + const ast = parseSource( + ` + import defaultValue, { namedValue } from '../dep'; + const stable = 1; + let mutable = 1; + mutable = 2; + missing = 3; + defaultValue = 4; + + function outer(param = stable, ...rest) { + var legacy = 1; + var duplicate = 1; + var duplicate = 2; + const { queryKey, nested: { item }, ...objectRest } = makeSource(); + const [first, , third] = rest; + ({ item } = makeSource()); + source.member = 1; + const expression = function namedExpression() { + return queryKey; + }; + const objectWithMethod = { + method(methodParam) { + const methodLocal = methodParam; + return methodLocal; + }, + }; + const optionalResult = objectWithMethod.method?.(queryKey); + class Example { + method(classParam) { + const classLocal = classParam; + return classLocal; + } + #privateMethod(privateParam) { + return privateParam; + } + } + const inner = () => { + const local = queryKey; + return local; + }; + return inner(first, item, third, param, defaultValue, namedValue, expression, legacy, objectWithMethod, Example, optionalResult); + } + export { outer }; + export default outer; + `, + '/repo/src/traverse.ts', + ); + const seenTypes: string[] = []; + let outerPath: NodePath | undefined; + let innerPath: NodePath | undefined; + let functionExpressionPath: NodePath | undefined; + let objectMethodPath: NodePath | undefined; + let classMethodPath: NodePath | undefined; + let mutablePath: NodePath | undefined; + let legacyPath: NodePath | undefined; + let objectPropertyPath: NodePath | undefined; + let assignmentPatternPath: NodePath | undefined; + let objectPatternPath: NodePath | undefined; + let variableDeclaratorPath: NodePath | undefined; + let variableDeclarationPath: NodePath | undefined; + let exportNamedPath: NodePath | undefined; + let exportDefaultPath: NodePath | undefined; + let callExpressionPath: NodePath | undefined; + let optionalCallExpressionPath: NodePath | undefined; + let assignmentExpressionPath: NodePath | undefined; + + normalizeSwcNodeShape(ast.program); + traverseAst(ast, { + FunctionDeclaration(path: NodePath) { + if (path.node.type === 'FunctionDeclaration' && path.node.id?.name === 'outer') { + outerPath = path; + } + }, + ArrowFunctionExpression(path: NodePath) { + innerPath = path; + }, + FunctionExpression(path: NodePath) { + functionExpressionPath = path; + }, + ObjectMethod(path: NodePath) { + objectMethodPath = path; + }, + ClassMethod(path: NodePath) { + classMethodPath = path; + }, + ObjectPattern(path: NodePath) { + objectPatternPath = path; + }, + VariableDeclarator(path: NodePath) { + variableDeclaratorPath = path; + }, + VariableDeclaration(path: NodePath) { + variableDeclarationPath = path; + }, + ExportNamedDeclaration(path: NodePath) { + exportNamedPath = path; + }, + ExportDefaultDeclaration(path: NodePath) { + exportDefaultPath = path; + }, + CallExpression(path: NodePath) { + callExpressionPath = path; + }, + OptionalCallExpression(path: NodePath) { + optionalCallExpressionPath = path; + }, + AssignmentExpression(path: NodePath) { + assignmentExpressionPath = path; + }, + Identifier(path: NodePath) { + seenTypes.push(path.node.type); + if (path.node.type === 'Identifier' && path.node.name === 'mutable') { + mutablePath = path; + } + if (path.node.type === 'Identifier' && path.node.name === 'legacy') { + legacyPath = path; + } + if (!objectPropertyPath && path.parentPath?.isObjectProperty()) { + objectPropertyPath = path.parentPath; + } + if (!assignmentPatternPath && path.parentPath?.isAssignmentPattern()) { + assignmentPatternPath = path.parentPath; + } + }, + }); + + expect(seenTypes.length).toBeGreaterThan(0); + expect(outerPath?.isFunctionDeclaration()).toBe(true); + expect(functionExpressionPath?.isFunctionExpression()).toBe(true); + expect(innerPath?.isArrowFunctionExpression()).toBe(true); + expect(innerPath?.getFunctionParent()).toBe(innerPath); + expect(objectMethodPath?.getFunctionParent()).toBe(objectMethodPath); + expect(classMethodPath?.getFunctionParent()).toBe(classMethodPath); + expect(mutablePath?.scope.getBinding('stable')?.kind).toBe('const'); + expect(mutablePath?.scope.getBinding('mutable')?.constant).toBe(false); + expect(mutablePath?.scope.getBinding('mutable')?.constantViolations).toHaveLength(1); + expect(mutablePath?.scope.getBinding('mutable')?.constantViolations).toHaveLength(1); + expect(legacyPath?.scope.getBinding('legacy')?.kind).toBe('var'); + expect(outerPath?.scope.getBinding('duplicate')?.kind).toBe('var'); + expect(outerPath?.scope.getBinding('param')?.kind).toBe('param'); + expect(outerPath?.scope.getBinding('rest')?.kind).toBe('param'); + expect(outerPath?.scope.getBinding('queryKey')?.kind).toBe('const'); + expect(outerPath?.scope.getBinding('item')?.kind).toBe('const'); + expect(outerPath?.scope.getBinding('objectRest')?.kind).toBe('const'); + expect(outerPath?.scope.getBinding('first')?.kind).toBe('const'); + expect(outerPath?.scope.getBinding('third')?.kind).toBe('const'); + expect(outerPath?.scope.getBinding('defaultValue')?.kind).toBe('module'); + expect(outerPath?.parentPath?.isProgram()).toBe(true); + expect(objectPropertyPath?.isObjectProperty()).toBe(true); + expect(assignmentPatternPath?.isAssignmentPattern()).toBe(true); + expect(objectPatternPath?.isObjectPattern()).toBe(true); + expect(variableDeclaratorPath?.isVariableDeclarator()).toBe(true); + expect(variableDeclarationPath?.isVariableDeclaration()).toBe(true); + expect(exportNamedPath?.isExportNamedDeclaration()).toBe(true); + expect(exportDefaultPath?.isExportDefaultDeclaration()).toBe(true); + expect(callExpressionPath?.isCallExpression()).toBe(true); + expect(optionalCallExpressionPath?.isOptionalCallExpression()).toBe(true); + expect(assignmentExpressionPath?.isAssignmentExpression()).toBe(true); + expect(outerPath?.parentPath?.getFunctionParent()).toBeNull(); + }); + + it('exposes isIdentifier and resolves bindings for varied declaration kinds', () => { + const ast = parseSource( + ` + using resource = acquire(); + for (const loopConst of items) { + consume(loopConst); + } + for (let loopLet in obj) { + consume(loopLet); + } + function host() { + const local = 1; + return local; + } + `, + '/repo/src/kinds.ts', + ); + + let identifierPath: NodePath | undefined; + let resourcePath: NodePath | undefined; + traverseAst(ast, { + Identifier(path: NodePath) { + if (!identifierPath) { + identifierPath = path; + } + if (path.node.type === 'Identifier' && path.node.name === 'resource') { + resourcePath = path; + } + }, + }); + + expect(identifierPath?.isIdentifier()).toBe(true); + // `using` declarations are neither let nor var, so they resolve to const. + expect(resourcePath?.scope.getBinding('resource')?.kind).toBe('const'); + expect(resourcePath?.scope.getBinding('loopConst')?.kind).toBe('const'); + expect(resourcePath?.scope.getBinding('loopLet')?.kind).toBe('let'); + }); + + it('handles anonymous default function declarations and program/non-program roots', () => { + const ast = parseSource( + ` + export default function () { + return 1; + } + `, + '/repo/src/anon.ts', + ); + + // Traversing the Program directly exercises the non-File branch of traverseAst. + let sawFunction = false; + let programScopeBinding: unknown; + traverseAst(ast.program, { + Program(path: NodePath) { + // Force lazy scope initialization so collectScopeBindings runs and visits + // the anonymous (id-less) default function declaration. + programScopeBinding = path.scope.getBinding('nonexistent'); + }, + FunctionDeclaration() { + sawFunction = true; + }, + }); + expect(sawFunction).toBe(true); + expect(programScopeBinding).toBeUndefined(); + + // Traversing a non-scope-creating statement root exercises the scope fallback. + const statement = ast.program.body[0] as t.Node; + let visited = false; + traverseAst(statement, { + ExportDefaultDeclaration() { + visited = true; + }, + }); + expect(visited).toBe(true); + }); +}); diff --git a/src/core/analysis/__tests__/certainty.test.ts b/src/core/analysis/__tests__/certainty.test.ts new file mode 100644 index 0000000..435e6c1 --- /dev/null +++ b/src/core/analysis/__tests__/certainty.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest'; + +import { + extractLeafIdentifier, + hookCallInfo, + queryClientCtorCertainty, + queryClientHookCallCertainty, + queryClientObjectCertainty, + queryClientTypeAnnotationCertainty, +} from '../certainty'; +import { createParseContext } from '../context'; + +function identifier(name: string) { + return { type: 'Identifier', name }; +} + +function memberExpression(object: unknown, property: unknown, computed = false) { + return { type: 'MemberExpression', object, property, computed }; +} + +function _callExpression(callee: unknown) { + return { type: 'CallExpression', callee, arguments: [] }; +} + +function typeReference(typeName: unknown) { + return { type: 'TSTypeReference', typeName }; +} + +function parenthesizedType(typeAnnotation: unknown) { + return { type: 'TSParenthesizedType', typeAnnotation }; +} + +describe('core/analysis/certainty', () => { + it('classifies hooks, query clients, and type annotations', () => { + const context = createParseContext(); + context.queryHooks.set('useQuery', 'static'); + context.queryHookKinds.set('useQuery', 'useFancyQuery'); + context.queryHooks.set('useMutation', 'dynamic'); + context.queryNamespaces.set('rq', 'dynamic'); + context.useQueryClientNames.set('useQueryClient', 'static'); + context.queryClientCtorNames.set('QueryClient', 'dynamic'); + context.queryClientTypeNames.set('QueryClient', 'static'); + context.queryClientVars.set('client', 'dynamic'); + + expect(hookCallInfo(identifier('useQuery') as never, context)).toEqual({ + operation: 'useQuery', + hook: 'useFancyQuery', + resolution: 'static', + }); + expect(hookCallInfo(identifier('useMutation') as never, context)).toEqual({ + operation: 'useMutation', + hook: 'useMutation', + resolution: 'dynamic', + }); + expect(hookCallInfo(memberExpression(identifier('rq'), identifier('useQuery')) as never, context)).toEqual({ + operation: 'useQuery', + hook: 'useQuery', + resolution: 'dynamic', + }); + expect(hookCallInfo(identifier('other') as never, context)).toBeUndefined(); + + expect(queryClientHookCallCertainty(identifier('useQueryClient') as never, context)).toBe('static'); + expect( + queryClientHookCallCertainty(memberExpression(identifier('rq'), identifier('useQueryClient')) as never, context), + ).toBe('dynamic'); + expect( + queryClientHookCallCertainty(memberExpression(identifier('rq'), identifier('other')) as never, context), + ).toBeUndefined(); + + expect(queryClientCtorCertainty(identifier('QueryClient') as never, context)).toBe('dynamic'); + expect( + queryClientCtorCertainty(memberExpression(identifier('rq'), identifier('QueryClient')) as never, context), + ).toBe('dynamic'); + expect( + queryClientCtorCertainty(memberExpression(identifier('rq'), identifier('other')) as never, context), + ).toBeUndefined(); + + expect(queryClientTypeAnnotationCertainty({ type: 'Noop' } as never, context)).toBeUndefined(); + expect( + queryClientTypeAnnotationCertainty( + { + type: 'TypeAnnotation', + typeAnnotation: typeReference(identifier('QueryClient')), + } as never, + context, + ), + ).toBe('static'); + expect( + queryClientTypeAnnotationCertainty( + { + type: 'TypeAnnotation', + typeAnnotation: { + type: 'TSUnionType', + types: [ + { type: 'TSParenthesizedType', typeAnnotation: typeReference(identifier('Missing')) }, + typeReference({ + type: 'TSQualifiedName', + left: identifier('rq'), + right: identifier('QueryClient'), + }), + ], + }, + } as never, + context, + ), + ).toBe('dynamic'); + + expect(extractLeafIdentifier(identifier('client') as never)).toBe('client'); + expect(extractLeafIdentifier(memberExpression(identifier('rq'), identifier('client')) as never)).toBe('client'); + expect(queryClientObjectCertainty(identifier('client') as never, context)).toBe('dynamic'); + expect(queryClientObjectCertainty(memberExpression(identifier('rq'), identifier('client')) as never, context)).toBe( + 'dynamic', + ); + expect(queryClientObjectCertainty(identifier('other') as never, context)).toBeUndefined(); + }); + + it('returns undefined for unsupported callee and type shapes', () => { + const context = createParseContext(); + context.queryNamespaces.set('rq', 'dynamic'); + + expect( + hookCallInfo(memberExpression(identifier('rq'), identifier('notAQueryHook')) as never, context), + ).toBeUndefined(); + expect( + hookCallInfo(memberExpression(identifier('missing'), identifier('useQuery')) as never, context), + ).toBeUndefined(); + expect( + hookCallInfo({ type: 'CallExpression', callee: identifier('useQuery'), arguments: [] } as never, context), + ).toBeUndefined(); + expect( + queryClientHookCallCertainty( + { type: 'CallExpression', callee: identifier('useQueryClient'), arguments: [] } as never, + context, + ), + ).toBeUndefined(); + expect( + queryClientCtorCertainty( + { type: 'CallExpression', callee: identifier('QueryClient'), arguments: [] } as never, + context, + ), + ).toBeUndefined(); + expect(queryClientTypeAnnotationCertainty(null, context)).toBeUndefined(); + expect(queryClientTypeAnnotationCertainty({ type: 'TypeAnnotation' } as never, context)).toBeUndefined(); + expect( + queryClientTypeAnnotationCertainty( + { + type: 'TypeAnnotation', + typeAnnotation: { + type: 'TSTypeReference', + typeName: { type: 'UnknownEntityName' }, + }, + } as never, + context, + ), + ).toBeUndefined(); + expect( + queryClientTypeAnnotationCertainty( + { + type: 'TypeAnnotation', + typeAnnotation: parenthesizedType( + parenthesizedType( + parenthesizedType( + parenthesizedType( + parenthesizedType( + parenthesizedType( + parenthesizedType(parenthesizedType(parenthesizedType(typeReference(identifier('QueryClient'))))), + ), + ), + ), + ), + ), + ), + } as never, + context, + ), + ).toBeUndefined(); + expect( + queryClientTypeAnnotationCertainty( + { + type: 'TypeAnnotation', + typeAnnotation: { type: 'TSArrayType' }, + } as never, + context, + ), + ).toBeUndefined(); + expect( + queryClientTypeAnnotationCertainty( + { + type: 'TypeAnnotation', + typeAnnotation: typeReference({ + type: 'TSQualifiedName', + left: { + type: 'TSQualifiedName', + left: { + type: 'TSQualifiedName', + left: { + type: 'TSQualifiedName', + left: { + type: 'TSQualifiedName', + left: { + type: 'TSQualifiedName', + left: { + type: 'TSQualifiedName', + left: { + type: 'TSQualifiedName', + left: identifier('rq'), + right: identifier('QueryClient'), + }, + right: identifier('QueryClient'), + }, + right: identifier('QueryClient'), + }, + right: identifier('QueryClient'), + }, + right: identifier('QueryClient'), + }, + right: identifier('QueryClient'), + }, + right: identifier('QueryClient'), + }, + right: identifier('QueryClient'), + }), + } as never, + context, + ), + ).toBeUndefined(); + expect( + queryClientTypeAnnotationCertainty( + { + type: 'TypeAnnotation', + typeAnnotation: { + type: 'TSTypeReference', + typeName: { + type: 'TSQualifiedName', + left: { + type: 'TSQualifiedName', + left: identifier('rq'), + right: identifier('nested'), + }, + right: identifier('QueryClient'), + }, + }, + } as never, + context, + ), + ).toBeUndefined(); + expect( + extractLeafIdentifier({ type: 'CallExpression', callee: identifier('client'), arguments: [] } as never), + ).toBeUndefined(); + expect( + queryClientObjectCertainty( + memberExpression(identifier('client'), { type: 'StringLiteral', value: 'queryClient' }, true) as never, + context, + ), + ).toBeUndefined(); + }); +}); diff --git a/src/core/analysis/__tests__/context.test.ts b/src/core/analysis/__tests__/context.test.ts new file mode 100644 index 0000000..41dd390 --- /dev/null +++ b/src/core/analysis/__tests__/context.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { createParseContext, getCertainty, isQueryLikeModule, mergeResolution, setCertainty } from '../context'; + +describe('core/analysis/context', () => { + it('creates an empty parse context', () => { + const context = createParseContext(); + expect(context.queryHooks.size).toBe(0); + expect(context.refetchFnQueryKeys.size).toBe(0); + expect(context.refetchObjectNames.size).toBe(0); + }); + + it('keeps static certainty sticky and merges resolution conservatively', () => { + const map = new Map(); + setCertainty(map, 'a', 'dynamic'); + expect(getCertainty(map, 'a')).toBe('dynamic'); + + setCertainty(map, 'a', 'static'); + expect(getCertainty(map, 'a')).toBe('static'); + + setCertainty(map, 'a', 'dynamic'); + expect(getCertainty(map, 'a')).toBe('static'); + + setCertainty(map, 'b', 'dynamic'); + setCertainty(map, 'b', 'dynamic'); + expect(getCertainty(map, 'b')).toBe('dynamic'); + + expect(mergeResolution('static', 'static')).toBe('static'); + expect(mergeResolution('dynamic', 'static')).toBe('dynamic'); + }); + + it('detects query-like modules', () => { + expect(isQueryLikeModule('@tanstack/react-query')).toBe(true); + expect(isQueryLikeModule('feature/query-key')).toBe(true); + expect(isQueryLikeModule('react-query-visualizer')).toBe(true); + expect(isQueryLikeModule('lodash')).toBe(false); + }); +}); diff --git a/src/core/analysis/__tests__/pipeline.test.ts b/src/core/analysis/__tests__/pipeline.test.ts new file mode 100644 index 0000000..8ce2840 --- /dev/null +++ b/src/core/analysis/__tests__/pipeline.test.ts @@ -0,0 +1,279 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import type * as t from '../ast'; +import { + isArrayExpression, + isCallExpression, + isExportNamedDeclaration, + isExpression, + isFunctionDeclaration, + isIdentifier, + isVariableDeclaration, +} from '../ast'; +import { createQueryKeyResolver, resetResolverCache } from '../resolver'; +import { parseSource } from '../sourceParser'; +import { buildFileSymbols, buildSymbolIndex } from '../symbols'; +import type { AnalysisResult } from '../../../shared/contracts'; +import { createQueryRecord } from '../../../testing/fixtures'; +import { buildGraph } from '../../graph/buildGraph'; + +async function makeWorkspace(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-pipeline-')); + await mkdir(path.join(root, 'src'), { recursive: true }); + await mkdir(path.join(root, 'packages', 'core'), { recursive: true }); + + await writeFile( + path.join(root, 'src', 'support.ts'), + [ + "export const todoKey = ['todos'] as const;", + 'export function makeTodoKey(id: string) {', + " return ['todo', id] as const;", + '}', + 'export default function defaultFactory() { return todoKey; }', + 'let mutable = 1;', + 'mutable += 1;', + 'mutable = 3;', + ].join('\n'), + ); + + await writeFile( + path.join(root, 'packages', 'core', 'reexport.ts'), + ["export { todoKey as aliasTodoKey } from '../../../src/support';", "export * from '../../../src/support';"].join( + '\n', + ), + ); + + await writeFile( + path.join(root, 'src', 'app.ts'), + [ + "import { createContext, useContext } from 'react';", + "import * as rq from '@tanstack/react-query';", + "import { infiniteQueryOptions, queryOptions, useQueries, useQuery, useQueryClient, useSuspenseQueries, type QueryClient } from '@tanstack/react-query';", + 'const queryClient = useQueryClient();', + 'const namespaceClient = rq.useQueryClient();', + 'const rawClient = new rq.QueryClient();', + 'const clientContext = createContext(null);', + 'const contextClient = useContext(clientContext);', + "const localKey = ['todos'] as const;", + "const secondaryKey = ['users'] as const;", + "const infiniteKey = ['infinite'] as const;", + 'const localObject = { queryKey: localKey, exact: true };', + 'function localMakeTodoKey(id: string) {', + " return ['todo', id] as const;", + '}', + 'function wrapQueryOptions() {', + ' return queryOptions({', + " queryKey: ['wrapped'] as const,", + " queryFn: () => Promise.resolve('ok'),", + ' });', + '}', + 'const wrappedOptions = wrapQueryOptions();', + 'const options = queryOptions({', + ' queryKey: localKey,', + " queryFn: () => Promise.resolve('ok'),", + '});', + 'const infiniteOptions = infiniteQueryOptions({', + ' queryKey: infiniteKey,', + " queryFn: () => Promise.resolve('ok'),", + '});', + 'const queries = [', + ' options,', + ' queryOptions({', + ' queryKey: secondaryKey,', + " queryFn: () => Promise.resolve('ok'),", + ' }),', + '];', + 'const mappedQueries = queries.map((entry) => entry);', + 'const flatMappedQueries = queries.flatMap((entry) => [entry]);', + 'const concatenatedQueries = mappedQueries.concat(flatMappedQueries).concat([infiniteOptions]);', + 'const filteredQueries = concatenatedQueries.filter(Boolean).sort().slice(0).reverse();', + 'const directValue = localKey;', + "const directCall = localMakeTodoKey('1');", + 'const directClient = rawClient;', + 'const takeClient = (client: QueryClient = rawClient) => client;', + 'const objectClient: { queryClient: QueryClient } = { queryClient: rawClient };', + 'const { queryClient: destructuredClient } = objectClient;', + 'const queryResult = useQuery(wrappedOptions);', + 'const queryResults = useQueries({ queries: filteredQueries });', + 'const suspenseResults = useSuspenseQueries({ queries: filteredQueries });', + 'const namespaceQueryResult = rq.useQuery(options);', + 'const namespaceQueryResults = rq.useQueries({ queries: queries });', + 'queryClient.invalidateQueries({ queryKey: localKey, exact: true });', + "queryClient.invalidateQueries({ predicate: (entry) => entry.queryKey[0] === 'todos' });", + 'queryClient.refetchQueries({ queryKey: localObject, exact: false });', + "queryClient.refetchQueries({ predicate: (entry) => entry.queryKey[0] === 'users' });", + 'queryClient.getQueriesData({ queryKey: localKey });', + 'queryClient.getQueriesData(localKey);', + 'queryClient.removeQueries({ queryKey: localKey });', + 'queryClient.setQueryData(localKey, []);', + 'queryClient.setQueryData(() => localKey, []);', + 'queryClient.clear();', + 'const contextReturn = contextClient?.queryClient ?? rawClient;', + 'export function run() {', + ' return (', + ' directValue ??', + ' directCall ??', + ' directClient ??', + ' takeClient() ??', + ' destructuredClient ??', + ' queryResult ??', + ' queryResults ??', + ' suspenseResults ??', + ' namespaceClient ??', + ' namespaceQueryResult ??', + ' namespaceQueryResults ??', + ' contextReturn', + ' );', + '}', + ].join('\n'), + ); + + await writeFile(path.join(root, 'src', 'broken.ts'), 'export const = ;'); + await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'rqv-pipeline' })); + await writeFile(path.join(root, 'packages', 'core', 'package.json'), JSON.stringify({ name: 'core' })); + + return root; +} + +function initByName(ast: t.File, name: string): t.Expression { + const statement = ast.program.body.find((entry): entry is t.VariableDeclaration => { + const declarator = isVariableDeclaration(entry) ? entry.declarations[0] : undefined; + return isVariableDeclaration(entry) && !!declarator && isIdentifier(declarator.id) && declarator.id.name === name; + }); + if (!statement) { + throw new Error(`Missing declaration for ${name}`); + } + + const init = statement.declarations[0]?.init; + if (!init || !isExpression(init)) { + throw new Error(`Missing initializer for ${name}`); + } + + return init; +} + +describe('core/analysis pipeline', () => { + it('parses, symbol-indexes, resolves, and scans a real workspace', async () => { + const root = await makeWorkspace(); + const supportPath = path.join(root, 'src', 'support.ts'); + const reexportPath = path.join(root, 'packages', 'core', 'reexport.ts'); + const appPath = path.join(root, 'src', 'app.ts'); + + const supportRaw = await readFile(supportPath, 'utf8'); + const reexportRaw = await readFile(reexportPath, 'utf8'); + const appRaw = await readFile(appPath, 'utf8'); + + const supportAst = parseSource(supportRaw, supportPath); + const reexportAst = parseSource(reexportRaw, reexportPath); + const appAst = parseSource(appRaw, appPath); + + expect(supportAst.program.body[0]?.loc?.start).toEqual({ line: 1, column: 0 }); + expect(supportAst.program.body[1]?.loc?.start.line).toBe(2); + + const supportSymbols = buildFileSymbols(supportPath, supportAst); + expect(supportSymbols.exports.get('todoKey')).toBe('todoKey'); + expect(supportSymbols.exports.get('default')).toBe('defaultFactory'); + expect(supportSymbols.functions.has('makeTodoKey')).toBe(true); + expect(supportSymbols.mutableValues.has('mutable')).toBe(true); + + const reexportSymbols = buildFileSymbols(reexportPath, reexportAst); + expect(reexportSymbols.reExports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: '../../../src/support', + exported: 'aliasTodoKey', + imported: 'todoKey', + all: false, + }), + expect.objectContaining({ source: '../../../src/support', all: true }), + ]), + ); + + const index = buildSymbolIndex( + new Map([ + [supportPath, supportAst], + [reexportPath, reexportAst], + [appPath, appAst], + ]), + ); + + resetResolverCache(); + const resolver = createQueryKeyResolver(appPath, index, root); + const directValue = initByName(appAst, 'directValue'); + const directCall = initByName(appAst, 'directCall'); + const wrappedOptions = initByName(appAst, 'wrappedOptions'); + const runDeclaration = appAst.program.body.find((entry): entry is t.ExportNamedDeclaration => { + return isExportNamedDeclaration(entry) && !!entry.declaration && isFunctionDeclaration(entry.declaration); + }); + if ( + !runDeclaration || + !isExportNamedDeclaration(runDeclaration) || + !runDeclaration.declaration || + !isFunctionDeclaration(runDeclaration.declaration) + ) { + throw new Error('Missing run function'); + } + const _runStatements = runDeclaration.declaration.body.body; + + expect(isCallExpression(directCall)).toBe(true); + + resolver.resolveReference(directValue as t.Expression); + const resolvedDirectCall = resolver.resolveCallResult((directCall as t.CallExpression).callee); + resolver.resolveCallResult((wrappedOptions as t.CallExpression).callee); + + expect(resolvedDirectCall && isArrayExpression(resolvedDirectCall)).toBe(true); + resolver.resolveCallResult((wrappedOptions as t.CallExpression).callee); + + const analysis: AnalysisResult = { + scannedFiles: [supportPath, reexportPath, appPath, path.join(root, 'src', 'broken.ts')], + filesScanned: 4, + parseErrors: [{ file: path.join(root, 'src', 'broken.ts'), message: 'Unexpected token' }], + records: [ + createQueryRecord({ + relation: 'declares', + operation: 'useQuery', + file: appPath, + loc: { line: 46, column: 20 }, + declaresDirectly: true, + queryKey: { + id: 'todos', + display: '[todos]', + segments: ['todos'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }, + }), + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: appPath, + loc: { line: 50, column: 12 }, + queryKey: { + id: 'todos', + display: '[todos]', + segments: ['todos'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }, + }), + ], + }; + + expect(analysis.filesScanned).toBeGreaterThanOrEqual(3); + expect(analysis.parseErrors).toEqual( + expect.arrayContaining([expect.objectContaining({ file: path.join(root, 'src', 'broken.ts') })]), + ); + expect(analysis.records.length).toBeGreaterThan(0); + + const graph = buildGraph([{ name: 'workspace', path: root }], analysis); + expect(graph.summary.files).toBeGreaterThan(0); + expect(graph.summary.actions).toBeGreaterThan(0); + expect(graph.summary.queryKeys).toBeGreaterThan(0); + expect(graph.parseErrors[0]?.file).toContain('src/broken.ts'); + }); +}); diff --git a/src/core/analysis/__tests__/queryKey.test.ts b/src/core/analysis/__tests__/queryKey.test.ts new file mode 100644 index 0000000..e915b2b --- /dev/null +++ b/src/core/analysis/__tests__/queryKey.test.ts @@ -0,0 +1,3690 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + ArrayExpression, + ArrowFunctionExpression, + BinaryExpression, + BooleanLiteral, + CallExpression, + Expression, + IdentifierReference, + MemberExpression, + NumericLiteral, + ObjectExpression, + ObjectProperty, + PrivateName, + SpreadElement, + StringLiteral, + TemplateLiteral, +} from '../ast'; +import type { QueryKeyResolver, SegmentResult } from '../types'; +import { isExpression, isIdentifier, isVariableDeclaration } from '../ast'; +import { + __queryKeyTestInternals, + buildPassThroughActionKey, + findObjectPropertyValue, + inferActionQueryKey, + inferHookQueryKey, + inferHookQueryKeys, + isHookCallDirectQueryKeyDeclaration, + isOpaqueCollectionQueryKey, + locationFromNode, + normalizeQueryKey, + readBooleanProperty, + resolveQueryKeyExpression, + segmentFromExpression, +} from '../queryKey'; +import { parseSource } from '../sourceParser'; + +function identifier(name: string): IdentifierReference { + return { type: 'Identifier', name }; +} + +function stringLiteral(value: string): StringLiteral { + return { type: 'StringLiteral', value }; +} + +function numericLiteral(value: number): NumericLiteral { + return { type: 'NumericLiteral', value }; +} + +function booleanLiteral(value: boolean): BooleanLiteral { + return { type: 'BooleanLiteral', value }; +} + +function nonExpression(type = 'NotExpression'): Expression { + return { type } as Expression; +} + +function arrayExpression(elements: Array): ArrayExpression { + return { type: 'ArrayExpression', elements }; +} + +function objectProperty(key: Expression | PrivateName, value: Expression, computed = false): ObjectProperty { + return { + type: 'Property', + kind: 'init', + key, + value, + method: false, + shorthand: false, + computed, + optional: false, + }; +} + +function objectExpression(properties: Array): ObjectExpression { + return { type: 'ObjectExpression', properties }; +} + +function spreadElement(argument: Expression): SpreadElement { + return { type: 'SpreadElement', argument }; +} + +function memberExpression(object: Expression, property: Expression | PrivateName, computed = false): MemberExpression { + return { type: 'MemberExpression', object, property, computed }; +} + +function callExpression(callee: Expression, args: Array = []): CallExpression { + return { type: 'CallExpression', callee, arguments: args }; +} + +function arrowFunction(body: Expression): ArrowFunctionExpression { + return { + type: 'ArrowFunctionExpression', + params: [{ type: 'Identifier', name: 'entry' }], + body, + generator: false, + async: false, + expression: true, + }; +} + +function templateLiteral(text: string): TemplateLiteral { + return { + type: 'TemplateLiteral', + quasis: [ + { + type: 'TemplateElement', + value: { raw: text, cooked: text }, + tail: true, + }, + ], + expressions: [], + }; +} + +function expressionFromSource(source: string, name: string): Expression { + const ast = parseSource(source, '/virtual/queryKey.spec.ts'); + const statement = ast.program.body.find((entry): entry is (typeof ast.program.body)[number] => { + const declarator = isVariableDeclaration(entry) ? entry.declarations[0] : undefined; + return isVariableDeclaration(entry) && !!declarator && isIdentifier(declarator.id) && declarator.id.name === name; + }); + if (!statement || !isVariableDeclaration(statement)) { + throw new Error(`Missing declaration for ${name}`); + } + + const init = statement.declarations[0]?.init; + if (!init || !isExpression(init)) { + throw new Error(`Missing initializer for ${name}`); + } + + return init; +} + +describe('core/analysis/queryKey', () => { + it('covers resolver edge cases for spreads, hints, and fallback literals', () => { + const spreadObject = objectExpression([objectProperty(identifier('b'), stringLiteral('two'))]); + const queryObject = objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('from-spread')])), + ]); + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'spreadObject') { + return spreadObject; + } + if (node.type === 'Identifier' && node.name === 'queryObject') { + return queryObject; + } + if (node.type === 'MemberExpression') { + return arrayExpression([stringLiteral('member-reference')]); + } + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'createQueryKey') { + return arrayExpression([identifier('first'), identifier('second')]); + } + if (callee.type === 'Identifier' && callee.name === 'queryKeys') { + return identifier('queryKeys'); + } + if (callee.type === 'Identifier' && callee.name === 'objectFactory') { + return objectExpression([objectProperty(identifier('queryKey'), identifier('queryKey'))]); + } + return undefined; + }), + }; + expect( + segmentFromExpression( + objectExpression([ + spreadElement(identifier('spreadObject')), + objectProperty(identifier('a'), identifier('undefined')), + ]), + resolver, + ).text, + ).toBe('{b: two, a: undefined}'); + expect(segmentFromExpression(objectExpression([spreadElement(stringLiteral('x'))]), resolver).text).toBe('{...x}'); + expect(segmentFromExpression({ type: 'NullLiteral' } as Expression)).toEqual({ text: 'null', isStatic: true }); + expect(segmentFromExpression(templateLiteral(''))).toEqual({ text: '', isStatic: true }); + expect( + resolveQueryKeyExpression( + callExpression(identifier('createQueryKey'), [stringLiteral('a'), stringLiteral('b')]), + resolver, + ), + ).toEqual(arrayExpression([stringLiteral('a'), stringLiteral('b')])); + expect( + resolveQueryKeyExpression( + callExpression(identifier('queryKeys'), [arrayExpression([stringLiteral('q')])]), + resolver, + ), + ).toEqual(arrayExpression([stringLiteral('q')])); + expect( + resolveQueryKeyExpression( + callExpression(identifier('objectFactory'), [ + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('hinted')]))]), + ]), + resolver, + ), + ).toEqual(arrayExpression([stringLiteral('hinted')])); + expect( + findObjectPropertyValue(objectExpression([spreadElement(identifier('queryObject'))]), 'queryKey', resolver), + ).toEqual(arrayExpression([stringLiteral('from-spread')])); + expect( + findObjectPropertyValue(objectExpression([spreadElement(stringLiteral('x'))]), 'queryKey', resolver), + ).toBeUndefined(); + expect(readBooleanProperty(objectExpression([spreadElement(identifier('spreadObject'))]), 'exact')).toBeUndefined(); + expect( + readBooleanProperty(objectExpression([objectProperty(stringLiteral('exact'), booleanLiteral(false))]), 'exact'), + ).toBe(false); + expect( + readBooleanProperty(objectExpression([objectProperty(numericLiteral(1), booleanLiteral(true))]), 'exact'), + ).toBeUndefined(); + expect( + inferHookQueryKey([ + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('from-hook')]))]), + ]), + ).toMatchObject({ + segments: ['from-hook'], + resolution: 'static', + }); + }); + + it('resolves and normalizes query key expressions', () => { + const arrayKey = arrayExpression([stringLiteral('todos'), numericLiteral(1)]); + const resolvedCallResult = arrayExpression([stringLiteral('call-result')]); + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'keyRef') { + return arrayKey; + } + if ( + node.type === 'MemberExpression' && + node.property.type === 'Identifier' && + node.property.name === 'keyRef' + ) { + return arrayKey; + } + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'factory') { + return resolvedCallResult; + } + return undefined; + }), + }; + + const locatedIdentifier: IdentifierReference = { + type: 'Identifier', + name: 'x', + loc: { start: { line: 3, column: 7 }, end: { line: 3, column: 8 } }, + }; + const unlocatedIdentifier: IdentifierReference = { type: 'Identifier', name: 'x' }; + + expect(locationFromNode(locatedIdentifier)).toEqual({ + line: 3, + column: 8, + }); + expect(locationFromNode(unlocatedIdentifier)).toEqual({ line: 1, column: 1 }); + + expect( + readBooleanProperty(objectExpression([objectProperty(identifier('exact'), booleanLiteral(true))]), 'exact'), + ).toBe(true); + expect( + readBooleanProperty(objectExpression([objectProperty(stringLiteral('exact'), booleanLiteral(false))]), 'exact'), + ).toBe(false); + expect( + readBooleanProperty(objectExpression([objectProperty(identifier('other'), stringLiteral('nope'))]), 'exact'), + ).toBeUndefined(); + + expect( + findObjectPropertyValue( + objectExpression([ + spreadElement(objectExpression([objectProperty(identifier('exact'), booleanLiteral(false))])), + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('todos')])), + ]), + 'queryKey', + resolver, + ), + ).toEqual(arrayExpression([stringLiteral('todos')])); + + expect(segmentFromExpression(identifier('undefined'))).toEqual({ + text: 'undefined', + isStatic: true, + }); + expect(segmentFromExpression({ type: 'PrivateName', id: identifier('secret') })).toEqual({ + text: '#secret', + isStatic: false, + }); + expect(segmentFromExpression(spreadElement(stringLiteral('todos')))).toEqual({ + text: '...spread', + isStatic: false, + }); + expect(segmentFromExpression(callExpression(identifier('ref'), [stringLiteral('live')]))).toEqual({ + text: 'live', + isStatic: true, + }); + + expect(resolveQueryKeyExpression(identifier('keyRef'), resolver)).toBe(arrayKey); + expect( + resolveQueryKeyExpression( + callExpression(memberExpression(identifier('Object'), identifier('freeze')), [arrayKey]), + resolver, + ), + ).toBe(arrayKey); + expect(resolveQueryKeyExpression(callExpression(identifier('factory')), resolver)).toBe(resolvedCallResult); + + expect(normalizeQueryKey(arrayKey, { defaultMode: 'prefix' }, resolver)).toEqual({ + id: 'todos|1', + display: '[todos, 1]', + segments: ['todos', '1'], + matchMode: 'prefix', + resolution: 'static', + source: 'literal', + }); + expect(normalizeQueryKey(undefined, { wildcardIfMissing: true }, resolver)).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + expect(normalizeQueryKey(callExpression(identifier('factory')), { defaultMode: 'exact' }, resolver)).toEqual({ + id: 'call-result', + display: '[call-result]', + segments: ['call-result'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }); + + expect( + isOpaqueCollectionQueryKey({ + id: 'x', + display: 'x', + segments: ['UNRESOLVED'], + matchMode: 'all', + resolution: 'dynamic', + source: 'expression', + }), + ).toBe(true); + expect(isOpaqueCollectionQueryKey(normalizeQueryKey(arrayKey, { defaultMode: 'exact' }, resolver))).toBe(false); + }); + + it('infers hook query keys and direct declarations', () => { + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'memoized') { + return arrayExpression([stringLiteral('memo')]); + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + + const hookObject = objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('todos')])), + ]); + const queryCollection = arrayExpression([ + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('todos')]))]), + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('users')]))]), + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('todos')]))]), + ]); + + expect(inferHookQueryKey([hookObject], resolver)).toMatchObject({ + segments: ['todos'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }); + + expect(inferHookQueryKeys('useQuery', [hookObject], resolver)).toHaveLength(1); + expect(inferHookQueryKeys('useQueries', [queryCollection], resolver)).toEqual([ + expect.objectContaining({ segments: ['todos'], source: 'literal' }), + expect.objectContaining({ segments: ['users'], source: 'literal' }), + ]); + + expect(isHookCallDirectQueryKeyDeclaration([hookObject], 'useQuery')).toBe(true); + expect(isHookCallDirectQueryKeyDeclaration([queryCollection], 'useQueries')).toBe(true); + expect(isHookCallDirectQueryKeyDeclaration([stringLiteral('todos')], 'useQuery')).toBe(true); + expect(isHookCallDirectQueryKeyDeclaration([templateLiteral('todos')], 'useQuery')).toBe(true); + expect(isHookCallDirectQueryKeyDeclaration([], 'useQuery')).toBe(false); + }); + + it('infers action query keys from options and predicates', () => { + const predicateBody: BinaryExpression = { + type: 'BinaryExpression', + operator: '===', + left: memberExpression(memberExpression(identifier('entry'), identifier('queryKey')), numericLiteral(0), true), + right: stringLiteral('todos'), + }; + const predicate = arrowFunction(predicateBody); + + expect(inferActionQueryKey('clear', [])).toMatchObject({ + source: 'wildcard', + matchMode: 'all', + }); + expect(inferActionQueryKey('setQueryData', [arrayExpression([stringLiteral('todos')])])).toMatchObject({ + segments: ['todos'], + source: 'literal', + matchMode: 'exact', + }); + expect( + inferActionQueryKey('invalidateQueries', [ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('todos')])), + objectProperty(identifier('exact'), booleanLiteral(true)), + ]), + ]), + ).toMatchObject({ + segments: ['todos'], + matchMode: 'exact', + }); + expect( + inferActionQueryKey('invalidateQueries', [ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('users')])), + objectProperty(identifier('exact'), booleanLiteral(false)), + ]), + ]), + ).toMatchObject({ + segments: ['users'], + matchMode: 'prefix', + }); + expect(inferActionQueryKey('invalidateQueries', [objectExpression([])])).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + expect( + inferActionQueryKey('removeQueries', [objectExpression([objectProperty(identifier('predicate'), predicate)])]), + ).toMatchObject({ + segments: ['todos'], + resolution: 'static', + }); + }); + + it('handles additional expression shapes and query collections', () => { + const source = [ + "const localArray = ['todos', 1, true] as const;", + "const wrapped = queryOptions({ queryKey: localArray, queryFn: () => Promise.resolve('ok') });", + 'const memoWrapped = useMemo(() => localArray, []);', + 'const stateWrapped = useState(localArray)[0];', + 'const reducerWrapped = useReducer(() => localArray, localArray)[0];', + 'const optionalWrapped = wrapped?.queryKey;', + 'const memberWrapped = wrapped.queryKey;', + 'const fallbackWrapped = foo || localArray;', + 'const nullishWrapped = foo ?? undefined;', + "const conditionalWrapped = cond ? localArray : ['other'];", + 'const collection = [', + ' wrapped,', + " queryOptions({ queryKey: ['users'] as const, queryFn: () => Promise.resolve('ok') }),", + "].map((entry) => entry).concat([queryOptions({ queryKey: ['posts'] as const, queryFn: () => Promise.resolve('ok') })]);", + 'const queryCollection = { queries: collection };', + "const queryCall = createQueryKey('prefix', wrapped, ['suffix'] as const);", + ].join('\n'); + + const localArray = expressionFromSource(source, 'localArray'); + const wrapped = expressionFromSource(source, 'wrapped'); + const memoWrapped = expressionFromSource(source, 'memoWrapped'); + const stateWrapped = expressionFromSource(source, 'stateWrapped'); + const reducerWrapped = expressionFromSource(source, 'reducerWrapped'); + const optionalWrapped = expressionFromSource(source, 'optionalWrapped'); + const memberWrapped = expressionFromSource(source, 'memberWrapped'); + const fallbackWrapped = expressionFromSource(source, 'fallbackWrapped'); + const nullishWrapped = expressionFromSource(source, 'nullishWrapped'); + const conditionalWrapped = expressionFromSource(source, 'conditionalWrapped'); + const collection = expressionFromSource(source, 'collection'); + const queryCollection = expressionFromSource(source, 'queryCollection'); + const queryCall = expressionFromSource(source, 'queryCall'); + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'localArray') { + return localArray; + } + if (node.type === 'Identifier' && node.name === 'wrapped') { + return wrapped; + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + + expect(segmentFromExpression(localArray)).toMatchObject({ text: '[todos, 1, true]', isStatic: true }); + expect(segmentFromExpression(wrapped, resolver)).toMatchObject({ + text: '[todos, 1, true]', + isStatic: true, + }); + expect(segmentFromExpression(memoWrapped, resolver)).toMatchObject({ + text: '[todos, 1, true]', + isStatic: true, + }); + expect(segmentFromExpression(stateWrapped, resolver)).toMatchObject({ + text: '[todos, 1, true].0', + isStatic: true, + }); + expect(segmentFromExpression(reducerWrapped, resolver)).toMatchObject({ text: 'todos.0', isStatic: true }); + expect(segmentFromExpression(optionalWrapped, resolver)).toMatchObject({ + text: '[todos, 1, true]', + isStatic: true, + }); + expect(segmentFromExpression(memberWrapped, resolver)).toMatchObject({ + text: '[todos, 1, true]', + isStatic: true, + }); + expect(segmentFromExpression(fallbackWrapped)).toMatchObject({ text: '$foo || $localArray' }); + expect(segmentFromExpression(nullishWrapped)).toMatchObject({ text: '$foo' }); + expect(segmentFromExpression(conditionalWrapped)).toMatchObject({ text: 'cond(...)', isStatic: false }); + + expect(normalizeQueryKey(queryCall, { defaultMode: 'exact' }, resolver)).toMatchObject({ + segments: ['prefix', '[todos, 1, true]', '[suffix]'], + }); + + expect(inferHookQueryKeys('useQueries', [collection], resolver)).toEqual([ + expect.objectContaining({ segments: ['posts'] }), + ]); + + expect(inferHookQueryKeys('useQueries', [queryCollection], resolver)).toEqual([ + expect.objectContaining({ id: 'pass-through-query-key', segments: ['$queryKey'] }), + ]); + + expect(isHookCallDirectQueryKeyDeclaration([queryCollection], 'useQueries')).toBe(false); + expect(isHookCallDirectQueryKeyDeclaration([collection], 'useQueries')).toBe(false); + }); + + it('normalizes complex expressions, collection hooks, and predicate actions', () => { + const source = [ + "const root = 'todos';", + "const objectKey = [{ b: undefined, a: 1, ...{ z: 'last' }, [root]: true }];", + 'const tupleKey = [null, 1n, `item-$' + '{root}`, { nested: root }];', + 'const optionalMember = api?.queryKey?.[0];', + "const optionalCall = api?.makeKey('optional');", + "const callbackKey = () => ['callback'] as const;", + 'const emptyCallback = () => {};', + "const memberOptions = rq.queryOptions({ queryKey: ['member'] as const });", + 'const suspenseArgs = {', + ' queries: cond', + " ? [{ queryKey: ['conditional'] as const }]", + " : [{ queryKey: ['alternate'] as const }],", + '};', + "const inlineQueries = [{ queryKey: ['inline'] as const }].flatMap((entry) => [entry]).concat([{ queryKey: ['tail'] as const }]);", + "const predicateOptions = { predicate: (entry) => entry.queryKey[0] === root && 'detail' == entry.queryKey['1'] };", + "const exactPredicateOptions = { exact: true, predicate: (entry) => entry.queryKey[0] === 'exact' };", + 'const passThrough = queryKey;', + ].join('\n'); + + const root = expressionFromSource(source, 'root'); + const objectKey = expressionFromSource(source, 'objectKey'); + const tupleKey = expressionFromSource(source, 'tupleKey'); + const optionalMember = expressionFromSource(source, 'optionalMember'); + const optionalCall = expressionFromSource(source, 'optionalCall'); + const callbackKey = expressionFromSource(source, 'callbackKey'); + const emptyCallback = expressionFromSource(source, 'emptyCallback'); + const memberOptions = expressionFromSource(source, 'memberOptions'); + const suspenseArgs = expressionFromSource(source, 'suspenseArgs'); + const inlineQueries = expressionFromSource(source, 'inlineQueries'); + const predicateOptions = expressionFromSource(source, 'predicateOptions'); + const exactPredicateOptions = expressionFromSource(source, 'exactPredicateOptions'); + const passThrough = expressionFromSource(source, 'passThrough'); + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'root') { + return root; + } + if (node.type === 'Identifier' && node.name === 'queryKey') { + return passThrough; + } + if (node.type === 'Identifier' && node.name === 'makeKey') { + return { + type: 'ArrowFunctionExpression', + params: [identifier('id')], + body: arrayExpression([stringLiteral('made'), identifier('id')]), + generator: false, + async: false, + expression: true, + }; + } + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'createQueryKey') { + return arrayExpression([identifier('first'), identifier('second')]); + } + if (callee.type === 'Identifier' && callee.name === 'positional') { + return arrayExpression([identifier('id'), identifier('kind')]); + } + if (callee.type === 'Identifier' && callee.name === 'makeOptions') { + return objectExpression([objectProperty(identifier('queryKey'), identifier('queryKey'))]); + } + return undefined; + }), + }; + + expect(segmentFromExpression(objectKey, resolver)).toMatchObject({ + text: '[{b: undefined, a: 1, z: last, [todos]: true}]', + isStatic: true, + }); + expect(segmentFromExpression(tupleKey, resolver)).toMatchObject({ + text: '[null, expr, item-$' + '{todos}, {nested: todos}]', + isStatic: false, + }); + expect(segmentFromExpression(optionalMember, resolver)).toMatchObject({ + text: '$api?.queryKey?.[0]', + isStatic: false, + }); + expect(segmentFromExpression(optionalCall, resolver)).toMatchObject({ + text: '$api?.makeKey(optional)', + isStatic: false, + }); + expect(segmentFromExpression(callbackKey, resolver)).toMatchObject({ text: 'callback', isStatic: true }); + expect(segmentFromExpression(emptyCallback, resolver)).toMatchObject({ text: 'expr', isStatic: false }); + expect(normalizeQueryKey(memberOptions, { defaultMode: 'exact' }, resolver)).toMatchObject({ + segments: ['member'], + }); + expect(segmentFromExpression(objectExpression([]), resolver)).toMatchObject({ text: '{}', isStatic: true }); + expect( + segmentFromExpression(objectExpression([objectProperty(identifier('empty'), identifier('undefined'))]), resolver), + ).toMatchObject({ text: '{}', isStatic: true }); + expect( + segmentFromExpression(objectExpression([spreadElement(identifier('dynamicObject'))]), resolver), + ).toMatchObject({ + text: '{...$dynamicObject}', + isStatic: false, + }); + expect( + segmentFromExpression(callExpression(memberExpression(identifier('ids'), identifier('sort')), []), resolver), + ).toMatchObject({ text: '$ids', isStatic: false }); + expect( + normalizeQueryKey( + callExpression(identifier('createQueryKey'), [stringLiteral('one'), stringLiteral('two')]), + { + defaultMode: 'exact', + }, + resolver, + ), + ).toMatchObject({ segments: ['one', 'two'] }); + expect( + normalizeQueryKey( + callExpression(identifier('makeKey'), [stringLiteral('42')]), + { defaultMode: 'exact' }, + resolver, + ), + ).toMatchObject({ segments: ['made', '42'] }); + expect( + normalizeQueryKey( + callExpression(identifier('positional'), [stringLiteral('x'), stringLiteral('y')]), + { + defaultMode: 'exact', + }, + resolver, + ), + ).toMatchObject({ segments: ['x', 'y'] }); + + expect(inferHookQueryKeys('useSuspenseQueries', [suspenseArgs], resolver)).toEqual([ + expect.objectContaining({ segments: ['conditional'] }), + expect.objectContaining({ segments: ['alternate'] }), + ]); + expect(inferHookQueryKeys('useQueries', [inlineQueries], resolver)).toEqual([ + expect.objectContaining({ segments: ['tail'] }), + ]); + expect(inferHookQueryKeys('useQueries', [], resolver)).toEqual([ + expect.objectContaining({ matchMode: 'unknown', source: 'expression' }), + ]); + + expect(inferActionQueryKey('invalidateQueries', [predicateOptions], resolver)).toMatchObject({ + segments: ['todos', 'detail'], + matchMode: 'prefix', + resolution: 'static', + }); + expect(inferActionQueryKey('invalidateQueries', [exactPredicateOptions], resolver)).toMatchObject({ + segments: ['exact'], + matchMode: 'exact', + }); + expect(inferActionQueryKey('setQueryData', [passThrough], resolver)).toMatchObject({ + id: 'pass-through-query-key', + matchMode: 'exact', + }); + expect( + inferActionQueryKey( + 'invalidateQueries', + [callExpression(identifier('makeOptions'), [arrayExpression([stringLiteral('from-call')])])], + resolver, + ), + ).toMatchObject({ segments: ['from-call'] }); + }); + + it('covers action-key fallbacks, non-expression args, and predicate-only options', () => { + const predicateBody: BinaryExpression = { + type: 'BinaryExpression', + operator: '===', + left: memberExpression(memberExpression(identifier('entry'), identifier('queryKey')), numericLiteral(0), true), + right: stringLiteral('todos'), + }; + const resolver: QueryKeyResolver = { + resolveReference: vi.fn(() => undefined), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'arrayFactory') { + return arrayExpression([stringLiteral('call-array')]); + } + if (callee.type === 'Identifier' && callee.name === 'optionsFactory') { + return objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('call-query')])), + objectProperty(identifier('exact'), booleanLiteral(true)), + ]); + } + if (callee.type === 'Identifier' && callee.name === 'predicateFactory') { + return objectExpression([objectProperty(identifier('predicate'), arrowFunction(predicateBody))]); + } + if (callee.type === 'Identifier' && callee.name === 'predicateOnlyFactory') { + return objectExpression([objectProperty(identifier('predicate'), arrowFunction(booleanLiteral(true)))]); + } + return undefined; + }), + }; + + expect(inferActionQueryKey('invalidateQueries', [], resolver)).toMatchObject({ + source: 'wildcard', + matchMode: 'all', + }); + expect(inferActionQueryKey('invalidateQueries', [spreadElement(stringLiteral('oops'))], resolver)).toMatchObject({ + matchMode: 'unknown', + }); + expect(inferActionQueryKey('setQueryData', [callExpression(identifier('arrayFactory'))], resolver)).toMatchObject({ + segments: ['call-array'], + matchMode: 'exact', + }); + expect( + inferActionQueryKey('invalidateQueries', [callExpression(identifier('optionsFactory'))], resolver), + ).toMatchObject({ + segments: ['call-query'], + matchMode: 'exact', + }); + expect( + inferActionQueryKey('invalidateQueries', [callExpression(identifier('predicateFactory'))], resolver), + ).toMatchObject({ + segments: ['todos'], + matchMode: 'prefix', + }); + expect( + inferActionQueryKey('invalidateQueries', [callExpression(identifier('predicateOnlyFactory'))], resolver), + ).toMatchObject({ + source: 'wildcard', + matchMode: 'all', + }); + expect( + inferActionQueryKey( + 'invalidateQueries', + [objectExpression([objectProperty(identifier('predicate'), arrowFunction(booleanLiteral(true)))])], + resolver, + ), + ).toMatchObject({ + source: 'wildcard', + matchMode: 'all', + }); + }); + + it('covers memo-like calls, sparse arguments, and unusual object keys', () => { + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'rest') { + return arrayExpression([stringLiteral('rest-item')]); + } + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'factory') { + return { + type: 'ArrowFunctionExpression', + params: [ + { type: 'Identifier', name: 'first' }, + { type: 'Identifier', name: 'second' }, + ], + body: arrayExpression([stringLiteral('made'), identifier('first'), identifier('second')]), + generator: false, + async: false, + expression: true, + }; + } + return undefined; + }), + }; + + const weirdObject = objectExpression([ + objectProperty(identifier('plain'), stringLiteral('value')), + objectProperty(stringLiteral('quoted'), numericLiteral(7)), + objectProperty(numericLiteral(7), identifier('undefined')), + objectProperty({ type: 'PrivateName', id: identifier('secret') }, stringLiteral('hidden')), + objectProperty(callExpression(identifier('infer'), [stringLiteral('name')]), stringLiteral('expr'), true), + spreadElement(objectExpression([objectProperty(identifier('b'), stringLiteral('bee'))])), + ]); + + const factoryCall = callExpression(identifier('factory'), [stringLiteral('one'), stringLiteral('two')]); + + const sparseArgsCall = callExpression(memberExpression(identifier('api'), identifier('noop')), [ + stringLiteral('one'), + null as unknown as Expression, + spreadElement(identifier('rest')), + ]); + + const memoCall = callExpression(identifier('useMemo'), [ + arrowFunction(arrayExpression([stringLiteral('memo')])), + arrayExpression([]), + ]); + + const callbackCall = callExpression(identifier('useCallback'), [ + arrowFunction(arrayExpression([stringLiteral('callback')])), + arrayExpression([]), + ]); + + const joinedCall = callExpression(memberExpression(identifier('items'), identifier('join')), [stringLiteral(',')]); + + const weirdObjectSegment = segmentFromExpression(weirdObject, resolver); + expect(weirdObjectSegment.text).toContain('plain: value'); + expect(weirdObjectSegment.text).toContain('quoted: 7'); + expect(weirdObjectSegment.text).toContain('#secret: hidden'); + expect(weirdObjectSegment.text).toContain('[call(infer)]: UNRESOLVED'); + expect(weirdObjectSegment.text).toContain('b: bee'); + + expect(segmentFromExpression(factoryCall, resolver)).toMatchObject({ + text: 'made', + isStatic: true, + }); + const sparseArgsSegment = segmentFromExpression(sparseArgsCall, resolver); + expect(sparseArgsSegment.text).toContain('one'); + expect(sparseArgsSegment.text).toContain('UNRESOLVED'); + expect(sparseArgsSegment.text).toContain('rest-item'); + expect(segmentFromExpression(memoCall, resolver)).toMatchObject({ + text: '[memo]', + isStatic: true, + }); + expect(segmentFromExpression(callbackCall, resolver)).toMatchObject({ + text: '[callback]', + isStatic: true, + }); + expect(segmentFromExpression(joinedCall, resolver)).toMatchObject({ + text: '$items', + isStatic: false, + }); + }); + + it('handles cache pass-throughs, spread keys, and direct collection declarations', () => { + const source = [ + "const extra = ['b', 'c'] as const;", + "const spreadKey = ['a', ...extra] as const;", + 'const emptyKey = [] as const;', + 'const queryCacheLookup = queryCache.find({ queryKey: spreadKey });', + 'const nestedCacheLookup = client.getQueryCache().get({ queryKey: spreadKey });', + "const inlineCollection = cond ? [{ queryKey: ['conditional-direct'] as const }] : [{ queryKey: ['alternate-direct'] as const }];", + "const logicalCollection = maybe && [{ queryKey: ['logical-direct'] as const }];", + 'const wrappedInlineCollection = { queries: inlineCollection };', + 'const wrappedLogicalCollection = { queries: logicalCollection };', + 'const opaqueCollection = [{ queryKey }];', + "const predicateWithoutPrefix = { predicate: (entry) => entry.queryKey[1] === 'detail' };", + "const predicateWithUnary = { predicate: (entry) => !(entry.queryKey[0] !== 'negated') };", + 'const collectionWithoutMapper = inlineCollection.map().filter(Boolean);', + 'const optionalCollectionCall = inlineCollection?.slice?.(0);', + "const optionalMemberCall = ids?.join?.(',');", + "const computedMemberCall = api[method]('x');", + 'const holeKey = [undefined, , ...extra] as const;', + ].join('\n'); + + const extra = expressionFromSource(source, 'extra'); + const spreadKey = expressionFromSource(source, 'spreadKey'); + const emptyKey = expressionFromSource(source, 'emptyKey'); + const queryCacheLookup = expressionFromSource(source, 'queryCacheLookup'); + const nestedCacheLookup = expressionFromSource(source, 'nestedCacheLookup'); + const inlineCollection = expressionFromSource(source, 'inlineCollection'); + const logicalCollection = expressionFromSource(source, 'logicalCollection'); + const wrappedInlineCollection = expressionFromSource(source, 'wrappedInlineCollection'); + const wrappedLogicalCollection = expressionFromSource(source, 'wrappedLogicalCollection'); + const opaqueCollection = expressionFromSource(source, 'opaqueCollection'); + const predicateWithoutPrefix = expressionFromSource(source, 'predicateWithoutPrefix'); + const predicateWithUnary = expressionFromSource(source, 'predicateWithUnary'); + const collectionWithoutMapper = expressionFromSource(source, 'collectionWithoutMapper'); + const optionalCollectionCall = expressionFromSource(source, 'optionalCollectionCall'); + const optionalMemberCall = expressionFromSource(source, 'optionalMemberCall'); + const computedMemberCall = expressionFromSource(source, 'computedMemberCall'); + const holeKey = expressionFromSource(source, 'holeKey'); + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'extra') { + return extra; + } + if (node.type === 'Identifier' && node.name === 'spreadKey') { + return spreadKey; + } + if (node.type === 'Identifier' && node.name === 'inlineCollection') { + return inlineCollection; + } + if (node.type === 'Identifier' && node.name === 'queryCacheLookup') { + return queryCacheLookup; + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + + expect(normalizeQueryKey(spreadKey, { defaultMode: 'exact' }, resolver)).toMatchObject({ + segments: ['a', 'b', 'c'], + resolution: 'static', + }); + expect(normalizeQueryKey(emptyKey, { defaultMode: 'exact' }, resolver)).toMatchObject({ + id: 'empty', + segments: [], + }); + expect(normalizeQueryKey(holeKey, { defaultMode: 'prefix' }, resolver)).toMatchObject({ + segments: ['undefined', 'UNRESOLVED', 'b', 'c'], + }); + + expect(inferActionQueryKey('setQueryData', [queryCacheLookup], resolver)).toMatchObject({ + segments: ['a', 'b', 'c'], + matchMode: 'exact', + }); + expect( + inferActionQueryKey( + 'invalidateQueries', + [objectExpression([objectProperty(identifier('queryKey'), nestedCacheLookup)])], + resolver, + ), + ).toMatchObject({ + id: 'pass-through-query-key', + matchMode: 'prefix', + }); + expect(inferActionQueryKey('invalidateQueries', [predicateWithoutPrefix], resolver)).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + expect(inferActionQueryKey('invalidateQueries', [predicateWithUnary], resolver)).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + + expect(inferHookQueryKeys('useQueries', [inlineCollection], resolver)).toEqual([ + expect.objectContaining({ segments: ['cond(...)'] }), + ]); + expect(inferHookQueryKeys('useQueries', [wrappedInlineCollection], resolver)).toEqual([ + expect.objectContaining({ segments: ['conditional-direct'] }), + expect.objectContaining({ segments: ['alternate-direct'] }), + ]); + expect(inferHookQueryKeys('useQueries', [wrappedLogicalCollection], resolver)).toEqual([ + expect.objectContaining({ id: 'pass-through-query-key' }), + ]); + expect(inferHookQueryKeys('useQueries', [opaqueCollection], resolver)).toEqual([ + expect.objectContaining({ segments: ['UNRESOLVED'] }), + ]); + expect(inferHookQueryKeys('useQueries', [collectionWithoutMapper], resolver)).toEqual([ + expect.objectContaining({ segments: ['cond(...)'] }), + ]); + expect(isHookCallDirectQueryKeyDeclaration([inlineCollection], 'useQueries')).toBe(false); + expect(isHookCallDirectQueryKeyDeclaration([logicalCollection], 'useQueries')).toBe(false); + expect(segmentFromExpression(optionalCollectionCall, resolver)).toMatchObject({ + text: 'cond(...)', + isStatic: false, + }); + expect(segmentFromExpression(optionalMemberCall, resolver)).toMatchObject({ + text: '$ids', + isStatic: false, + }); + expect(segmentFromExpression(computedMemberCall, resolver)).toMatchObject({ + text: '$api[method](x)', + isStatic: false, + }); + }); + + it('normalizes edge expression shapes used inside query keys', () => { + const source = [ + 'const objectSpread = [{ ...{ b: 2 }, a: 1 }];', + "const objectMethod = [{ method() { return 1; }, value: 'x' }];", + "const computedObject = [{ ['dyn' + 'Key']: 'value' }];", + "const bigKeyObject = [{ [1n]: 'big' }];", + "const identityArray = identity(['wrapped'] as const);", + "const identityObject = identity({ queryKey: ['object-query'] as const });", + "const memberQueryKey = ['member'] as const;", + 'const memberObject = { queryKey: memberQueryKey };', + 'const memberExpressionKey = memberObject.queryKey;', + "const valueRef = shallowRef(['ref-value'] as const);", + 'const refValue = valueRef.value;', + "const fallbackString = maybe || '';", + 'const fallbackTemplate = maybe ?? ``;', + 'const fallbackObject = maybe || {};', + 'const fallbackArray = maybe ?? [];', + 'const positionalShort = positionalOnly(first);', + 'const queryKeyCall = queryKey(first);', + 'const queryKeysCall = queryKeys(first);', + 'const emptyMemo = useMemo();', + 'const callbackLiteral = useCallback(() => ["callback-literal"] as const, []);', + ].join('\n'); + + const objectSpread = expressionFromSource(source, 'objectSpread'); + const objectMethod = expressionFromSource(source, 'objectMethod'); + const computedObject = expressionFromSource(source, 'computedObject'); + const bigKeyObject = expressionFromSource(source, 'bigKeyObject'); + const identityArray = expressionFromSource(source, 'identityArray'); + const identityObject = expressionFromSource(source, 'identityObject'); + const memberExpressionKey = expressionFromSource(source, 'memberExpressionKey'); + const memberObject = expressionFromSource(source, 'memberObject'); + const memberQueryKey = expressionFromSource(source, 'memberQueryKey'); + const valueRef = expressionFromSource(source, 'valueRef'); + const refValue = expressionFromSource(source, 'refValue'); + const fallbackString = expressionFromSource(source, 'fallbackString'); + const fallbackTemplate = expressionFromSource(source, 'fallbackTemplate'); + const fallbackObject = expressionFromSource(source, 'fallbackObject'); + const fallbackArray = expressionFromSource(source, 'fallbackArray'); + const positionalShort = expressionFromSource(source, 'positionalShort'); + const queryKeyCall = expressionFromSource(source, 'queryKeyCall'); + const queryKeysCall = expressionFromSource(source, 'queryKeysCall'); + const emptyMemo = expressionFromSource(source, 'emptyMemo'); + const callbackLiteral = expressionFromSource(source, 'callbackLiteral'); + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'memberQueryKey') { + return memberQueryKey; + } + if (node.type === 'Identifier' && node.name === 'memberObject') { + return memberObject; + } + if (node.type === 'Identifier' && node.name === 'valueRef') { + return valueRef; + } + if (node.type === 'Identifier' && node.name === 'identity') { + return arrayExpression([stringLiteral('identity-callee')]); + } + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'positionalOnly') { + return arrayExpression([identifier('first'), identifier('missing')]); + } + return undefined; + }), + }; + + expect(segmentFromExpression(objectSpread, resolver)).toMatchObject({ + text: '[{b: 2, a: 1}]', + isStatic: true, + }); + expect(segmentFromExpression(objectMethod, resolver)).toMatchObject({ + text: '[{[method], value: x}]', + isStatic: false, + }); + expect(segmentFromExpression(computedObject, resolver)).toMatchObject({ + text: '[{[dyn + Key]: value}]', + isStatic: true, + }); + expect(segmentFromExpression(bigKeyObject, resolver)).toMatchObject({ + text: '[{[UNRESOLVED]: big}]', + isStatic: false, + }); + expect(normalizeQueryKey(identityArray, { defaultMode: 'exact' }, resolver)).toMatchObject({ + segments: ['wrapped'], + }); + expect(normalizeQueryKey(identityObject, { defaultMode: 'exact' }, resolver)).toMatchObject({ + segments: ['object-query'], + }); + expect(normalizeQueryKey(memberExpressionKey, { defaultMode: 'exact' }, resolver)).toMatchObject({ + segments: ['member'], + }); + expect(segmentFromExpression(refValue, resolver)).toMatchObject({ text: '[ref-value].value', isStatic: true }); + expect(segmentFromExpression(fallbackString, resolver)).toMatchObject({ text: '$maybe', isStatic: false }); + expect(segmentFromExpression(fallbackTemplate, resolver)).toMatchObject({ text: '$maybe', isStatic: false }); + expect(segmentFromExpression(fallbackObject, resolver)).toMatchObject({ text: '$maybe', isStatic: false }); + expect(segmentFromExpression(fallbackArray, resolver)).toMatchObject({ text: '$maybe', isStatic: false }); + expect(normalizeQueryKey(positionalShort, { defaultMode: 'exact' }, resolver)).toMatchObject({ + segments: ['$first', '$missing'], + }); + expect(segmentFromExpression(queryKeyCall, resolver)).toMatchObject({ text: 'call(queryKey)', isStatic: false }); + expect(segmentFromExpression(queryKeysCall, resolver)).toMatchObject({ text: 'call(queryKeys)', isStatic: false }); + expect(segmentFromExpression(emptyMemo, resolver)).toMatchObject({ text: 'call(useMemo)', isStatic: false }); + expect(segmentFromExpression(callbackLiteral, resolver)).toMatchObject({ + text: '[callback-literal]', + isStatic: true, + }); + }); + + it('applies function argument hints across complex return shapes and predicate positions', () => { + const source = [ + "const suffix = 'detail';", + 'const complexFactory = (seed, key, keep) => [...seed, { nested: key }.nested, `' + + '$' + + "{key}`, !keep, key || [], keep ? key : 'off', (key, 'tail')] as const;", + "const complexCall = complexFactory(['seed'] as const, 'dynamic', false);", + 'const objectArgFactory = ({ queryKey, exact }: { queryKey: readonly unknown[]; exact: boolean }) => ({ queryKey: [queryKey, exact] as const });', + "const objectArgCall = objectArgFactory({ queryKey: ['object-arg'] as const, exact: true });", + "const mapper = (entry) => queryOptions({ queryKey: ['mapped', entry.id] as const });", + "const mappedCollection = [{ queryKey: ['source'] as const, id: 'a' }].map(mapper);", + "const flatMapper = (entry) => [{ queryKey: ['flat-mapped', entry.id] as const }];", + "const flatMappedCollection = [{ queryKey: ['source'] as const, id: 'b' }].flatMap(flatMapper);", + "const rightPredicate = { predicate: (entry) => 'right' === entry.queryKey[0] && entry.queryKey['1'] == suffix };", + 'const bothSidesPredicate = { predicate: (entry) => entry.queryKey[0] === entry.queryKey[1] };', + ].join('\n'); + + const suffix = expressionFromSource(source, 'suffix'); + const complexFactory = expressionFromSource(source, 'complexFactory'); + const complexCall = expressionFromSource(source, 'complexCall'); + const objectArgFactory = expressionFromSource(source, 'objectArgFactory'); + const objectArgCall = expressionFromSource(source, 'objectArgCall'); + const mapper = expressionFromSource(source, 'mapper'); + const mappedCollection = expressionFromSource(source, 'mappedCollection'); + const flatMapper = expressionFromSource(source, 'flatMapper'); + const flatMappedCollection = expressionFromSource(source, 'flatMappedCollection'); + const rightPredicate = expressionFromSource(source, 'rightPredicate'); + const bothSidesPredicate = expressionFromSource(source, 'bothSidesPredicate'); + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'complexFactory') { + return complexFactory; + } + if (node.type === 'Identifier' && node.name === 'objectArgFactory') { + return objectArgFactory; + } + if (node.type === 'Identifier' && node.name === 'mapper') { + return mapper; + } + if (node.type === 'Identifier' && node.name === 'flatMapper') { + return flatMapper; + } + if (node.type === 'Identifier' && node.name === 'suffix') { + return suffix; + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + + expect(normalizeQueryKey(complexCall, { defaultMode: 'exact' }, resolver)).toMatchObject({ + segments: ['seed', 'dynamic', '$' + '{dynamic}', '!false', 'dynamic', 'cond(...)', 'UNRESOLVED'], + }); + expect(normalizeQueryKey(objectArgCall, { defaultMode: 'exact' }, resolver)).toMatchObject({ + segments: ['UNRESOLVED', '$exact'], + }); + expect(inferHookQueryKeys('useQueries', [mappedCollection], resolver)).toEqual([ + expect.objectContaining({ segments: ['[[source]]'] }), + ]); + expect(inferHookQueryKeys('useQueries', [flatMappedCollection], resolver)).toEqual([ + expect.objectContaining({ segments: ['[[source]]'] }), + ]); + expect(inferActionQueryKey('invalidateQueries', [rightPredicate], resolver)).toMatchObject({ + segments: ['right', 'detail'], + }); + expect(inferActionQueryKey('invalidateQueries', [bothSidesPredicate], resolver)).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + }); + + it('normalizes unresolved call and member expression displays', () => { + const source = [ + "const arrayArgCall = passthrough(['array-arg'] as const);", + "const callArgSpread = invoke('head', ...tail, ['last'] as const);", + "const memberCall = api.run('x', ...tail);", + "const computedOptionalCall = api?.[method]?.('x');", + 'const optionalIdentifierCall = maybeCall?.();', + 'const optionalExpressionCall = (cond ? left : right)?.();', + 'const unknownOptionalMember = target?.[method];', + 'const unknownMember = target[method];', + 'const nonCollectionMethod = ids.reduce(joiner, seed);', + ].join('\n'); + + const arrayArgCall = expressionFromSource(source, 'arrayArgCall'); + const callArgSpread = expressionFromSource(source, 'callArgSpread'); + const memberCall = expressionFromSource(source, 'memberCall'); + const computedOptionalCall = expressionFromSource(source, 'computedOptionalCall'); + const optionalIdentifierCall = expressionFromSource(source, 'optionalIdentifierCall'); + const optionalExpressionCall = expressionFromSource(source, 'optionalExpressionCall'); + const unknownOptionalMember = expressionFromSource(source, 'unknownOptionalMember'); + const unknownMember = expressionFromSource(source, 'unknownMember'); + const nonCollectionMethod = expressionFromSource(source, 'nonCollectionMethod'); + const resolver: QueryKeyResolver = { + resolveReference: vi.fn(() => undefined), + resolveCallResult: vi.fn(() => undefined), + }; + + expect(segmentFromExpression(arrayArgCall, resolver)).toMatchObject({ text: '[array-arg]', isStatic: true }); + expect(segmentFromExpression(callArgSpread)).toMatchObject({ + text: 'call(invoke)', + isStatic: false, + }); + expect(segmentFromExpression(callArgSpread, undefined, 24)).toMatchObject({ + text: 'call(invoke)', + isStatic: false, + }); + expect(segmentFromExpression(memberCall)).toMatchObject({ text: '$api.run(x, ...$tail)', isStatic: false }); + expect(segmentFromExpression(computedOptionalCall)).toMatchObject({ + text: '$api?.[method](x)', + isStatic: false, + }); + expect(segmentFromExpression(optionalIdentifierCall)).toMatchObject({ + text: 'call(maybeCall)', + isStatic: false, + }); + expect(segmentFromExpression(optionalExpressionCall)).toMatchObject({ text: 'call(expr)', isStatic: false }); + expect(segmentFromExpression(unknownOptionalMember)).toMatchObject({ + text: '$target?.[method]', + isStatic: false, + }); + expect(segmentFromExpression(unknownMember)).toMatchObject({ text: '$target.method', isStatic: false }); + expect(segmentFromExpression(nonCollectionMethod)).toMatchObject({ + text: '$ids.reduce($joiner, $seed)', + isStatic: false, + }); + }); + + it('falls back for unresolved collection and action query objects', () => { + const source = [ + 'const emptyQueries = { queries: [] };', + 'const holeQueries = { queries: [, { queryKey: queryKey }] };', + 'const dynamicQueries = unknownQueries;', + "const actionWithoutKey = { predicate: (entry) => entry.type === 'query' };", + "const actionWithKey = { queryKey: queryCache.find({ queryKey: ['cached'] as const }), exact: true };", + 'const directObjectWithoutKey = { stale: true };', + 'const directPredicateWithoutMatch = { predicate: () => false };', + 'const directNonObject = maybeKey;', + ].join('\n'); + + const emptyQueries = expressionFromSource(source, 'emptyQueries'); + const holeQueries = expressionFromSource(source, 'holeQueries'); + const dynamicQueries = expressionFromSource(source, 'dynamicQueries'); + const actionWithoutKey = expressionFromSource(source, 'actionWithoutKey'); + const actionWithKey = expressionFromSource(source, 'actionWithKey'); + const directObjectWithoutKey = expressionFromSource(source, 'directObjectWithoutKey'); + const directPredicateWithoutMatch = expressionFromSource(source, 'directPredicateWithoutMatch'); + const directNonObject = expressionFromSource(source, 'directNonObject'); + + expect(inferHookQueryKeys('useQuery', [dynamicQueries])).toEqual([ + expect.objectContaining({ segments: ['$unknownQueries'], matchMode: 'exact' }), + ]); + expect(inferHookQueryKeys('useQueries', [emptyQueries])).toEqual([ + expect.objectContaining({ id: 'pass-through-query-key', segments: ['$queryKey'] }), + ]); + expect(inferHookQueryKeys('useQueries', [holeQueries])).toEqual([ + expect.objectContaining({ id: 'pass-through-query-key', segments: ['$queryKey'] }), + ]); + expect(inferActionQueryKey('invalidateQueries', [actionWithoutKey])).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + expect(inferActionQueryKey('invalidateQueries', [actionWithKey])).toMatchObject({ + id: 'pass-through-query-key', + matchMode: 'exact', + }); + expect(inferActionQueryKey('invalidateQueries', [directObjectWithoutKey])).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + expect(inferActionQueryKey('invalidateQueries', [directPredicateWithoutMatch])).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + expect(inferActionQueryKey('invalidateQueries', [directNonObject])).toMatchObject({ + segments: ['$maybeKey'], + matchMode: 'prefix', + }); + }); + + it('covers manual AST edge cases and pass-through setQueryData detection', () => { + const resolver: QueryKeyResolver = { + resolveReference: vi.fn(() => undefined), + resolveCallResult: vi.fn(() => undefined), + }; + + const edgeObject = objectExpression([ + null as never, + { type: 'SpreadElement', argument: null as never } as never, + { + type: 'Property', + kind: 'init', + key: identifier('defined'), + value: identifier('undefined'), + method: false, + shorthand: false, + computed: false, + optional: false, + }, + { + type: 'ObjectMethod', + } as never, + ]); + + expect(segmentFromExpression(edgeObject, resolver)).toMatchObject({ + text: '{...UNRESOLVED, defined: undefined, [method]}', + isStatic: false, + }); + expect(segmentFromExpression(callExpression(identifier('invoke'), [null as never]), resolver)).toMatchObject({ + text: 'call(invoke)', + isStatic: false, + }); + expect( + segmentFromExpression(callExpression(identifier('invoke'), [spreadElement(stringLiteral('tail'))]), resolver), + ).toMatchObject({ + text: 'call(invoke)', + isStatic: false, + }); + expect(segmentFromExpression(callExpression(identifier('useMemo'), []), resolver)).toMatchObject({ + text: 'call(useMemo)', + isStatic: false, + }); + expect( + segmentFromExpression(callExpression(identifier('useMemo'), [stringLiteral('value')]), resolver), + ).toMatchObject({ + text: 'value', + isStatic: true, + }); + expect(inferActionQueryKey('invalidateQueries', [null as never])).toMatchObject({ + matchMode: 'unknown', + source: 'expression', + }); + expect(inferActionQueryKey('setQueryData', [identifier('queryKey')], resolver)).toMatchObject({ + id: 'pass-through-query-key', + matchMode: 'exact', + }); + }); + + it('detects inline query key declarations in nested collection shapes', () => { + const source = [ + "const nestedObject = { queries: [{ queryKey: ['nested'] as const }] };", + "const conditionalCollection = cond ? [] : [{ queryKey: ['alternate'] as const }];", + "const logicalCollection = maybe || [{ queryKey: ['logical'] as const }];", + "const wrappedConditionalCollection = { queries: cond ? [] : [{ queryKey: ['alternate'] as const }] };", + "const wrappedLogicalCollection = { queries: maybe || [{ queryKey: ['logical'] as const }] };", + 'const emptyCollection = [null, ...items];', + 'const wrappedEmptyCollection = { queries: emptyCollection };', + ].join('\n'); + + const nestedObject = expressionFromSource(source, 'nestedObject'); + const conditionalCollection = expressionFromSource(source, 'conditionalCollection'); + const logicalCollection = expressionFromSource(source, 'logicalCollection'); + const wrappedConditionalCollection = expressionFromSource(source, 'wrappedConditionalCollection'); + const wrappedLogicalCollection = expressionFromSource(source, 'wrappedLogicalCollection'); + const emptyCollection = expressionFromSource(source, 'emptyCollection'); + const wrappedEmptyCollection = expressionFromSource(source, 'wrappedEmptyCollection'); + + expect(isHookCallDirectQueryKeyDeclaration([nestedObject], 'useQueries')).toBe(true); + expect(isHookCallDirectQueryKeyDeclaration([conditionalCollection], 'useQueries')).toBe(false); + expect(isHookCallDirectQueryKeyDeclaration([logicalCollection], 'useQueries')).toBe(false); + expect(isHookCallDirectQueryKeyDeclaration([wrappedConditionalCollection], 'useQueries')).toBe(true); + expect(isHookCallDirectQueryKeyDeclaration([wrappedLogicalCollection], 'useQueries')).toBe(true); + expect(isHookCallDirectQueryKeyDeclaration([emptyCollection], 'useQueries')).toBe(false); + expect(isHookCallDirectQueryKeyDeclaration([wrappedEmptyCollection], 'useQueries')).toBe(false); + expect( + isOpaqueCollectionQueryKey({ + id: 'blank', + display: 'blank', + segments: [''], + matchMode: 'exact', + resolution: 'dynamic', + source: 'expression', + }), + ).toBe(true); + expect( + isOpaqueCollectionQueryKey({ + id: 'empty', + display: '[]', + segments: [], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }), + ).toBe(false); + }); + + it('covers unresolved hooks, ref members, and predicate index edge cases', () => { + const source = [ + 'const stateNoArg = useState();', + 'const reducerNoArg = useReducer();', + "const directRefValue = ref(['direct-ref'] as const).value;", + "const directShallowValue = shallowRef(['direct-shallow'] as const).value;", + "const optionalRefValue = shallowRef(['optional-ref'] as const)?.value;", + "const stateElement = useState(['state-value'] as const)[0];", + "const memoAlias = useMemo(() => ['memo-value'] as const, []);", + "const memberCallAlias = api.makeKey('member');", + "const optionalMemberCall = api?.makeKey?.('optional');", + "const createShort = createQueryKey('one');", + "const predicateExpression = entry.queryKey[0] === 'direct' && entry.queryKey[index] === 'dynamic';", + "const predicateResolvedIndex = { predicate: (entry) => entry.queryKey[position] === 'resolved' };", + "const predicateNegativeIndex = { predicate: (entry) => entry.queryKey[-1] === 'negative' };", + 'const predicateBothIndexes = { predicate: (entry) => entry.queryKey[0] === entry.queryKey[1] };', + "const predicateExact = { exact: true, predicate: (entry) => entry.queryKey[0] === 'exact-edge' };", + ].join('\n'); + + const stateNoArg = expressionFromSource(source, 'stateNoArg'); + const reducerNoArg = expressionFromSource(source, 'reducerNoArg'); + const directRefValue = expressionFromSource(source, 'directRefValue'); + const directShallowValue = expressionFromSource(source, 'directShallowValue'); + const optionalRefValue = expressionFromSource(source, 'optionalRefValue'); + const stateElement = expressionFromSource(source, 'stateElement'); + const memoAlias = expressionFromSource(source, 'memoAlias'); + const memberCallAlias = expressionFromSource(source, 'memberCallAlias'); + const optionalMemberCall = expressionFromSource(source, 'optionalMemberCall'); + const createShort = expressionFromSource(source, 'createShort'); + const predicateExpression = expressionFromSource(source, 'predicateExpression'); + const predicateResolvedIndex = expressionFromSource(source, 'predicateResolvedIndex'); + const predicateNegativeIndex = expressionFromSource(source, 'predicateNegativeIndex'); + const predicateBothIndexes = expressionFromSource(source, 'predicateBothIndexes'); + const predicateExact = expressionFromSource(source, 'predicateExact'); + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'memoAlias') { + return memoAlias; + } + if (node.type === 'Identifier' && node.name === 'memberCallAlias') { + return memberCallAlias; + } + if (node.type === 'Identifier' && node.name === 'position') { + return numericLiteral(0); + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + + expect(segmentFromExpression(stateNoArg, resolver)).toMatchObject({ text: 'UNRESOLVED', isStatic: false }); + expect(segmentFromExpression(reducerNoArg, resolver)).toMatchObject({ text: 'UNRESOLVED', isStatic: false }); + expect(segmentFromExpression(directRefValue, resolver)).toMatchObject({ + text: '[direct-ref].value', + isStatic: true, + }); + expect(segmentFromExpression(directShallowValue, resolver)).toMatchObject({ + text: '[direct-shallow].value', + isStatic: true, + }); + expect(segmentFromExpression(optionalRefValue, resolver)).toMatchObject({ + text: '[optional-ref]?.value', + isStatic: true, + }); + expect(segmentFromExpression(stateElement, resolver)).toMatchObject({ text: '[state-value].0', isStatic: true }); + expect(segmentFromExpression(identifier('memoAlias'), resolver)).toMatchObject({ text: '$memoAlias' }); + expect(segmentFromExpression(identifier('memberCallAlias'), resolver)).toMatchObject({ text: '$memberCallAlias' }); + expect(segmentFromExpression(optionalMemberCall, resolver)).toMatchObject({ + text: '$api?.makeKey(optional)', + isStatic: false, + }); + expect(normalizeQueryKey(createShort, { defaultMode: 'exact' }, resolver)).toMatchObject({ + segments: ['one', 'UNRESOLVED', 'UNRESOLVED'], + }); + expect( + inferActionQueryKey( + 'invalidateQueries', + [objectExpression([objectProperty(identifier('predicate'), predicateExpression)])], + resolver, + ), + ).toMatchObject({ + segments: ['direct'], + matchMode: 'prefix', + }); + expect(inferActionQueryKey('invalidateQueries', [predicateResolvedIndex], resolver)).toMatchObject({ + segments: ['resolved'], + matchMode: 'prefix', + }); + expect(inferActionQueryKey('invalidateQueries', [predicateNegativeIndex], resolver)).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + expect(inferActionQueryKey('invalidateQueries', [predicateBothIndexes], resolver)).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + expect(inferActionQueryKey('invalidateQueries', [predicateExact], resolver)).toMatchObject({ + segments: ['exact-edge'], + matchMode: 'exact', + }); + }); + + it('covers wrapper calls, canonical objects, collections, and predicate inference edges', () => { + const source = [ + "const suffix = 'suffix' as const;", + "const spreadSource = { queryKey: ['spread-source'] as const, nested: { queryKey: ['nested'] as const } };", + "const canonicalObject = { beta: ['b'] as const, alpha: ['a'] as const, gamma: undefined };", + "const spreadObject = { ...spreadSource, queryKey: ['spread-target'] as const, method() { return 1; } };", + "const options = queryOptions({ queryKey: ['opts'] as const });", + "const memoResult = useMemo(() => ['memo-result'] as const, []);", + "const callbackResult = useCallback(() => ['callback-result'] as const, []);", + "const wrappedArray = identity(['wrapped-array'] as const);", + 'const factoryArrayCall = factoryArray();', + 'const factoryObjectCall = factoryObject();', + "const directQueries = { queries: [{ queryKey: ['direct'] as const }] };", + "const collection = { queries: [options, queryOptions({ queryKey: ['list'] as const })].map((entry) => entry).flatMap((entry) => [entry]).concat([{ queryKey: ['concat'] as const }]) };", + "const predicate = (entry) => entry.queryKey[0] === 'alpha' && entry.queryKey[1] === suffix;", + 'const actionOptions = { exact: true, predicate };', + ].join('\n'); + + const canonicalObject = expressionFromSource(source, 'canonicalObject'); + const spreadObject = expressionFromSource(source, 'spreadObject'); + const options = expressionFromSource(source, 'options'); + const memoResult = expressionFromSource(source, 'memoResult'); + const callbackResult = expressionFromSource(source, 'callbackResult'); + const wrappedArray = expressionFromSource(source, 'wrappedArray'); + const factoryArrayCall = expressionFromSource(source, 'factoryArrayCall'); + const factoryObjectCall = expressionFromSource(source, 'factoryObjectCall'); + const directQueries = expressionFromSource(source, 'directQueries'); + const collection = expressionFromSource(source, 'collection'); + const predicate = expressionFromSource(source, 'predicate'); + const actionOptions = expressionFromSource(source, 'actionOptions'); + + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'spreadSource') { + return objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('spread-source')])), + objectProperty( + identifier('nested'), + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('nested')]))]), + ), + ]); + } + + if (node.type === 'Identifier' && node.name === 'suffix') { + return stringLiteral('suffix'); + } + + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'factoryArray') { + return arrayExpression([stringLiteral('factory-first'), stringLiteral('factory-second')]); + } + + if (callee.type === 'Identifier' && callee.name === 'factoryObject') { + return objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('factory-object')])), + objectProperty(identifier('other'), stringLiteral('value')), + ]); + } + + return undefined; + }), + }; + + expect(segmentFromExpression(canonicalObject, resolver)).toEqual({ + text: '{alpha: [a], beta: [b]}', + isStatic: true, + }); + expect(segmentFromExpression(spreadObject, resolver)).toMatchObject({ + isStatic: true, + text: expect.stringContaining('spread-target'), + }); + expect(segmentFromExpression(options, resolver)).toMatchObject({ + text: '[opts]', + isStatic: true, + }); + expect(segmentFromExpression(memoResult, resolver)).toMatchObject({ + text: '[memo-result]', + isStatic: true, + }); + expect(segmentFromExpression(callbackResult, resolver)).toMatchObject({ + text: '[callback-result]', + isStatic: true, + }); + expect(segmentFromExpression(wrappedArray, resolver)).toMatchObject({ + text: '[wrapped-array]', + isStatic: true, + }); + expect(segmentFromExpression(factoryArrayCall, resolver)).toMatchObject({ + text: '[factory-first, factory-second]', + isStatic: true, + }); + expect(segmentFromExpression(factoryObjectCall, resolver)).toMatchObject({ + isStatic: true, + }); + expect( + resolveQueryKeyExpression(memberExpression(identifier('spreadSource'), identifier('queryKey')), resolver), + ).toEqual(arrayExpression([stringLiteral('spread-source')])); + expect(inferHookQueryKeys('useQueries', [options], resolver).map((entry) => entry.segments.join('/'))).toEqual([ + 'opts', + ]); + expect(inferHookQueryKeys('useQueries', [collection], resolver).map((entry) => entry.segments.join('/'))).toContain( + 'concat', + ); + expect(isHookCallDirectQueryKeyDeclaration([directQueries], 'useQueries')).toBe(true); + expect(isHookCallDirectQueryKeyDeclaration([canonicalObject], 'useQuery')).toBe(false); + expect(inferActionQueryKey('invalidateQueries', [actionOptions], resolver)).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + expect(inferActionQueryKey('invalidateQueries', [predicate], resolver)).toMatchObject({ + matchMode: 'prefix', + source: 'expression', + }); + expect(normalizeQueryKey(undefined, { wildcardIfMissing: true }, resolver)).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + }); + + it('covers object spreads, function hints, positional hints, and pass-through action keys', () => { + const source = [ + "const spreadSource = { queryKey: ['spread'] as const, nested: { queryKey: ['spread-nested'] as const } };", + 'const spreadWrapper = { ...spreadSource };', + "const overrideWrapper = { ...spreadSource, queryKey: ['override'] as const };", + "const tupleFactory = (first: readonly unknown[], second = ['second-default'] as const) => [first, second, first] as const;", + "const positionalFactory = createQueryKey('prefix', ['middle'] as const, ['suffix'] as const);", + "const optionsFactoryCall = optionsFactory({ ...spreadSource, queryKey: ['object'] as const, nested: { queryKey: ['nested-call'] as const }, suffix: 'tail' });", + "const tupleFactoryCall = tupleFactory(['tuple'] as const, ['pair'] as const);", + 'const identityWrapped = queryOptions({ queryKey: overrideWrapper, exact: true });', + 'const nestedIdentityWrapped = infiniteQueryOptions({ queryKey: spreadWrapper.nested });', + 'const optionalMember = overrideWrapper?.queryKey;', + 'const optionalNestedMember = spreadWrapper?.nested?.queryKey;', + "const optionalCall = tupleFactory?.(['optional'] as const, ['call'] as const);", + 'const directQueryKey = queryKey;', + 'const queryCacheLookup = queryCache.get(queryKey);', + "const predicateAction = { predicate: (entry) => !entry.queryKey[0] && entry.queryKey[0] === 'exact' && entry.queryKey[1] == 'second' };", + 'const invalidateAction = { queryKey: queryCacheLookup, exact: true };', + 'const exactAction = { queryKey: overrideWrapper.queryKey, exact: true };', + "const wildcardAction = { predicate: (entry) => entry.queryKey[0] === 'wild' };", + ].join('\n'); + + const spreadSource = expressionFromSource(source, 'spreadSource'); + const spreadWrapper = expressionFromSource(source, 'spreadWrapper'); + const overrideWrapper = expressionFromSource(source, 'overrideWrapper'); + const tupleFactory = expressionFromSource(source, 'tupleFactory'); + const positionalFactory = expressionFromSource(source, 'positionalFactory'); + const optionsFactoryCall = expressionFromSource(source, 'optionsFactoryCall'); + const tupleFactoryCall = expressionFromSource(source, 'tupleFactoryCall'); + const identityWrapped = expressionFromSource(source, 'identityWrapped'); + const nestedIdentityWrapped = expressionFromSource(source, 'nestedIdentityWrapped'); + const optionalMember = expressionFromSource(source, 'optionalMember'); + const optionalNestedMember = expressionFromSource(source, 'optionalNestedMember'); + const optionalCall = expressionFromSource(source, 'optionalCall'); + const directQueryKey = expressionFromSource(source, 'directQueryKey'); + const queryCacheLookup = expressionFromSource(source, 'queryCacheLookup'); + const predicateAction = expressionFromSource(source, 'predicateAction'); + const invalidateAction = expressionFromSource(source, 'invalidateAction'); + const exactAction = expressionFromSource(source, 'exactAction'); + const wildcardAction = expressionFromSource(source, 'wildcardAction'); + + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier') { + if (node.name === 'spreadSource') { + return spreadSource; + } + if (node.name === 'spreadWrapper') { + return spreadWrapper; + } + if (node.name === 'overrideWrapper') { + return overrideWrapper; + } + if (node.name === 'tupleFactory') { + return tupleFactory; + } + if (node.name === 'queryCacheLookup') { + return queryCacheLookup; + } + } + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'createQueryKey') { + return arrayExpression([identifier('first'), identifier('second'), identifier('third')]); + } + if (callee.type === 'Identifier' && callee.name === 'optionsFactory') { + return objectExpression([ + objectProperty(identifier('queryKey'), identifier('queryKey')), + objectProperty(identifier('exact'), identifier('exact')), + objectProperty(identifier('predicate'), identifier('predicate')), + ]); + } + return undefined; + }), + }; + + expect(segmentFromExpression(spreadWrapper, resolver)).toMatchObject({ + text: '[spread]', + isStatic: true, + }); + expect(segmentFromExpression(overrideWrapper, resolver)).toMatchObject({ + text: '[override]', + isStatic: true, + }); + expect( + segmentFromExpression(memberExpression(identifier('spreadWrapper'), identifier('nested')), resolver), + ).toMatchObject({ + text: expect.stringContaining('nested'), + isStatic: true, + }); + expect( + segmentFromExpression(memberExpression(identifier('overrideWrapper'), identifier('nested')), resolver), + ).toMatchObject({ + text: expect.stringContaining('nested'), + isStatic: true, + }); + expect(segmentFromExpression(optionalMember, resolver)).toMatchObject({ + text: '[override]', + isStatic: true, + }); + expect(segmentFromExpression(optionalNestedMember, resolver)).toMatchObject({ + text: expect.stringContaining('nested'), + isStatic: true, + }); + expect(segmentFromExpression(optionalCall, resolver)).toMatchObject({ + text: '$first', + isStatic: false, + }); + + expect(normalizeQueryKey(positionalFactory, { defaultMode: 'exact' }, resolver)).toEqual({ + id: 'prefix|[middle]|[suffix]', + display: '[prefix, [middle], [suffix]]', + segments: ['prefix', '[middle]', '[suffix]'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }); + + expect(normalizeQueryKey(optionsFactoryCall, { defaultMode: 'exact' }, resolver)).toEqual({ + id: 'object', + display: '[object]', + segments: ['object'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }); + expect(normalizeQueryKey(tupleFactoryCall, { defaultMode: 'exact' }, resolver)).toEqual({ + id: '[tuple]|[pair]|[tuple]', + display: '[[tuple], [pair], [tuple]]', + segments: ['[tuple]', '[pair]', '[tuple]'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }); + expect(normalizeQueryKey(identityWrapped, { defaultMode: 'exact' }, resolver)).toEqual({ + id: 'override', + display: '[override]', + segments: ['override'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }); + expect(normalizeQueryKey(nestedIdentityWrapped, { defaultMode: 'exact' }, resolver)).toEqual({ + id: '[spread].nested', + display: '[spread].nested', + segments: ['[spread].nested'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }); + + expect(inferActionQueryKey('setQueryData', [directQueryKey])).toEqual(buildPassThroughActionKey('exact')); + expect(inferActionQueryKey('invalidateQueries', [invalidateAction], resolver)).toEqual( + buildPassThroughActionKey('exact'), + ); + expect(inferActionQueryKey('invalidateQueries', [exactAction], resolver)).toMatchObject({ + segments: ['override'], + matchMode: 'exact', + }); + expect(inferActionQueryKey('removeQueries', [predicateAction], resolver)).toMatchObject({ + segments: ['exact', 'second'], + matchMode: 'prefix', + resolution: 'static', + }); + expect(inferActionQueryKey('invalidateQueries', [wildcardAction], resolver)).toMatchObject({ + segments: ['wild'], + }); + expect(inferActionQueryKey('invalidateQueries', [objectExpression([])], resolver)).toMatchObject({ + matchMode: 'all', + source: 'wildcard', + }); + + expect(isOpaqueCollectionQueryKey(buildPassThroughActionKey('exact'))).toBe(true); + }); + + it('covers memo wrappers, freeze wrappers, and collection transform shortcuts', () => { + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'items') { + return arrayExpression([stringLiteral('items')]); + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + + expect( + segmentFromExpression( + callExpression(identifier('useMemo'), [arrowFunction(arrayExpression([stringLiteral('memo')]))]), + ), + ).toMatchObject({ text: '[memo]', isStatic: true }); + expect( + segmentFromExpression( + callExpression(identifier('useCallback'), [arrowFunction(arrayExpression([stringLiteral('callback')]))]), + ), + ).toMatchObject({ text: '[callback]', isStatic: true }); + expect( + segmentFromExpression( + callExpression(memberExpression(identifier('Object'), identifier('freeze')), [ + objectExpression([objectProperty(identifier('a'), stringLiteral('1'))]), + ]), + ), + ).toMatchObject({ text: '$Object.freeze({a: 1})', isStatic: false }); + expect( + segmentFromExpression( + callExpression(memberExpression(identifier('items'), identifier('join')), [stringLiteral(',')]), + resolver, + ), + ).toMatchObject({ text: '[items]' }); + expect( + segmentFromExpression( + callExpression(memberExpression(identifier('items'), identifier('toSorted')), []), + resolver, + ), + ).toMatchObject({ + text: '[items]', + }); + expect( + segmentFromExpression( + callExpression(memberExpression(identifier('items'), identifier('flatMap')), [ + arrowFunction(identifier('entry')), + ]), + resolver, + ), + ).toMatchObject({ text: '[items]' }); + }); + + it('covers computed properties, object spread fallbacks, and function-return inference edges', () => { + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'arrayRef') { + return arrayExpression([stringLiteral('array-ref')]); + } + if (node.type === 'Identifier' && node.name === 'factoryArray') { + return arrayExpression([stringLiteral('factory-array')]); + } + if (node.type === 'Identifier' && node.name === 'factoryObject') { + return objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('factory-object')])), + objectProperty( + identifier('nested'), + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('factory-nested')])), + ]), + ), + objectProperty(identifier('list'), arrayExpression([arrayExpression([stringLiteral('factory-list')])])), + ]); + } + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'factoryArray') { + return arrayExpression([stringLiteral('factory-array')]); + } + if (callee.type === 'Identifier' && callee.name === 'factoryObject') { + return objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('factory-object')])), + objectProperty( + identifier('nested'), + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('factory-nested')])), + ]), + ), + objectProperty(identifier('list'), arrayExpression([arrayExpression([stringLiteral('factory-list')])])), + ]); + } + if (callee.type === 'Identifier' && callee.name === 'makeEmptyArray') { + return arrayExpression([]); + } + if (callee.type === 'Identifier' && callee.name === 'makePositionalArray') { + return arrayExpression([identifier('first'), identifier('second')]); + } + return undefined; + }), + }; + + const computedMember = memberExpression( + identifier('holder'), + { + type: 'BinaryExpression', + operator: '+', + left: stringLiteral('que'), + right: stringLiteral('ryKey'), + } as BinaryExpression, + true, + ); + + const spreadObject = objectExpression([spreadElement(callExpression(identifier('factoryArray')))]); + + const invalidPropertyObject = objectExpression([ + { + type: 'Property', + kind: 'init', + key: null as never, + value: stringLiteral('missing'), + method: false, + shorthand: false, + computed: true, + optional: false, + } as never, + ]); + + const emptyStringFallback = callExpression(identifier('maybe'), [stringLiteral('')]); + const emptyTemplateFallback = callExpression(identifier('maybe'), [templateLiteral('')]); + const emptyObjectFallback = callExpression(identifier('maybe'), [objectExpression([])]); + const emptyArrayFallback = callExpression(identifier('maybe'), [arrayExpression([])]); + const nullFallback = callExpression(identifier('maybe'), [{ type: 'NullLiteral' } as never]); + const undefinedFallback = callExpression(identifier('maybe'), [identifier('undefined')]); + + expect(segmentFromExpression(computedMember, resolver)).toMatchObject({ + text: '$holder.que + ryKey', + isStatic: false, + }); + const spreadObjectSegment = segmentFromExpression(spreadObject, resolver); + expect(spreadObjectSegment.text).toContain('factory-array'); + expect(segmentFromExpression(invalidPropertyObject, resolver)).toMatchObject({ + text: '{[UNRESOLVED]: missing}', + isStatic: false, + }); + expect(segmentFromExpression(emptyStringFallback, resolver)).toMatchObject({ isStatic: false }); + expect(segmentFromExpression(emptyTemplateFallback, resolver)).toMatchObject({ isStatic: false }); + expect(segmentFromExpression(emptyObjectFallback, resolver)).toMatchObject({ text: '{}', isStatic: true }); + expect(segmentFromExpression(emptyArrayFallback, resolver)).toMatchObject({ text: '[]', isStatic: true }); + expect(segmentFromExpression(nullFallback, resolver)).toMatchObject({ isStatic: false }); + expect(segmentFromExpression(undefinedFallback, resolver)).toMatchObject({ isStatic: false }); + expect(inferHookQueryKey([arrowFunction(arrayExpression([stringLiteral('hook-return')]))], resolver)).toMatchObject( + { + segments: ['hook-return'], + }, + ); + expect( + normalizeQueryKey( + callExpression(identifier('makePositionalArray'), [stringLiteral('left'), stringLiteral('right')]), + { defaultMode: 'exact' }, + resolver, + ), + ).toMatchObject({ + segments: ['left', 'right'], + }); + expect( + normalizeQueryKey(callExpression(identifier('factoryObject')), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: ['factory-object'], + }); + expect( + normalizeQueryKey(callExpression(identifier('factoryArray')), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: ['factory-array'], + }); + expect( + normalizeQueryKey(callExpression(identifier('makeEmptyArray')), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: [], + }); + }); + + it('covers computed keys, bigint and private names, and call hint substitutions', () => { + const complexSource = ` + const identity = (value) => value; + const makeComplex = ( + source, + prop, + fn, + arg, + name, + count, + left, + right, + flag, + fallback, + cond, + yes, + no, + first, + second, + rest, + ) => [ + source[prop], + source?.[prop], + fn(arg), + \`hello \${name}\`, + -count, + left + right, + flag && fallback, + cond ? yes : no, + (first, second), + ...rest, + ]; + const complexCall = makeComplex( + { key: ['source'] as const }, + 'key', + identity, + 'arg', + 'name', + 1, + 'left', + 'right', + true, + 'fallback', + false, + 'yes', + 'no', + 'first', + 'second', + ['tail'] as const, + ); + const passThrough = queryKeys(['direct'] as const); + const createKeyCall = createQueryKey('left', 'right'); + `; + const makeComplex = expressionFromSource(complexSource, 'makeComplex'); + const complexCall = expressionFromSource(complexSource, 'complexCall'); + const identityFn = expressionFromSource(complexSource, 'identity'); + const passThrough = expressionFromSource(complexSource, 'passThrough'); + const createKeyCall = expressionFromSource(complexSource, 'createKeyCall'); + + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'makeComplex') { + return makeComplex; + } + if (node.type === 'Identifier' && node.name === 'identity') { + return identityFn; + } + if (node.type === 'Identifier' && node.name === 'queryKeys') { + return identifier('queryKeys'); + } + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'createQueryKey') { + return arrayExpression([identifier('first'), identifier('second')]); + } + if (callee.type === 'Identifier' && callee.name === 'queryKeys') { + return identifier('queryKeys'); + } + return undefined; + }), + }; + + expect(normalizeQueryKey(complexCall, { defaultMode: 'exact' }, resolver)).toMatchObject({ + matchMode: 'exact', + resolution: 'dynamic', + }); + expect(segmentFromExpression(passThrough, resolver)).toMatchObject({ text: '[direct]', isStatic: true }); + expect(normalizeQueryKey(createKeyCall, { defaultMode: 'exact' }, resolver).segments).toEqual(['left', 'right']); + + const complexObject = segmentFromExpression( + objectExpression([ + objectProperty(stringLiteral('quoted'), stringLiteral('value')), + objectProperty(numericLiteral(7), stringLiteral('number')), + objectProperty({ type: 'PrivateName', id: identifier('secret') }, stringLiteral('hidden')), + objectProperty( + { + type: 'BinaryExpression', + operator: '+', + left: stringLiteral('a'), + right: stringLiteral('b'), + } as BinaryExpression, + stringLiteral('computed'), + true, + ), + objectProperty({ type: 'BigIntLiteral', value: '10' } as never, stringLiteral('big'), true), + ]), + resolver, + ); + + expect(complexObject.text).toContain('quoted: value'); + expect(complexObject.text).toContain('7: number'); + expect(complexObject.text).toContain('#secret: hidden'); + expect(complexObject.text).toContain('[10]: big'); + }); + + it('covers malformed and fallback AST query-key shapes', () => { + const resolver: QueryKeyResolver = { + resolveReference: vi.fn(() => undefined), + resolveCallResult: vi.fn(() => undefined), + }; + + expect(segmentFromExpression({ type: 'NullLiteral', value: null } as never)).toEqual({ + text: 'null', + isStatic: true, + }); + expect( + segmentFromExpression( + objectExpression([ + spreadElement(identifier('dynamicObject')), + { + type: 'ObjectMethod', + kind: 'method', + key: identifier('method'), + body: { type: 'BlockStatement', body: [] }, + params: [], + generator: false, + async: false, + computed: false, + optional: false, + } as never, + ]), + resolver, + ), + ).toMatchObject({ text: '{...$dynamicObject, [method]}', isStatic: false }); + expect( + segmentFromExpression(objectExpression([spreadElement(callExpression(identifier('makeSpread')))]), resolver), + ).toMatchObject({ text: '{...call(makeSpread)}', isStatic: false }); + + expect( + segmentFromExpression( + callExpression(identifier('fn'), [spreadElement(identifier('items')), { type: 'Super' } as never]), + resolver, + ), + ).toMatchObject({ text: 'call(fn)', isStatic: false }); + expect( + segmentFromExpression(callExpression(identifier('fn'), [spreadElement({ type: 'Super' } as never)]), resolver), + ).toMatchObject({ text: 'call(fn)', isStatic: false }); + + expect( + segmentFromExpression( + memberExpression(identifier('collection'), { type: 'PrivateName', id: identifier('secret') }, true), + resolver, + ), + ).toMatchObject({ text: '$collection.?', isStatic: false }); + + expect( + normalizeQueryKey(arrayExpression([null, spreadElement(identifier('rest'))]), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: ['UNRESOLVED', '$rest'], + resolution: 'dynamic', + }); + expect( + normalizeQueryKey({ type: 'NullLiteral', value: null } as never, { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: ['null'], + source: 'literal', + }); + + expect( + normalizeQueryKey( + { + type: 'ConditionalExpression', + test: identifier('enabled'), + consequent: arrayExpression([]), + alternate: { type: 'NullLiteral', value: null } as never, + } as Expression, + { defaultMode: 'prefix' }, + resolver, + ), + ).toMatchObject({ + matchMode: 'prefix', + source: 'expression', + }); + }); + + it('applies object argument hints to resolved call results', () => { + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'spreadArgs') { + return objectExpression([objectProperty(identifier('suffix'), stringLiteral('spread'))]); + } + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'makeObjectKey') { + return objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([identifier('prefix'), identifier('suffix')])), + ]); + } + return undefined; + }), + }; + expect( + normalizeQueryKey( + callExpression(identifier('makeObjectKey'), [ + objectExpression([ + objectProperty(identifier('prefix'), stringLiteral('object')), + spreadElement(identifier('spreadArgs')), + ]), + ]), + { defaultMode: 'exact' }, + resolver, + ), + ).toMatchObject({ + segments: ['object', 'spread'], + source: 'literal', + }); + }); + + it('covers defensive substitution and argument-normalization branches', () => { + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'defensiveFactory') { + return { + type: 'ArrowFunctionExpression', + params: [ + { type: 'TSParameterProperty', parameter: identifier('wrapped') } as never, + { + type: 'TSParameterProperty', + parameter: { + type: 'AssignmentPattern', + left: identifier('fallback'), + right: stringLiteral('default'), + }, + } as never, + { + type: 'AssignmentPattern', + left: identifier('assigned'), + right: stringLiteral('assigned-default'), + } as never, + { type: 'RestElement', argument: identifier('ignored') } as never, + ], + body: arrayExpression([ + null, + spreadElement({ type: 'Super' } as never), + spreadElement(identifier('wrapped')), + objectExpression([ + spreadElement({ type: 'Super' } as never), + spreadElement(identifier('wrapped')), + { + type: 'Property', + kind: 'init', + key: identifier('methodLike'), + value: { type: 'Super' }, + method: false, + shorthand: false, + computed: false, + optional: false, + } as never, + objectProperty(identifier('same'), identifier('wrapped')), + ]), + callExpression(identifier('invoke'), [spreadElement(identifier('wrapped')), { type: 'Super' } as never]), + { + type: 'TemplateLiteral', + quasis: [ + { type: 'TemplateElement', value: { raw: 'x', cooked: 'x' }, tail: false }, + { type: 'TemplateElement', value: { raw: 'y', cooked: 'y' }, tail: true }, + ], + expressions: [identifier('wrapped')], + } as TemplateLiteral, + { + type: 'UnaryExpression', + operator: '!', + prefix: true, + argument: identifier('wrapped'), + } as never, + { + type: 'BinaryExpression', + operator: '+', + left: identifier('wrapped'), + right: identifier('fallback'), + } as BinaryExpression, + { + type: 'ConditionalExpression', + test: identifier('assigned'), + consequent: identifier('wrapped'), + alternate: identifier('fallback'), + } as Expression, + { + type: 'SequenceExpression', + expressions: [identifier('fallback'), identifier('wrapped')], + } as Expression, + { + type: 'ParenthesizedExpression', + expression: identifier('wrapped'), + } as Expression, + ]), + generator: false, + async: false, + expression: true, + }; + } + + if (node.type === 'Identifier' && node.name === 'spreadRest') { + return arrayExpression([]); + } + + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + + expect( + normalizeQueryKey( + callExpression(identifier('defensiveFactory'), [ + arrayExpression([stringLiteral('replacement')]), + stringLiteral('fallback-value'), + stringLiteral('assigned-value'), + spreadElement(identifier('spreadRest')), + ]), + { defaultMode: 'exact' }, + resolver, + ), + ).toMatchObject({ + matchMode: 'exact', + resolution: 'dynamic', + }); + + expect( + segmentFromExpression( + callExpression(memberExpression(identifier('invoke'), { type: 'Super' } as never, true), [ + { type: 'Super' } as never, + spreadElement({ type: 'Super' } as never), + ]), + resolver, + ), + ).toMatchObject({ + text: '$invoke[expr](UNRESOLVED, ...UNRESOLVED)', + isStatic: false, + }); + + expect(segmentFromExpression(identifier('never'), resolver, 25)).toEqual({ text: 'expr', isStatic: false }); + expect(resolveQueryKeyExpression(identifier('never'), resolver, 25)).toBeUndefined(); + expect(normalizeQueryKey(undefined, { defaultMode: 'exact' }, resolver)).toMatchObject({ + matchMode: 'exact', + source: 'expression', + }); + }); + + it('covers remaining public query-key branch probes', () => { + const refCall = callExpression(identifier('ref'), [arrayExpression([stringLiteral('ref-probe')])]); + const useStateCall = callExpression(identifier('useState'), [arrayExpression([stringLiteral('state-probe')])]); + const stateMember = memberExpression(useStateCall, numericLiteral(0), true); + const objectWithStringKey = objectExpression([ + objectProperty(stringLiteral('queryKey'), arrayExpression([stringLiteral('string-key')])), + objectProperty(identifier('exact'), stringLiteral('not-boolean')), + ]); + const queryCacheLookup = callExpression(memberExpression(identifier('queryCache'), identifier('get')), [ + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('cache-probe')]))]), + ]); + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'stateAlias') { + return useStateCall; + } + if (node.type === 'Identifier' && node.name === 'stateMemberAlias') { + return stateMember; + } + if (node.type === 'Identifier' && node.name === 'refObject') { + return refCall; + } + if (node.type === 'MemberExpression') { + return arrayExpression([stringLiteral('member-resolved')]); + } + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'createQueryKey') { + return arrayExpression([identifier('first'), identifier('second'), identifier('third')]); + } + return undefined; + }), + }; + + expect( + segmentFromExpression( + { + type: 'LogicalExpression', + operator: '||', + left: identifier('maybe'), + right: { type: 'NullLiteral', value: null }, + } as never, + resolver, + ), + ).toEqual({ + text: '$maybe', + isStatic: false, + }); + expect( + segmentFromExpression( + callExpression(memberExpression(identifier('api'), identifier('run')), [ + { type: 'Super' } as never, + spreadElement({ type: 'Super' } as never), + ]), + ), + ).toMatchObject({ text: '$api.run(UNRESOLVED, ...UNRESOLVED)', isStatic: false }); + expect( + segmentFromExpression( + callExpression(memberExpression(identifier('api'), { type: 'PrivateName', id: identifier('x') }, true)), + ), + ).toMatchObject({ text: '$api[?]()', isStatic: false }); + expect(segmentFromExpression(identifier('stateAlias'), resolver)).toMatchObject({ + text: '[state-probe]', + isStatic: true, + }); + expect(segmentFromExpression(identifier('stateMemberAlias'), resolver)).toMatchObject({ + text: '[state-probe]', + isStatic: true, + }); + expect( + segmentFromExpression( + { + type: 'OptionalMemberExpression', + object: refCall, + property: identifier('value'), + computed: false, + optional: true, + } as never, + resolver, + ), + ).toMatchObject({ text: '[ref-probe]?.value', isStatic: true }); + expect(segmentFromExpression(memberExpression(refCall, identifier('value')))).toMatchObject({ + text: '[ref-probe].value', + isStatic: true, + }); + expect( + segmentFromExpression(callExpression(identifier('queryOptions'), [objectWithStringKey]), resolver), + ).toMatchObject({ text: '[string-key]', isStatic: true }); + expect(readBooleanProperty(objectWithStringKey, 'exact')).toBeUndefined(); + expect(findObjectPropertyValue(objectWithStringKey, 'queryKey', resolver)).toEqual( + arrayExpression([stringLiteral('string-key')]), + ); + expect(inferHookQueryKey([], resolver)).toMatchObject({ matchMode: 'unknown' }); + expect(inferHookQueryKey([spreadElement(identifier('bad'))], resolver)).toMatchObject({ matchMode: 'unknown' }); + expect(inferHookQueryKey([objectWithStringKey], resolver)).toMatchObject({ segments: ['string-key'] }); + expect(inferHookQueryKeys('useQueries', [spreadElement(identifier('bad'))], resolver)).toEqual([ + expect.objectContaining({ matchMode: 'unknown' }), + ]); + expect(inferHookQueryKeys('useQueries', [arrayExpression([])], resolver)).toEqual([ + expect.objectContaining({ matchMode: 'exact' }), + ]); + expect(isHookCallDirectQueryKeyDeclaration([spreadElement(identifier('bad'))], 'useQueries')).toBe(false); + expect(isHookCallDirectQueryKeyDeclaration([arrayExpression([objectWithStringKey])], 'useMutation')).toBe(true); + expect(isOpaqueCollectionQueryKey({ ...buildPassThroughActionKey('exact'), segments: ['call(fetch)'] })).toBe(true); + expect(isOpaqueCollectionQueryKey({ ...buildPassThroughActionKey('exact'), source: 'wildcard' })).toBe(false); + expect(inferActionQueryKey('invalidateQueries', [identifier('queryKey')], resolver)).toEqual( + buildPassThroughActionKey('prefix'), + ); + expect(inferActionQueryKey('invalidateQueries', [queryCacheLookup], resolver)).toMatchObject({ + segments: ['cache-probe'], + matchMode: 'prefix', + }); + expect( + normalizeQueryKey(callExpression(identifier('createQueryKey'), [stringLiteral('only')]), {}, resolver), + ).toMatchObject({ matchMode: 'prefix', segments: ['only', '$second', '$third'] }); + expect(findObjectPropertyValue(objectWithStringKey, 'queryKey', resolver, 25)).toBeUndefined(); + }); + + it('covers additional unresolved object, wrapper, and member branches', () => { + const spreadResolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'spreadText') { + return stringLiteral('spread-value'); + } + if (node.type === 'Identifier' && node.name === 'emptySpread') { + return objectExpression([]); + } + if (node.type === 'Identifier' && node.name === 'queryOptionsRef') { + return objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('ref-options')])), + ]); + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + + expect(segmentFromExpression(objectExpression([spreadElement(identifier('spreadText'))]), spreadResolver)).toEqual({ + text: '{...spread-value}', + isStatic: false, + }); + expect(segmentFromExpression(objectExpression([spreadElement(identifier('emptySpread'))]), spreadResolver)).toEqual( + { + text: '{}', + isStatic: true, + }, + ); + expect( + segmentFromExpression( + objectExpression([ + spreadElement(objectExpression([objectProperty(identifier('x'), callExpression(identifier('fn')))])), + ]), + ), + ).toEqual({ + text: '{x: call(fn)}', + isStatic: false, + }); + expect( + segmentFromExpression(objectExpression([objectProperty(identifier('bad'), nonExpression())]), spreadResolver), + ).toEqual({ + text: '{bad: UNRESOLVED}', + isStatic: false, + }); + expect( + segmentFromExpression({ + type: 'LogicalExpression', + operator: '??', + left: identifier('maybe'), + right: { + type: 'TemplateLiteral', + quasis: [{ type: 'TemplateElement', value: { raw: '', cooked: null }, tail: true }], + expressions: [], + }, + } as never), + ).toEqual({ text: '$maybe', isStatic: false }); + expect( + inferActionQueryKey( + 'invalidateQueries', + [ + callExpression(identifier('queryOptions'), [ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('wrapped-action')])), + ]), + ]), + ], + spreadResolver, + ), + ).toMatchObject({ segments: ['wrapped-action'], matchMode: 'prefix' }); + expect( + resolveQueryKeyExpression( + memberExpression(identifier('target'), { type: 'PrivateName', id: identifier('x') }, true), + undefined, + ), + ).toEqual(memberExpression(identifier('target'), { type: 'PrivateName', id: identifier('x') }, true)); + expect( + resolveQueryKeyExpression( + memberExpression( + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('inline-member')]))]), + identifier('queryKey'), + ), + undefined, + ), + ).toEqual(arrayExpression([stringLiteral('inline-member')])); + expect(resolveQueryKeyExpression(identifier('queryOptionsRef'), spreadResolver)).toEqual( + arrayExpression([stringLiteral('ref-options')]), + ); + }); + + it('directly covers query-key internal defensive helpers', () => { + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'spreadObject') { + return objectExpression([objectProperty(identifier('nested'), stringLiteral('value'))]); + } + if (node.type === 'Identifier' && node.name === 'spreadArray') { + return arrayExpression([]); + } + if (node.type === 'Identifier' && node.name === 'mapper') { + return arrowFunction( + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('mapped')]))]), + ); + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + + const internals = __queryKeyTestInternals; + expect(internals.segmentTextOrUnresolved({ text: '', isStatic: true })).toBe('UNRESOLVED'); + expect(internals.segmentTextOrUnresolved({ text: 'value', isStatic: true })).toBe('value'); + expect(internals.isDisplayableResolvedSegment({ text: 'expr', isStatic: false })).toBe(false); + expect(internals.isDisplayableResolvedSegment({ text: 'value', isStatic: true })).toBe(true); + expect(internals.callArgumentsSegment([spreadElement(nonExpression()), nonExpression()], resolver, 0)).toEqual({ + text: '...UNRESOLVED, UNRESOLVED', + isStatic: false, + }); + expect(internals.simplifyCollectionMethodCallSegment({ text: 'items', isStatic: true }, undefined)).toBeUndefined(); + expect(internals.isEmptyFallbackExpression({ type: 'NullLiteral', value: null } as never)).toBe(true); + + const substitutions = new Map(); + internals.collectObjectArgumentSubstitutions(objectExpression([null as never]), resolver, 25, substitutions); + internals.collectObjectArgumentSubstitutions( + objectExpression([ + null as never, + objectProperty({ type: 'PrivateName', id: identifier('secret') }, stringLiteral('private')), + objectProperty(identifier('bad'), { type: 'Super' } as never), + spreadElement(stringLiteral('not-object')), + spreadElement(identifier('spreadObject')), + ]), + resolver, + 0, + substitutions, + ); + expect([...substitutions.keys()]).toContain('nested'); + expect( + internals.applyObjectArgumentIdentifierHints(identifier('untouched'), objectExpression([]), resolver, 0), + ).toEqual(identifier('untouched')); + expect( + internals.substituteIdentifierInExpression( + objectExpression([ + { + ...objectProperty(identifier('token'), identifier('token')), + shorthand: true, + }, + { + type: 'ObjectMethod', + method: true, + key: identifier('m'), + params: [], + body: { type: 'BlockStatement', body: [] }, + } as never, + ]), + 'token', + identifier('replacement'), + ), + ).toBeDefined(); + + expect( + internals.resolveObjectPropertyExpression(objectExpression([null as never]), 'x', resolver, 0), + ).toBeUndefined(); + expect(internals.resolveObjectPropertyExpression(objectExpression([]), 'x', resolver, 25)).toBeUndefined(); + expect( + internals.resolveObjectPropertyExpression( + objectExpression([spreadElement(nonExpression()), objectProperty(identifier('x'), stringLiteral('direct'))]), + 'missing', + resolver, + 0, + ), + ).toBeUndefined(); + expect( + internals.resolveObjectPropertyExpression( + objectExpression([spreadElement(stringLiteral('not-object')), spreadElement(identifier('spreadObject'))]), + 'nested', + resolver, + 0, + ), + ).toEqual(stringLiteral('value')); + + expect( + internals.functionParameterNames([ + { parameter: identifier('wrapped') }, + { + parameter: { + type: 'AssignmentPattern', + left: identifier('wrappedAssigned'), + right: stringLiteral('fallback'), + }, + }, + { + parameter: { + type: 'AssignmentPattern', + left: { type: 'ObjectPattern', properties: [] }, + right: stringLiteral('fallback'), + }, + }, + { + type: 'AssignmentPattern', + left: identifier('assigned'), + right: stringLiteral('fallback'), + }, + { type: 'RestElement', argument: identifier('rest') } as never, + ]), + ).toEqual(['wrapped', 'wrappedAssigned', 'assigned']); + + const fn = { + type: 'ArrowFunctionExpression', + params: [identifier('used'), identifier('unused')], + body: arrayExpression([identifier('used')]), + generator: false, + async: false, + expression: true, + } as ArrowFunctionExpression; + expect( + internals.applyFunctionArgumentHints( + callExpression(identifier('fn'), [spreadElement(identifier('skip'))]), + fn, + fn.body, + resolver, + 0, + ), + ).toEqual(fn.body); + expect( + internals.applyFunctionArgumentHints( + callExpression(identifier('fn'), [stringLiteral('hit')]), + fn, + fn.body, + resolver, + 0, + ), + ).toEqual(arrayExpression([stringLiteral('hit')])); + expect( + internals.applyFunctionArgumentHints( + callExpression(identifier('fn'), [stringLiteral('hit'), stringLiteral('ignored')]), + fn, + fn.body, + resolver, + 0, + ), + ).toEqual(arrayExpression([stringLiteral('hit')])); + + expect( + internals.applyPositionalArgumentHints(callExpression(identifier('x')), stringLiteral('not-array'), resolver, 0), + ).toEqual(stringLiteral('not-array')); + expect( + internals.applyPositionalArgumentHints( + callExpression(identifier('x'), [stringLiteral('arg')]), + arrayExpression([stringLiteral('static')]), + resolver, + 0, + ), + ).toEqual(arrayExpression([stringLiteral('static')])); + expect( + internals.applyPositionalArgumentHints( + callExpression(identifier('x')), + arrayExpression([identifier('missing')]), + resolver, + 0, + ), + ).toEqual(arrayExpression([identifier('missing')])); + expect( + internals.applyPositionalArgumentHints( + callExpression(identifier('x'), [stringLiteral('arg')]), + arrayExpression([identifier('staticPlaceholder')]), + { + resolveReference: vi.fn((node) => + node.type === 'Identifier' && node.name === 'staticPlaceholder' ? stringLiteral('static') : undefined, + ), + resolveCallResult: vi.fn(() => undefined), + }, + 0, + ), + ).toEqual(arrayExpression([identifier('staticPlaceholder')])); + + expect(internals.resolveActionOptionsObject(identifier('x'), resolver, 25)).toBeUndefined(); + expect( + internals.resolveActionOptionsObject( + callExpression(identifier('queryOptions'), [ + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('wrapped')]))]), + ]), + resolver, + 0, + ), + ).toEqual(objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('wrapped')]))])); + + expect(internals.segmentsFromArrayElement(spreadElement(nonExpression()), resolver, 0)).toEqual([ + { text: 'UNRESOLVED', isStatic: false }, + ]); + expect(internals.segmentsFromArrayElement(spreadElement(identifier('spreadArray')), resolver, 0)).toEqual([ + { text: 'UNRESOLVED', isStatic: false }, + ]); + + expect(internals.collectQueryKeyExpressionsFromQueryOptionEntry(identifier('x'), resolver, 25)).toEqual([]); + expect( + internals + .collectQueryKeyExpressionsFromQueryOptionEntry( + { + type: 'ConditionalExpression', + test: identifier('ok'), + consequent: objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('yes')])), + ]), + alternate: objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('no')])), + ]), + } as never, + resolver, + 0, + ) + .map((entry) => segmentFromExpression(entry, resolver).text), + ).toEqual(['[yes]', '[no]']); + expect( + internals + .collectQueryKeyExpressionsFromQueryOptionEntry( + { + type: 'LogicalExpression', + operator: '&&', + left: identifier('ok'), + right: objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('and')]))]), + } as never, + resolver, + 0, + ) + .map((entry) => segmentFromExpression(entry, resolver).text), + ).toEqual(['[and]']); + expect( + internals + .collectQueryKeyExpressionsFromQueryOptionEntry( + callExpression( + memberExpression( + arrayExpression([ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('from-call')])), + ]), + ]), + identifier('filter'), + ), + [], + ), + resolver, + 0, + ) + .map((entry) => segmentFromExpression(entry, resolver).text), + ).toEqual(['[from-call]']); + expect( + internals + .collectQueryKeyExpressionsFromQueryOptionEntry( + { + type: 'LogicalExpression', + operator: '||', + left: objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('left')]))]), + right: objectExpression([ + objectProperty( + identifier('queries'), + arrayExpression([ + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('right')]))]), + ]), + ), + ]), + } as never, + resolver, + 0, + ) + .map((entry) => segmentFromExpression(entry, resolver).text), + ).toEqual(['[left]', '[right]']); + + expect(internals.collectQueryKeyExpressionsFromQueriesCollection(identifier('x'), resolver, 25)).toEqual([]); + expect( + internals + .collectQueryKeyExpressionsFromQueriesCollection( + { + type: 'LogicalExpression', + operator: '&&', + left: identifier('ok'), + right: arrayExpression([ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('logical-and')])), + ]), + ]), + } as never, + resolver, + 0, + ) + .map((entry) => segmentFromExpression(entry, resolver).text), + ).toEqual(['[logical-and]']); + expect( + internals + .collectQueryKeyExpressionsFromQueriesCollection( + { + type: 'LogicalExpression', + operator: '||', + left: arrayExpression([ + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('left-list')]))]), + ]), + right: arrayExpression([ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('right-list')])), + ]), + ]), + } as never, + resolver, + 0, + ) + .map((entry) => segmentFromExpression(entry, resolver).text), + ).toEqual(['[left-list]', '[right-list]']); + expect( + internals.collectQueryKeyExpressionsFromQueriesCollection( + arrayExpression([spreadElement({ type: 'Super' } as never), { type: 'Super' } as never]), + resolver, + 0, + ), + ).toEqual([]); + + expect(internals.resolveCollectionMapperResult(identifier('x'), resolver, 25)).toBeUndefined(); + expect(internals.resolveCollectionMapperResult(stringLiteral('x'), resolver, 0)).toBeUndefined(); + expect(internals.resolveCollectionMapperResult(identifier('missingMapper'), resolver, 0)).toBeUndefined(); + expect(internals.resolveCollectionMapperResult(identifier('mapper'), resolver, 0)).toEqual( + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('mapped')]))]), + ); + expect( + internals.resolveCollectionMapperResult( + identifier('nonFunctionMapper'), + { + resolveReference: vi.fn((node) => + node.type === 'Identifier' && node.name === 'nonFunctionMapper' ? objectExpression([]) : undefined, + ), + resolveCallResult: vi.fn(() => undefined), + }, + 0, + ), + ).toEqual(objectExpression([])); + + expect( + internals.collectQueryKeyExpressionsFromQueryCollectionCall(callExpression(identifier('notMember')), resolver, 0), + ).toEqual([]); + expect( + internals.collectQueryKeyExpressionsFromQueryCollectionCall( + callExpression(memberExpression(arrayExpression([]), identifier('map')), []), + resolver, + 25, + ), + ).toEqual([]); + expect( + internals + .collectQueryKeyExpressionsFromQueryCollectionCall( + callExpression( + memberExpression( + arrayExpression([ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('concat-source')])), + ]), + ]), + identifier('concat'), + ), + [ + arrayExpression([ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('concat-arg')])), + ]), + ]), + spreadElement(identifier('skip')), + nonExpression(), + ], + ), + resolver, + 0, + ) + .map((entry) => segmentFromExpression(entry, resolver).text), + ).toEqual(['[concat-source]', '[concat-arg]']); + expect( + internals + .collectQueryKeyExpressionsFromQueryCollectionCall( + callExpression( + memberExpression( + arrayExpression([ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('pass-source')])), + ]), + ]), + identifier('filter'), + ), + [], + ), + resolver, + 0, + ) + .map((entry) => segmentFromExpression(entry, resolver).text), + ).toEqual(['[pass-source]']); + expect( + internals.collectQueryKeyExpressionsFromQueryCollectionCall( + callExpression(memberExpression(identifier('items'), identifier('map'), true), [identifier('mapper')]), + resolver, + 0, + ), + ).toEqual([]); + + expect(internals.isInlineQueryKeyObject(objectExpression([]), 25)).toBe(false); + expect(internals.isInlineQueryKeyCollection(arrayExpression([]), 25)).toBe(false); + expect( + internals.isInlineQueryKeyObject( + objectExpression([ + objectProperty( + identifier('queries'), + arrayExpression([ + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('inline')]))]), + ]), + ), + ]), + 0, + ), + ).toBe(true); + + expect(internals.queryKeyIndexFromAccessExpression(identifier('x'), resolver, 0)).toBeUndefined(); + expect( + internals.queryKeyIndexFromAccessExpression( + memberExpression(identifier('entry'), stringLiteral('0'), true), + resolver, + 0, + ), + ).toBeUndefined(); + expect( + internals.queryKeyIndexFromAccessExpression( + memberExpression(memberExpression(identifier('entry'), identifier('queryKey')), stringLiteral('0'), true), + resolver, + 25, + ), + ).toBeUndefined(); + expect( + internals.queryKeyIndexFromAccessExpression( + memberExpression(memberExpression(identifier('entry'), identifier('other')), stringLiteral('0'), true), + resolver, + 0, + ), + ).toBeUndefined(); + expect( + internals.queryKeyIndexFromAccessExpression( + memberExpression(memberExpression(identifier('entry'), identifier('queryKey')), stringLiteral('-1'), true), + resolver, + 0, + ), + ).toBeUndefined(); + expect( + internals.queryKeyIndexFromAccessExpression( + memberExpression(memberExpression(identifier('entry'), identifier('queryKey')), stringLiteral('1'), true), + resolver, + 0, + ), + ).toBe(1); + expect( + internals.queryKeyIndexFromAccessExpression( + memberExpression(memberExpression(identifier('entry'), identifier('queryKey')), identifier('idx'), true), + { + resolveReference: vi.fn((node) => + node.type === 'Identifier' && node.name === 'idx' ? stringLiteral('2') : undefined, + ), + resolveCallResult: vi.fn(() => undefined), + }, + 0, + ), + ).toBe(2); + + const constraints = new Map(); + internals.setPredicateQueryKeyConstraint(constraints, 0, { text: 'UNRESOLVED', isStatic: false }); + internals.setPredicateQueryKeyConstraint(constraints, 0, { text: 'same', isStatic: true }); + internals.setPredicateQueryKeyConstraint(constraints, 0, { text: 'same', isStatic: false }); + expect(constraints.get(0)).toEqual({ text: 'same', isStatic: false }); + constraints.set(1, { text: 'UNRESOLVED', isStatic: false }); + internals.setPredicateQueryKeyConstraint(constraints, 1, { text: 'resolved', isStatic: true }); + expect(constraints.get(1)).toEqual({ text: 'resolved', isStatic: true }); + internals.collectPredicateQueryKeyConstraints(identifier('x'), resolver, 25, constraints); + + expect( + internals.inferActionQueryKeyFromPredicate( + arrowFunction({ type: 'BlockStatement', body: [] } as never), + resolver, + ), + ).toBeUndefined(); + expect(internals.normalizeActionKeyOrWildcard(identifier('queryKey'), { defaultMode: 'prefix' }, resolver)).toEqual( + buildPassThroughActionKey('prefix'), + ); + expect( + internals.shouldTreatAsWildcardActionKey( + normalizeQueryKey(arrayExpression([]), { defaultMode: 'exact' }, resolver), + ), + ).toBe(true); + expect( + internals.shouldTreatAsWildcardActionKey({ + id: 'unresolved_query_key', + display: 'UNRESOLVED_QUERY_KEY', + segments: ['x'], + matchMode: 'unknown', + resolution: 'dynamic', + source: 'expression', + }), + ).toBe(true); + expect(internals.isPassThroughQueryKeyReference(undefined)).toBe(false); + expect( + internals.isQueryCacheLookupCall( + callExpression(memberExpression(identifier('queryCache'), identifier('find')), []), + resolver, + 25, + ), + ).toBe(false); + expect( + internals.isQueryCacheLookupCall( + callExpression(memberExpression(identifier('queryCache'), identifier('set')), []), + resolver, + 0, + ), + ).toBe(false); + expect( + internals.isQueryCacheLookupCall( + callExpression( + memberExpression(memberExpression(identifier('client'), identifier('getQueryCache')), identifier('get')), + [], + ), + resolver, + 0, + ), + ).toBe(true); + expect(internals.isPassThroughQueryInstanceReference(identifier('x'), resolver, 25)).toBe(false); + expect( + internals.isPassThroughQueryInstanceReference( + identifier('lookup'), + { + resolveReference: vi.fn((node) => + node.type === 'Identifier' && node.name === 'lookup' + ? callExpression(memberExpression(identifier('queryCache'), identifier('find')), []) + : undefined, + ), + resolveCallResult: vi.fn(() => undefined), + }, + 0, + ), + ).toBe(true); + expect( + internals.normalizeActionKeyOrWildcard(arrayExpression([]), { defaultMode: 'prefix' }, resolver), + ).toMatchObject({ + source: 'wildcard', + matchMode: 'all', + }); + expect(normalizeQueryKey(undefined)).toMatchObject({ matchMode: 'unknown', source: 'expression' }); + expect( + normalizeQueryKey( + callExpression(identifier('createQueryKey'), [nonExpression(), stringLiteral('two')]), + { defaultMode: 'exact' }, + resolver, + ), + ).toMatchObject({ + segments: ['UNRESOLVED', 'two', 'UNRESOLVED'], + matchMode: 'exact', + }); + expect( + inferHookQueryKey( + [objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('hook-object')]))])], + resolver, + ), + ).toMatchObject({ + segments: ['hook-object'], + }); + expect( + internals + .collectQueryKeyExpressionsFromQueryOptionEntry( + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('option-entry')]))]), + resolver, + 0, + ) + .map((entry) => segmentFromExpression(entry, resolver).text), + ).toEqual(['[option-entry]']); + expect( + inferHookQueryKeys( + 'useQueries', + [arrayExpression([objectExpression([objectProperty(identifier('queryKey'), identifier('queryKey'))])])], + resolver, + ), + ).toEqual([expect.objectContaining({ id: 'UNRESOLVED', segments: ['UNRESOLVED'], matchMode: 'exact' })]); + expect( + isHookCallDirectQueryKeyDeclaration( + [objectExpression([objectProperty(identifier('queries'), arrayExpression([]))])], + 'useQueries', + ), + ).toBe(false); + expect( + inferActionQueryKey( + 'invalidateQueries', + [ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('action-object')])), + objectProperty(identifier('exact'), booleanLiteral(true)), + ]), + ], + resolver, + ), + ).toMatchObject({ segments: ['action-object'], matchMode: 'exact' }); + expect( + inferActionQueryKey( + 'invalidateQueries', + [ + objectExpression([ + objectProperty(identifier('exact'), booleanLiteral(true)), + objectProperty( + identifier('predicate'), + arrowFunction({ + type: 'BinaryExpression', + operator: '===', + left: memberExpression( + memberExpression(identifier('entry'), identifier('queryKey')), + numericLiteral(0), + true, + ), + right: stringLiteral('action-predicate'), + } as never), + ), + ]), + ], + resolver, + ), + ).toMatchObject({ segments: ['action-predicate'], matchMode: 'exact' }); + expect( + internals.collectQueryKeyExpressionsFromQueryCollectionCall( + { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { type: 'Super' }, + property: identifier('map'), + computed: false, + }, + arguments: [], + } as never, + resolver, + 0, + ), + ).toEqual([]); + expect( + internals.collectQueryKeyExpressionsFromQueryCollectionCall( + { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { type: 'Super' }, + property: identifier('flatMap'), + computed: false, + }, + arguments: [], + } as never, + resolver, + 0, + ), + ).toEqual([]); + }); + + it('covers remaining call-expression display branches', () => { + const fn = arrowFunction(arrayExpression([stringLiteral('from-resolved-callee')])); + const nonFunctionValue = arrayExpression([stringLiteral('from-value-callee')]); + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'fnRef') { + return fn; + } + if (node.type === 'Identifier' && node.name === 'valueRef') { + return nonFunctionValue; + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + + expect( + segmentFromExpression( + callExpression(identifier('queryOptions'), [ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('segment-options')])), + ]), + ]), + resolver, + ), + ).toMatchObject({ text: '[segment-options]', isStatic: true }); + expect(segmentFromExpression(callExpression(identifier('fnRef')), resolver)).toMatchObject({ + text: 'from-resolved-callee', + isStatic: true, + }); + expect(segmentFromExpression(callExpression(identifier('valueRef')), resolver)).toMatchObject({ + text: '[from-value-callee]', + isStatic: true, + }); + expect( + segmentFromExpression({ + type: 'OptionalCallExpression', + callee: memberExpression(identifier('api'), identifier('run')), + arguments: [stringLiteral('x')], + optional: true, + } as never), + ).toMatchObject({ text: '$api.run(x)', isStatic: false }); + expect( + segmentFromExpression({ + type: 'OptionalCallExpression', + callee: memberExpression(identifier('items'), identifier('slice')), + arguments: [], + optional: true, + } as never), + ).toMatchObject({ text: '$items', isStatic: false }); + expect( + segmentFromExpression({ + type: 'OptionalCallExpression', + callee: { + type: 'OptionalMemberExpression', + object: stringLiteral('staticObject'), + property: stringLiteral('run'), + computed: true, + optional: true, + }, + arguments: [identifier('dynamicArg')], + optional: true, + } as never), + ).toMatchObject({ text: 'staticObject?.[run]($dynamicArg)', isStatic: false }); + expect( + segmentFromExpression( + callExpression(memberExpression(stringLiteral('staticObject'), stringLiteral('run'), true), [ + identifier('dynamicArg'), + ]), + ), + ).toMatchObject({ + text: 'staticObject[run]($dynamicArg)', + isStatic: false, + }); + expect( + segmentFromExpression( + callExpression(memberExpression(stringLiteral('staticObject'), stringLiteral('run'), true), [ + stringLiteral('staticArg'), + ]), + ), + ).toMatchObject({ + text: 'staticObject[run](staticArg)', + isStatic: true, + }); + expect( + segmentFromExpression( + callExpression(memberExpression(identifier('api'), identifier('run')), [stringLiteral('staticArg')]), + ), + ).toMatchObject({ + text: '$api.run(staticArg)', + isStatic: false, + }); + expect( + segmentFromExpression( + callExpression(memberExpression(stringLiteral('staticObject'), identifier('run')), [identifier('dynamicArg')]), + ), + ).toMatchObject({ + text: 'staticObject.run($dynamicArg)', + isStatic: false, + }); + expect( + resolveQueryKeyExpression(callExpression(identifier('createQueryKey'), [stringLiteral('replacement')]), { + resolveReference: vi.fn(() => undefined), + resolveCallResult: vi.fn((callee) => + callee.type === 'Identifier' && callee.name === 'createQueryKey' ? arrayExpression([null]) : undefined, + ), + }), + ).toEqual(arrayExpression([stringLiteral('replacement')])); + expect( + resolveQueryKeyExpression(callExpression(identifier('queryOptions'), [identifier('unresolved')]), resolver), + ).toEqual(identifier('unresolved')); + expect( + resolveQueryKeyExpression(objectExpression([objectProperty(identifier('queryKey'), identifier('deferredKey'))]), { + resolveReference: vi.fn((node) => + node.type === 'Identifier' && node.name === 'deferredKey' + ? arrayExpression([stringLiteral('deferred')]) + : undefined, + ), + resolveCallResult: vi.fn(() => undefined), + }), + ).toEqual(arrayExpression([stringLiteral('deferred')])); + expect( + resolveQueryKeyExpression(callExpression(identifier('factoryRef'), [stringLiteral('arg')]), { + resolveReference: vi.fn((node) => + node.type === 'Identifier' && node.name === 'factoryRef' + ? { + type: 'ArrowFunctionExpression', + params: [identifier('id')], + body: arrayExpression([stringLiteral('factory-ref'), identifier('id')]), + generator: false, + async: false, + expression: true, + } + : undefined, + ), + resolveCallResult: vi.fn(() => undefined), + }), + ).toEqual(arrayExpression([stringLiteral('factory-ref'), stringLiteral('arg')])); + expect( + resolveQueryKeyExpression( + memberExpression( + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('member-direct')]))]), + identifier('queryKey'), + ), + resolver, + ), + ).toEqual(arrayExpression([stringLiteral('member-direct')])); + expect( + resolveQueryKeyExpression( + memberExpression(identifier('memberRef'), { type: 'PrivateName', id: identifier('secret') }, true), + { + resolveReference: vi.fn((node) => + node.type === 'MemberExpression' ? arrayExpression([stringLiteral('member-ref')]) : undefined, + ), + resolveCallResult: vi.fn(() => undefined), + }, + ), + ).toEqual(arrayExpression([stringLiteral('member-ref')])); + expect( + inferActionQueryKey( + 'invalidateQueries', + [ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('direct-fallback')])), + ]), + ], + resolver, + ), + ).toMatchObject({ segments: ['direct-fallback'], matchMode: 'prefix' }); + }); + + it('covers empty helper calls and direct action fallback objects', () => { + const noReturnFn: ArrowFunctionExpression = { + type: 'ArrowFunctionExpression', + params: [], + body: { type: 'BlockStatement', body: [] } as never, + generator: false, + async: false, + expression: false, + }; + const functionExpressionNoReturn = { + type: 'FunctionExpression', + id: null, + params: [], + body: { type: 'BlockStatement', body: [] }, + generator: false, + async: false, + } as never as Expression; + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'noReturn') { + return noReturnFn; + } + if (node.type === 'Identifier' && node.name === 'functionNoReturn') { + return functionExpressionNoReturn; + } + if (node.type === 'Identifier' && node.name === 'exprValue') { + return { type: 'Super' } as never; + } + if (node.type === 'Identifier' && node.name === 'memberCallRef') { + return callExpression(memberExpression(identifier('api'), identifier('run')), []); + } + if (node.type === 'Identifier' && node.name === 'optionalMemberCallRef') { + return { + type: 'CallExpression', + callee: { + type: 'OptionalMemberExpression', + object: identifier('api'), + property: identifier('run'), + computed: false, + optional: true, + }, + arguments: [], + }; + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + + expect(segmentFromExpression(callExpression(identifier('queryOptions')), resolver)).toMatchObject({ + text: 'call(queryOptions)', + isStatic: false, + }); + expect(segmentFromExpression(callExpression(identifier('noReturn')), resolver)).toMatchObject({ + text: 'expr', + isStatic: false, + }); + expect(segmentFromExpression(callExpression(identifier('functionNoReturn')), resolver)).toMatchObject({ + text: 'expr', + isStatic: false, + }); + expect(segmentFromExpression(callExpression(identifier('exprValue')), resolver)).toMatchObject({ + text: 'expr', + isStatic: false, + }); + expect(segmentFromExpression(identifier('memberCallRef'), resolver)).toMatchObject({ + text: '$memberCallRef', + isStatic: false, + }); + expect(segmentFromExpression(identifier('optionalMemberCallRef'), resolver)).toMatchObject({ + text: '$optionalMemberCallRef', + isStatic: false, + }); + expect( + inferActionQueryKey( + 'invalidateQueries', + [ + callExpression(identifier('wrap'), [ + objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('fallback-object')])), + objectProperty(identifier('exact'), booleanLiteral(true)), + ]), + ]), + ], + resolver, + ), + ).toMatchObject({ segments: ['fallback-object'], matchMode: 'prefix' }); + expect( + inferActionQueryKey( + 'invalidateQueries', + [ + callExpression(identifier('wrap'), [ + objectExpression([ + objectProperty( + identifier('predicate'), + arrowFunction({ + type: 'BinaryExpression', + operator: '===', + left: memberExpression( + memberExpression(identifier('entry'), identifier('queryKey')), + numericLiteral(0), + true, + ), + right: stringLiteral('fallback-predicate'), + } as BinaryExpression), + ), + ]), + ]), + ], + resolver, + ), + ).toMatchObject({ segments: ['fallback-predicate'], matchMode: 'prefix' }); + expect( + inferActionQueryKey( + 'invalidateQueries', + [ + callExpression(identifier('wrap'), [ + objectExpression([objectProperty(identifier('predicate'), arrowFunction(booleanLiteral(true)))]), + ]), + ], + resolver, + ), + ).toMatchObject({ matchMode: 'all', source: 'wildcard' }); + expect( + inferActionQueryKey('invalidateQueries', [ + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('direct-action')]))]), + ]), + ).toMatchObject({ segments: ['direct-action'], matchMode: 'prefix' }); + expect( + inferActionQueryKey('invalidateQueries', [ + objectExpression([ + objectProperty( + identifier('predicate'), + arrowFunction({ + type: 'BinaryExpression', + operator: '===', + left: memberExpression( + memberExpression(identifier('query'), identifier('queryKey')), + numericLiteral(0), + true, + ), + right: stringLiteral('direct-predicate'), + } as BinaryExpression), + ), + ]), + ]), + ).toMatchObject({ segments: ['direct-predicate'], matchMode: 'prefix' }); + }); + + it('covers query key formatting helpers directly', () => { + expect(__queryKeyTestInternals.propertySegmentText(undefined)).toBe('?'); + expect(__queryKeyTestInternals.propertySegmentText({ value: 'select', isStatic: true })).toBe('select'); + expect(__queryKeyTestInternals.queryKeyId([])).toBe('empty'); + expect(__queryKeyTestInternals.queryKeyId(['todos', '$id'])).toBe('todos|$id'); + expect(__queryKeyTestInternals.queryKeySource('static')).toBe('literal'); + expect(__queryKeyTestInternals.queryKeySource('dynamic')).toBe('expression'); + expect(__queryKeyTestInternals.defaultMatchMode({}, 'prefix')).toBe('prefix'); + expect(__queryKeyTestInternals.defaultMatchMode({ defaultMode: 'exact' }, 'prefix')).toBe('exact'); + expect(__queryKeyTestInternals.actionModeFromExact(true)).toBe('exact'); + expect(__queryKeyTestInternals.actionModeFromExact(false)).toBe('prefix'); + expect(__queryKeyTestInternals.actionModeFromExact(undefined)).toBe('prefix'); + expect(__queryKeyTestInternals.predicateMatchMode(true, 'predicate')).toBe('exact'); + expect(__queryKeyTestInternals.predicateMatchMode(false, 'predicate')).toBe('predicate'); + expect(__queryKeyTestInternals.missingActionMode(true)).toBe('predicate'); + expect(__queryKeyTestInternals.missingActionMode(false)).toBe('all'); + expect( + findObjectPropertyValue( + objectExpression([objectProperty(stringLiteral('queryKey'), arrayExpression([stringLiteral('string-key')]))]), + 'queryKey', + ), + ).toEqual(arrayExpression([stringLiteral('string-key')])); + expect( + __queryKeyTestInternals.expressionContainsIdentifier( + { + type: 'FunctionDeclaration', + id: identifier('fn'), + params: [identifier('ignored')], + body: { type: 'BlockStatement', body: [] }, + generator: false, + async: false, + } as never, + 'ignored', + ), + ).toBe(false); + expect( + __queryKeyTestInternals.resolveObjectPropertyExpression( + objectExpression([spreadElement(identifier('notObject'))]), + 'queryKey', + { resolveReference: vi.fn(() => stringLiteral('not-object')), resolveCallResult: vi.fn(() => undefined) }, + 0, + ), + ).toBeUndefined(); + expect( + __queryKeyTestInternals.resolveObjectPropertyExpression( + objectExpression([spreadElement(identifier('nestedObject'))]), + 'queryKey', + { + resolveReference: vi.fn(() => + objectExpression([objectProperty(identifier('queryKey'), stringLiteral('nested-key'))]), + ), + resolveCallResult: vi.fn(() => undefined), + }, + 0, + ), + ).toEqual(stringLiteral('nested-key')); + expect( + __queryKeyTestInternals.simplifyCollectionMethodCallSegment( + { text: 'getQueryCache', isStatic: false }, + { value: 'sort', isStatic: true }, + ), + ).toEqual({ text: 'getQueryCache', isStatic: false }); + expect( + __queryKeyTestInternals.normalizeActionKeyOrWildcard(identifier('queryKey'), { defaultMode: 'exact' }), + ).toMatchObject({ + id: 'pass-through-query-key', + matchMode: 'exact', + }); + }); + + it('covers direct resolver-assisted query key expression branches', () => { + const returnedArray = arrayExpression([stringLiteral('returned')]); + const nestedObject = objectExpression([objectProperty(identifier('target'), stringLiteral('nested'))]); + const resolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'makeKey') { + return arrowFunction(returnedArray); + } + if (node.type === 'Identifier' && node.name === 'resolvedCallee') { + return stringLiteral('callee-value'); + } + if (node.type === 'Identifier' && node.name === 'spreadObject') { + return nestedObject; + } + if (node.type === 'MemberExpression') { + return arrayExpression([stringLiteral('member-ref')]); + } + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'createQueryKey') { + return arrayExpression([null, stringLiteral('fallback')]); + } + return undefined; + }), + }; + const objectResolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'spreadObject') { + return nestedObject; + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + + expect( + resolveQueryKeyExpression( + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('from-object')]))]), + resolver, + ), + ).toEqual(arrayExpression([stringLiteral('from-object')])); + expect( + resolveQueryKeyExpression( + callExpression(identifier('queryOptions'), [arrayExpression([stringLiteral('identity')])]), + undefined, + ), + ).toEqual(arrayExpression([stringLiteral('identity')])); + expect(resolveQueryKeyExpression(callExpression(identifier('makeKey'), [stringLiteral('id')]), resolver)).toEqual( + returnedArray, + ); + expect( + resolveQueryKeyExpression( + callExpression(identifier('createQueryKey'), [ + { type: 'SpreadElement', argument: identifier('args') }, + stringLiteral('second'), + ]), + resolver, + ), + ).toEqual(arrayExpression([null, stringLiteral('second')])); + expect( + resolveQueryKeyExpression( + memberExpression( + objectExpression([objectProperty(identifier('target'), stringLiteral('direct'))]), + identifier('target'), + ), + objectResolver, + ), + ).toEqual(stringLiteral('direct')); + expect( + resolveQueryKeyExpression(memberExpression(identifier('spreadObject'), identifier('target')), objectResolver), + ).toEqual(stringLiteral('nested')); + expect( + resolveQueryKeyExpression( + memberExpression(identifier('unknown'), nonExpression('PrivateName') as never), + resolver, + ), + ).toEqual(arrayExpression([stringLiteral('member-ref')])); + expect(segmentFromExpression(callExpression(identifier('resolvedCallee')), resolver)).toMatchObject({ + text: 'callee-value', + isStatic: true, + }); + expect( + segmentFromExpression(callExpression(identifier('ref'), [stringLiteral('ref-value')]), resolver), + ).toMatchObject({ + text: 'ref-value', + isStatic: true, + }); + expect(segmentFromExpression(callExpression(identifier('useState'), []), resolver)).toMatchObject({ + text: 'UNRESOLVED', + isStatic: false, + }); + expect( + segmentFromExpression({ + type: 'OptionalMemberExpression', + object: identifier('obj'), + property: { type: 'PrivateName', id: identifier('secret') }, + computed: false, + optional: true, + } as never), + ).toMatchObject({ text: '$obj?.?', isStatic: false }); + expect( + segmentFromExpression({ + type: 'OptionalCallExpression', + callee: { + type: 'OptionalMemberExpression', + object: identifier('obj'), + property: stringLiteral('method'), + computed: true, + optional: true, + }, + arguments: [stringLiteral('arg')], + optional: true, + } as never), + ).toMatchObject({ text: '$obj?.[method](arg)' }); + expect(segmentFromExpression(arrowFunction(arrayExpression([])), resolver)).toMatchObject({ + text: 'UNRESOLVED', + isStatic: false, + }); + expect( + segmentFromExpression(identifier('refWithoutArg'), { + resolveReference: vi.fn(() => callExpression(identifier('ref'), [])), + resolveCallResult: vi.fn(() => undefined), + }), + ).toMatchObject({ text: 'expr', isStatic: false }); + expect( + segmentFromExpression(identifier('stateWithoutArg'), { + resolveReference: vi.fn(() => callExpression(identifier('useState'), [])), + resolveCallResult: vi.fn(() => undefined), + }), + ).toMatchObject({ text: 'expr', isStatic: false }); + }); +}); diff --git a/src/core/analysis/__tests__/resolver.test.ts b/src/core/analysis/__tests__/resolver.test.ts new file mode 100644 index 0000000..e16838f --- /dev/null +++ b/src/core/analysis/__tests__/resolver.test.ts @@ -0,0 +1,2711 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { isCallExpression, isExpression, isIdentifier, isVariableDeclaration } from '../ast'; +import { normalizeQueryKey } from '../queryKey'; +import { createQueryKeyResolver, resetResolverCache } from '../resolver'; +import { parseSource } from '../sourceParser'; +import { buildSymbolIndex } from '../symbols'; +import type { NormalizedQueryKey } from '../../../shared/contracts'; + +async function makeWorkspace(): Promise<{ root: string; files: Record }> { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-')); + await mkdir(path.join(root, 'src'), { recursive: true }); + await mkdir(path.join(root, 'packages', 'core', 'src'), { recursive: true }); + + const files = { + 'package.json': JSON.stringify({ name: 'workspace' }), + 'tsconfig.json': JSON.stringify({ + extends: './tsconfig.base.json', + compilerOptions: { + baseUrl: '.', + paths: { + '@app/*': ['src/*'], + }, + }, + }), + 'tsconfig.base.json': JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '@shared/*': ['src/*'], + }, + }, + }), + 'src/values.ts': [ + "import { ref } from 'vue';", + "export const literalKey = ['literal'] as const;", + 'export const aliasKey = literalKey;', + "export const refKey = ref(['ref'] as const);", + "export let mutableKey = ['mutable'] as const;", + "mutableKey = ['changed'] as const;", + 'export function makeKey(id: string) {', + " return ['local', id] as const;", + '}', + ].join('\n'), + 'src/other.ts': ["export { aliasKey as reExportedKey } from '../values';", "export * from '../values';"].join('\n'), + 'packages/core/package.json': JSON.stringify({ name: 'core' }), + 'packages/core/src/index.ts': [ + "export const pkgKey = ['pkg'] as const;", + 'export function makePkgKey(id: string) {', + " return ['pkg', id] as const;", + '}', + "export default function defaultFactory() { return ['default'] as const; }", + ].join('\n'), + 'src/consumer.ts': [ + "import pkgDefault, { makePkgKey, pkgKey } from 'core';", + "import { literalKey as aliasedByTsconfig } from '@shared/values';", + "import { literalKey as importedLocalKey, makeKey, mutableKey, refKey } from '../values';", + "import { reExportedKey } from '../other';", + "import * as values from '../values';", + "const localKey = ['local'] as const;", + 'const directValue = localKey;', + 'const pathAlias = importedLocalKey;', + 'const refAlias = refKey;', + 'const mutableAlias = mutableKey;', + 'const namespaceAlias = values.literalKey;', + 'const reExportAlias = reExportedKey;', + 'const packageAlias = pkgKey;', + 'const tsconfigAlias = aliasedByTsconfig;', + 'const packageNamespaceAlias = pkgDefault;', + "const packagePathAlias = makePkgKey('pkg');", + "const localCall = makeKey('local');", + "const namespaceCall = values.makeKey('ns');", + "const packageCall = makePkgKey('pkg');", + 'const packageDefaultCall = pkgDefault();', + ].join('\n'), + }; + + for (const [relativePath, content] of Object.entries(files)) { + await writeFile(path.join(root, relativePath), content); + } + + return { root, files }; +} + +async function makeAdvancedWorkspace(): Promise<{ root: string; consumerPath: string }> { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-advanced-')); + const files = { + 'package.json': JSON.stringify({ name: 'advanced-workspace' }), + 'app/jsconfig.json': [ + '{', + ' // comments and trailing commas are accepted here', + ' "compilerOptions": {', + ' "baseUrl": ".",', + ' "paths": {', + ' "#exact": ["src/exact"],', + ' "@feature/*": ["features/*"],', + ' },', + ' },', + '}', + ].join('\n'), + 'app/src/exact.ts': "export const exactKey = ['exact'] as const;", + 'app/features/nested/index.ts': [ + "export const nestedKey = ['nested'] as const;", + "export const holder = { queryKey: ['holder'] as const, nested: { queryKey: ['holder-nested'] as const }, 1: ['one'] as const };", + "export const fromOptions = queryOptions({ queryKey: ['options'] as const });", + 'export function createNestedQueryKey(id: string) {', + " return ['nested-call', id] as const;", + '}', + 'export function objectFactory() {', + " return { queryKey: ['object-factory'] as const, tuple: [['tuple-zero'] as const] as const };", + '}', + ].join('\n'), + 'packages/ui/package.json': JSON.stringify({ + name: '@scope/ui', + exports: { + '.': { + import: 'src/main.ts', + }, + }, + }), + 'packages/ui/src/main.ts': [ + "export const uiKey = ['ui'] as const;", + 'export const makeUiQueryKey = (id: string) => {', + " return ['ui-call', id] as const;", + '};', + ].join('\n'), + 'packages/ui/src/sub.ts': "export const subKey = ['sub'] as const;", + 'app/src/consumer.ts': [ + "import { exactKey } from '#exact';", + "import { createNestedQueryKey, fromOptions, holder, nestedKey, objectFactory } from '@feature/nested';", + "import { makeUiQueryKey, uiKey } from '@scope/ui';", + "import { subKey } from '@scope/ui/sub';", + 'const exactAlias = exactKey;', + 'const nestedAlias = nestedKey;', + 'const holderKey = holder.queryKey;', + 'const holderNestedKey = holder.nested.queryKey;', + 'const holderNumericKey = holder[1];', + 'const optionsKey = fromOptions.queryKey;', + 'const objectFactoryKey = objectFactory().queryKey;', + 'const tupleFactoryKey = objectFactory().tuple[0];', + "const nestedCall = createNestedQueryKey('value');", + 'const uiAlias = uiKey;', + "const uiCall = makeUiQueryKey('value');", + 'const subAlias = subKey;', + 'const missingMember = holder.missing;', + ].join('\n'), + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + return { root, consumerPath: path.join(root, 'app/src/consumer.ts') }; +} + +async function makeEdgeWorkspace(): Promise<{ root: string; consumerPath: string; helperPath: string }> { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-edge-')); + const files = { + 'package.json': JSON.stringify({ name: 'edge-workspace' }), + 'app/tsconfig.json': [ + '{', + ' "extends": "./missing-base",', + ' "compilerOptions": {', + ' "baseUrl": ".",', + ' "paths": {', + ' "": ["src/ignored"],', + ' "@empty/*": [],', + ' "@bad/*": [false],', + ' "@mixed/*": [false, "src/*"],', + ' "@dupe/*": ["../other/*", "src/*"]', + ' }', + ' }', + '}', + ].join('\n'), + 'app/src/mixed.ts': "export const mixedKey = ['mixed'] as const;", + 'app/src/chosen.ts': "export const chosenKey = ['near'] as const;", + 'other/chosen.ts': "export const chosenKey = ['far'] as const;", + 'packages/string-pkg/package.json': JSON.stringify({ name: 'string-pkg', exports: 'src/entry.ts' }), + 'packages/string-pkg/src/entry.ts': "export const stringKey = ['string-export'] as const;", + 'packages/main-pkg/package.json': JSON.stringify({ name: 'main-pkg', main: 'src/main.ts' }), + 'packages/main-pkg/src/main.ts': "export const mainKey = ['main-entry'] as const;", + 'packages/types-pkg/package.json': JSON.stringify({ name: 'types-pkg', types: 'src/types.ts' }), + 'packages/types-pkg/src/types.ts': "export const typesKey = ['types-entry'] as const;", + 'packages/ignored/package.json': JSON.stringify({ exports: 'src/index.ts' }), + 'app/src/helper.ts': [ + "export const namespaceFn = () => ['namespace-fn'] as const;", + 'export const namespaceAlias = namespaceFn;', + "export const namespaceObject = { queryKey: ['namespace-object'] as const };", + ].join('\n'), + 'app/src/consumer.ts': [ + "import { chosenKey } from '@dupe/chosen';", + "import { mixedKey } from '@mixed/mixed';", + "import { mainKey } from 'main-pkg';", + "import * as helper from '../helper';", + "import { stringKey } from 'string-pkg';", + "import { typesKey } from 'types-pkg';", + 'const stringAlias = stringKey;', + 'const mainAlias = mainKey;', + 'const typesAlias = typesKey;', + 'const mixedAlias = mixedKey;', + 'const chosenAlias = chosenKey;', + "const propertyName = 'queryKey';", + 'const numericProperty = 1;', + "const spreadSource = { queryKey: ['spread'] as const };", + 'const spreadAlias = { ...spreadSource }[propertyName];', + "const numericHolder = { 1: ['one'] as const };", + 'const numericAlias = numericHolder[numericProperty];', + "const arrayHolder = [['zero'] as const, , ['two'] as const] as const;", + 'const arrayZero = arrayHolder[0];', + 'const arrayHole = arrayHolder[1];', + "const wrappedObject = identity({ queryKey: ['wrapped-object'] as const }).queryKey;", + "const wrappedArray = identity([['wrapped-array'] as const])[0];", + "const frozenAlias = Object.freeze({ queryKey: ['frozen'] as const }).queryKey;", + "const refValue = shallowRef(['shallow'] as const).value;", + 'const helperObjectAlias = helper.namespaceObject.queryKey;', + 'const helperCallAlias = helper.namespaceAlias();', + 'function wrapQueryKey(options: { queryKey: readonly unknown[] }) {', + ' return options;', + '}', + 'function makeNestedCall() {', + " return wrapQueryKey({ queryKey: ['nested-call'] as const });", + '}', + 'function makeNestedArray() {', + " return [['nested-array'] as const, ['second-array'] as const] as const;", + '}', + "const localArrowFunctionQueryKey = () => ['local-arrow-function'] as const;", + "let mutableArrowFunctionQueryKey = () => ['mutable-arrow-function'] as const;", + "mutableArrowFunctionQueryKey = () => ['changed-arrow-function'] as const;", + 'const nestedCallQueryKey = makeNestedCall().queryKey;', + 'const nestedArrayValue = makeNestedArray()[0];', + "function tupleFactory(first = ['default'] as const) {", + " return [first, ['second'] as const] as const;", + '}', + "const tupleFirst = tupleFactory(['provided'] as const)[0];", + 'function complexFactory(seed: readonly unknown[], key: string, keep: boolean) {', + ' return [...seed, { nested: key }.nested, `' + + '$' + + '{key}' + + '`, !keep ? key : "kept", (key, "tail")] as const;', + '}', + "const complexCall = complexFactory(['seed'] as const, 'dynamic', false);", + ].join('\n'), + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + return { + root, + consumerPath: path.join(root, 'app/src/consumer.ts'), + helperPath: path.join(root, 'app/src/helper.ts'), + }; +} + +async function makeResolutionWorkspace(): Promise<{ root: string; consumerPath: string; sources: string[] }> { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-resolution-')); + const files = { + 'package.json': JSON.stringify({ name: 'resolution-workspace' }), + 'tsconfig.json': [ + '{', + ' // alias resolution with comments', + ' "compilerOptions": {', + ' "baseUrl": ".",', + ' "paths": {', + ' "@app/*": ["app/src/*"],', + ' },', + ' },', + '}', + ].join('\n'), + 'app/src/shared/createSharedQueryKey.ts': + "export function createSharedQueryKey(seed: string) { return ['shared', seed] as const; }", + 'app/src/features/createSharedQueryKey.ts': + "export function createSharedQueryKey(seed: string) { return ['shared-alt', seed] as const; }", + 'app/src/unique/createUniqueQueryKey.ts': + "export function createUniqueQueryKey(seed: string) { return ['unique', seed] as const; }", + 'app/src/values.ts': [ + "import { ref, shallowRef } from 'vue';", + "export const literalKey = ['literal'] as const;", + 'export const aliasKey = literalKey;', + 'export function identity(value: T) {', + ' return value;', + '}', + "export const refKey = ref(['ref'] as const);", + "export const shallowKey = shallowRef(['shallow'] as const);", + "export const frozenKey = Object.freeze(['frozen'] as const);", + "export const wrappedObject = { queryKey: ['wrapped'] as const };", + "export const nestedObject = { queryKey: ['nested'] as const, nested: { queryKey: ['nested-deep'] as const }, 1: ['one'] as const };", + "export const objectFactory = () => ({ queryKey: ['factory-object'] as const, nested: { queryKey: ['factory-nested'] as const }, list: [['zero'] as const] as const, call: () => ['factory-call'] as const });", + 'export const nestedCall = () => objectFactory();', + ].join('\n'), + 'packages/other/package.json': JSON.stringify({ + name: 'other-pkg', + exports: { + '.': { + import: 'src/index.ts', + }, + }, + main: 'src/main.ts', + types: 'src/types.ts', + }), + 'packages/other/src/index.ts': [ + "export const pkgKey = ['pkg-key'] as const;", + 'export function makePkgQueryKey(id: string) {', + " return ['pkg', id] as const;", + '}', + "export default function defaultPkg(id: string = 'default') {", + " return ['pkg-default', id] as const;", + '}', + "export const makeObject = () => ({ queryKey: ['pkg-object'] as const, nested: { queryKey: ['pkg-nested'] as const }, call: () => ['pkg-call'] as const, list: [['pkg-array'] as const] as const });", + "export { subKey } from '../sub';", + ].join('\n'), + 'packages/other/src/sub.ts': "export const subKey = ['sub'] as const;", + 'packages/other/src/main.ts': "export const mainKey = ['main-entry'] as const;", + 'packages/other/src/types.ts': "export const typesKey = ['types-entry'] as const;", + 'app/src/consumer.ts': [ + "import pkgDefault, { makeObject, makePkgQueryKey, pkgKey, subKey } from 'other-pkg';", + "import * as pkgNS from 'other-pkg';", + "import { aliasKey, frozenKey, literalKey, nestedCall, nestedObject, objectFactory, refKey, shallowKey, wrappedObject } from '@app/values';", + "const computedName = 'queryKey';", + 'const numericIndex = 1;', + 'const uniqueFromWorkspace = createUniqueQueryKey("unique");', + 'const sharedFromWorkspace = createSharedQueryKey("shared");', + 'const packageCall = makePkgQueryKey("pkg");', + 'const packageDefaultCall = pkgDefault("pkg");', + 'const namespaceCall = pkgNS.makePkgQueryKey("ns");', + 'const namespaceDefaultCall = pkgNS.default("ns");', + 'const packageObjectKey = makeObject().queryKey;', + 'const packageNestedKey = makeObject().nested.queryKey;', + 'const packageListKey = makeObject().list[0];', + 'const packageCallKey = makeObject().call();', + 'const aliasValue = aliasKey;', + 'const literalValue = literalKey;', + 'const identityObjectKey = identity(objectFactory()).queryKey;', + 'const identityArrayKey = identity(objectFactory().list)[0];', + 'const identityNestedCallKey = identity(nestedCall()).queryKey;', + 'const refValue = refKey.value;', + 'const shallowValue = shallowKey.value;', + 'const frozenValue = frozenKey;', + 'const wrappedValue = wrappedObject.queryKey;', + 'const nestedValue = nestedObject.nested.queryKey;', + 'const nestedCallValue = nestedCall().queryKey;', + 'const factoryValue = objectFactory().queryKey;', + 'const factoryNestedValue = objectFactory().nested.queryKey;', + 'const factoryListValue = objectFactory().list[0];', + 'const factoryCallValue = objectFactory().call();', + 'const computedValue = nestedObject[computedName];', + 'const numericValue = nestedObject[numericIndex];', + 'const pkgAlias = pkgKey;', + 'const subAlias = subKey;', + ].join('\n'), + }; + + const sources = Object.keys(files).filter((relativePath) => relativePath.endsWith('.ts')); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + return { root, consumerPath: path.join(root, 'app/src/consumer.ts'), sources }; +} + +async function makeAliasSortingWorkspace(): Promise<{ + root: string; + consumerPath: string; + sources: string[]; +}> { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-alias-sort-')); + const files = { + 'package.json': JSON.stringify({ name: 'alias-sort-workspace' }), + 'app/node_modules/base-tsconfig.json': [ + '{', + ' "compilerOptions": {', + ' "baseUrl": "../src",', + ' "paths": {', + ' "@shared": ["shared"],', + ' "@shared-util": ["shared/util"],', + ' "@shared/*": ["shared/*"],', + ' "@shared-util/*": ["shared/util/*"]', + ' }', + ' }', + '}', + ].join('\n'), + 'app/tsconfig.json': [ + '{', + ' "extends": "base-tsconfig.json",', + ' "compilerOptions": {', + ' "baseUrl": ".",', + ' "paths": {', + ' "@alpha": ["src/alpha"],', + ' "@beta": ["src/beta"],', + ' "@wild/*": ["src/wild/*"]', + ' }', + ' }', + '}', + ].join('\n'), + 'app/src/shared/index.ts': "export const sharedKey = ['shared'] as const;", + 'app/src/shared/util.ts': "export const sharedUtilKey = ['shared-util'] as const;", + 'app/src/shared/item.ts': "export const sharedItemKey = ['shared-item'] as const;", + 'app/src/alpha.ts': "export const alphaKey = ['alpha'] as const;", + 'app/src/beta.ts': "export const betaKey = ['beta'] as const;", + 'app/src/wild/item.ts': "export const wildKey = ['wild'] as const;", + 'app/src/local.ts': [ + 'export function makeLocal(id: string) {', + " return ['local', id] as const;", + '}', + 'export const localAlias = makeLocal;', + "export const arrowFactory = (id: string) => ['arrow', id] as const;", + 'export const arrowAlias = arrowFactory;', + 'export let mutableAlias = makeLocal;', + 'mutableAlias = arrowFactory;', + ].join('\n'), + 'packages/pkg/package.json': JSON.stringify({ + name: 'pkg-exp', + exports: { + '.': { + default: 'src/default.ts', + types: 'src/types.ts', + import: 'src/import.ts', + require: 'src/require.ts', + }, + './sub': { + default: 'src/sub/index.ts', + types: 'src/sub/index.ts', + }, + }, + main: 'src/main.ts', + types: 'src/types.ts', + }), + 'packages/pkg/src/default.ts': + "export default function defaultPkg(id: string = 'default') { return ['pkg-default', id] as const; }", + 'packages/pkg/src/types.ts': "export const typesKey = ['pkg-types'] as const;", + 'packages/pkg/src/import.ts': "export const importKey = ['pkg-import'] as const;", + 'packages/pkg/src/require.ts': "export const requireKey = ['pkg-require'] as const;", + 'packages/pkg/src/main.ts': "export const mainKey = ['pkg-main'] as const;", + 'packages/pkg/src/sub/index.ts': "export const subKey = ['pkg-sub'] as const;", + 'app/src/consumer.ts': [ + "import pkgDefault, { subKey } from 'pkg-exp';", + "import * as pkgNS from 'pkg-exp';", + "import { alphaKey } from '@alpha';", + "import { betaKey } from '@beta';", + "import { sharedItemKey, sharedKey } from '@shared';", + "import { sharedUtilKey } from '@shared-util';", + "import { wildKey } from '@wild/item';", + "import { arrowAlias, localAlias, makeLocal, mutableAlias } from '../local';", + 'const alphaAlias = alphaKey;', + 'const betaAlias = betaKey;', + 'const sharedAlias = sharedKey;', + 'const sharedUtilAlias = sharedUtilKey;', + 'const sharedItemAlias = sharedItemKey;', + 'const wildAlias = wildKey;', + 'const defaultAlias = pkgDefault;', + 'const subAlias = subKey;', + 'const namespaceDefaultAlias = pkgNS.default;', + "const localCall = localAlias('x');", + "const arrowCall = arrowAlias('y');", + "const directCall = makeLocal('z');", + "const mutableCall = mutableAlias('m');", + "const defaultCall = pkgDefault('d');", + "const namespaceCall = pkgNS.default('n');", + ].join('\n'), + }; + + const sources = new Map>(); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + if (relativePath.endsWith('.ts')) { + sources.set(filePath, parseSource(content, filePath)); + } + } + + return { + root, + consumerPath: path.join(root, 'app/src/consumer.ts'), + sources: [...sources.keys()], + }; +} + +function queryKeySegments(queryKey: NormalizedQueryKey): string[] { + return queryKey.segments; +} + +function variableInit(ast: ReturnType, name: string) { + for (const statement of ast.program.body) { + if (!isVariableDeclaration(statement)) { + continue; + } + + const declarator = statement.declarations[0]; + if (!declarator || !isIdentifier(declarator.id) || declarator.id.name !== name) { + continue; + } + + const init = declarator.init; + if (init && isExpression(init)) { + return init; + } + } + + throw new Error(`Missing statement ${name}`); +} + +describe('core/analysis/resolver', () => { + it('resolves local, imported, namespaced, and package symbols', async () => { + const { root } = await makeWorkspace(); + const sources = new Map>(); + + for (const relativePath of ['src/values.ts', 'src/other.ts', 'packages/core/src/index.ts', 'src/consumer.ts']) { + const filePath = path.join(root, relativePath); + sources.set(filePath, parseSource(await readFile(filePath, 'utf8'), filePath)); + } + + const index = buildSymbolIndex(sources); + + resetResolverCache(); + const resolver = createQueryKeyResolver(path.join(root, 'src/consumer.ts'), index, root); + const consumerAst = sources.get(path.join(root, 'src/consumer.ts')); + if (!consumerAst) { + throw new Error('Missing consumer AST'); + } + expect(index.files.size).toBe(4); + + const statements = consumerAst.program.body; + const getInit = (name: string) => { + const statement = statements.find((entry): entry is (typeof statements)[number] => { + const declarator = isVariableDeclaration(entry) ? entry.declarations[0] : undefined; + return ( + isVariableDeclaration(entry) && !!declarator && isIdentifier(declarator.id) && declarator.id.name === name + ); + }); + if (!statement || !isVariableDeclaration(statement)) { + throw new Error(`Missing statement ${name}`); + } + const init = statement.declarations[0]?.init; + return init && isExpression(init) ? init : undefined; + }; + + const directValue = getInit('directValue'); + const pathAlias = getInit('pathAlias'); + const refAlias = getInit('refAlias'); + const mutableAlias = getInit('mutableAlias'); + const namespaceAlias = getInit('namespaceAlias'); + const reExportAlias = getInit('reExportAlias'); + const packageAlias = getInit('packageAlias'); + const tsconfigAlias = getInit('tsconfigAlias'); + const packageNamespaceAlias = getInit('packageNamespaceAlias'); + const localCall = getInit('localCall'); + const packagePathAlias = getInit('packagePathAlias'); + const namespaceCall = getInit('namespaceCall'); + const packageCall = getInit('packageCall'); + const packageDefaultCall = getInit('packageDefaultCall'); + + if (directValue) { + expect(queryKeySegments(normalizeQueryKey(directValue, { defaultMode: 'exact' }, resolver))).toEqual(['local']); + } + if (pathAlias) { + expect(queryKeySegments(normalizeQueryKey(pathAlias, { defaultMode: 'exact' }, resolver)).length).toBeGreaterThan( + 0, + ); + } + if (refAlias) { + expect(queryKeySegments(normalizeQueryKey(refAlias, { defaultMode: 'exact' }, resolver)).length).toBeGreaterThan( + 0, + ); + } + if (mutableAlias) { + expect(queryKeySegments(normalizeQueryKey(mutableAlias, { defaultMode: 'exact' }, resolver))).toEqual([ + '$mutableKey', + ]); + } + if (namespaceAlias) { + expect( + queryKeySegments(normalizeQueryKey(namespaceAlias, { defaultMode: 'exact' }, resolver)).length, + ).toBeGreaterThan(0); + } + if (reExportAlias) { + expect( + queryKeySegments(normalizeQueryKey(reExportAlias, { defaultMode: 'exact' }, resolver)).length, + ).toBeGreaterThan(0); + } + if (packageAlias) { + expect(queryKeySegments(normalizeQueryKey(packageAlias, { defaultMode: 'exact' }, resolver))).toEqual(['pkg']); + } + if (tsconfigAlias) { + expect(queryKeySegments(normalizeQueryKey(tsconfigAlias, { defaultMode: 'exact' }, resolver))).toEqual([ + 'literal', + ]); + } + if (packageNamespaceAlias) { + expect(queryKeySegments(normalizeQueryKey(packageNamespaceAlias, { defaultMode: 'exact' }, resolver))).toEqual([ + 'default', + ]); + } + + if (localCall && isCallExpression(localCall)) { + expect(queryKeySegments(normalizeQueryKey(localCall, { defaultMode: 'exact' }, resolver)).length).toBeGreaterThan( + 0, + ); + } + if (packagePathAlias && isCallExpression(packagePathAlias)) { + expect(queryKeySegments(normalizeQueryKey(packagePathAlias, { defaultMode: 'exact' }, resolver))).toEqual([ + 'pkg', + '$id', + ]); + } + if (namespaceCall && isCallExpression(namespaceCall)) { + expect( + queryKeySegments(normalizeQueryKey(namespaceCall, { defaultMode: 'exact' }, resolver)).length, + ).toBeGreaterThan(0); + } + if (packageCall && isCallExpression(packageCall)) { + expect(queryKeySegments(normalizeQueryKey(packageCall, { defaultMode: 'exact' }, resolver))).toEqual([ + 'pkg', + '$id', + ]); + } + if (packageDefaultCall && isCallExpression(packageDefaultCall)) { + expect(queryKeySegments(normalizeQueryKey(packageDefaultCall, { defaultMode: 'exact' }, resolver))).toEqual([ + 'default', + ]); + } + + resetResolverCache(); + }); + + it('resolves jsconfig aliases, lazily indexed files, package exports, and member chains', async () => { + const { root, consumerPath } = await makeAdvancedWorkspace(); + const consumerAst = parseSource(await readFile(consumerPath, 'utf8'), consumerPath); + const subPath = path.join(root, 'packages/ui/src/sub.ts'); + const subAst = parseSource(await readFile(subPath, 'utf8'), subPath); + const index = buildSymbolIndex( + new Map([ + [consumerPath, consumerAst], + [subPath, subAst], + ]), + ); + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, index, root); + const expectSegments = (name: string, expected: string[]) => { + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, name), { defaultMode: 'exact' }, resolver)), + ).toEqual(expected); + }; + + expectSegments('exactAlias', ['exact']); + expectSegments('nestedAlias', ['nested']); + expectSegments('holderKey', ['holder']); + expectSegments('holderNestedKey', ['holder-nested']); + expectSegments('holderNumericKey', ['one']); + expectSegments('optionsKey', ['options']); + expectSegments('objectFactoryKey', ['object-factory']); + expectSegments('tupleFactoryKey', ['tuple-zero']); + expectSegments('nestedCall', ['nested-call', '$id']); + expectSegments('uiAlias', ['ui']); + expectSegments('uiCall', ['ui-call', 'value']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'subAlias'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expectSegments('missingMember', ['[holder].missing']); + expect(index.files.size).toBeGreaterThan(0); + + resetResolverCache(); + }); + + it('resolves edge aliases, package entry fields, wrapped members, and hinted call arguments', async () => { + const { root, consumerPath, helperPath } = await makeEdgeWorkspace(); + const consumerAst = parseSource(await readFile(consumerPath, 'utf8'), consumerPath); + const helperAst = parseSource(await readFile(helperPath, 'utf8'), helperPath); + const index = buildSymbolIndex( + new Map([ + [consumerPath, consumerAst], + [helperPath, helperAst], + ]), + ); + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, index, root); + const expectSegments = (name: string, expected: string[]) => { + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, name), { defaultMode: 'exact' }, resolver)), + ).toEqual(expected); + }; + + expectSegments('stringAlias', ['string-export']); + expectSegments('mainAlias', ['main-entry']); + expectSegments('typesAlias', ['types-entry']); + expectSegments('mixedAlias', ['mixed']); + expectSegments('chosenAlias', ['near']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'spreadAlias'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expectSegments('numericAlias', ['{1: [one]}.numericProperty']); + expectSegments('arrayZero', ['zero']); + expectSegments('arrayHole', ['[[zero], undefined, [two]].1']); + expectSegments('wrappedObject', ['wrapped-object']); + expectSegments('wrappedArray', ['wrapped-array']); + expectSegments('frozenAlias', ['frozen']); + expectSegments('refValue', ['shallow']); + expectSegments('helperObjectAlias', ['$helper.namespaceObject.queryKey']); + expectSegments('helperCallAlias', ['$helper.namespaceAlias()']); + expectSegments('nestedCallQueryKey', ['nested-call']); + expectSegments('nestedArrayValue', ['nested-array']); + expectSegments('tupleFirst', ['provided']); + expectSegments('complexCall', ['$seed', '$key', '$' + '{key}', 'cond(...)', 'UNRESOLVED']); + + expect(index.files.size).toBeGreaterThan(2); + resetResolverCache(); + }); + + it('parses block comments and escaped strings while resolving aliases', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-comments-')); + const files = { + 'package.json': JSON.stringify({ name: 'comment-workspace' }), + 'tsconfig.json': [ + '{', + ' /* block comment */', + ' "compilerOptions": {', + ' "baseUrl": ".",', + ' "paths": {', + ' "@escaped/*": ["src/*"],', + ' },', + ' },', + ' "description": "value with a \\"quoted\\" string",', + '}', + ].join('\n'), + 'src/escape.ts': "export const escapeKey = ['escape'] as const;", + 'src/consumer.ts': ["import { escapeKey } from '@escaped/escape';", 'const alias = escapeKey;'].join('\n'), + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + const consumerPath = path.join(root, 'src/consumer.ts'); + const escapePath = path.join(root, 'src/escape.ts'); + const consumerAst = parseSource(await readFile(consumerPath, 'utf8'), consumerPath); + const escapeAst = parseSource(await readFile(escapePath, 'utf8'), escapePath); + const index = buildSymbolIndex( + new Map([ + [consumerPath, consumerAst], + [escapePath, escapeAst], + ]), + ); + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, index, root); + + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'alias'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['escape']); + + resetResolverCache(); + }); + + it('resolves local function aliases, wrapped objects, tuple indexes, ref values, and namespace calls', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-wrappers-')); + const files = { + 'package.json': JSON.stringify({ name: 'wrapper-workspace' }), + 'src/values.ts': [ + "import { shallowRef } from 'vue';", + "export const localFactory = (seed = ['seed'] as const) => ['factory', seed] as const;", + 'export const localAlias = localFactory;', + "export const objectWrapper = Object.freeze({ queryKey: ['wrapped'] as const, nested: { queryKey: ['nested'] as const }, tuple: [['tuple'] as const] as const });", + "export const refValue = shallowRef(['ref'] as const).value;", + "export const namespaceFn = () => ['namespace'] as const;", + ].join('\n'), + 'src/consumer.ts': [ + "import { localAlias, namespaceFn, objectWrapper, refValue } from '../values';", + "import * as values from '../values';", + "const aliasCall = localAlias('value');", + 'const wrappedKey = objectWrapper.queryKey;', + 'const wrappedNestedKey = objectWrapper.nested.queryKey;', + 'const wrappedTuple = objectWrapper.tuple[0];', + 'const refAlias = refValue;', + 'const namespaceCall = namespaceFn();', + 'const namespaceAlias = values.namespaceFn;', + ].join('\n'), + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + const sources = new Map>(); + for (const relativePath of Object.keys(files).filter((value) => value.endsWith('.ts'))) { + const filePath = path.join(root, relativePath); + sources.set(filePath, parseSource(await readFile(filePath, 'utf8'), filePath)); + } + + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = sources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing consumer AST'); + } + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, buildSymbolIndex(sources), root); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'aliasCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'wrappedKey'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'wrappedNestedKey'), { defaultMode: 'exact' }, resolver), + ).length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'wrappedTuple'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'refAlias'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'namespaceCall'), { defaultMode: 'exact' }, resolver), + ).length, + ).toBeGreaterThan(0); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'namespaceAlias'), { defaultMode: 'exact' }, resolver), + ).length, + ).toBeGreaterThan(0); + + resetResolverCache(); + }); + + it('resolves unique workspace query key factories without imports', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-workspace-factory-')); + const files = { + 'src/consumer.ts': [ + "const uniqueCall = makeTodoQueryKey('42');", + "const rqCall = buildRqKey('rq');", + "const ambiguousCall = makeSharedQueryKey('ambiguous');", + "const ignoredCall = makeKey('ignored');", + ].join('\n'), + 'src/factory.ts': [ + 'export function makeTodoQueryKey(id = "fallback") {', + " return ['todo', id] as const;", + '}', + "export const buildRqKey = (id: string) => ['rq', id] as const;", + ].join('\n'), + 'src/a/shared.ts': "export const makeSharedQueryKey = (id: string) => ['a', id] as const;", + 'src/b/shared.ts': "export const makeSharedQueryKey = (id: string) => ['b', id] as const;", + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + const sources = new Map>(); + for (const relativePath of Object.keys(files)) { + const filePath = path.join(root, relativePath); + sources.set(filePath, parseSource(await readFile(filePath, 'utf8'), filePath)); + } + + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = sources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing consumer AST'); + } + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, buildSymbolIndex(sources), root); + + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'uniqueCall'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['todo', '$id']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'rqCall'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['rq', '$id']); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'ambiguousCall'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(['call(makeSharedQueryKey)']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'ignoredCall'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['call(makeKey)']); + + resetResolverCache(); + }); + + it('fails closed for missing modules, invalid configs, and unresolvable members', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-fail-closed-')); + const files = { + 'app/tsconfig.json': [ + '{', + ' "compilerOptions": {', + ' "baseUrl": ".",', + ' "paths": {', + ' "@missing/*": ["missing/*"],', + ' "@empty/*": [],', + ' "@invalid/*": [false],', + ' },', + ' },', + '}', + ].join('\n'), + 'app/src/consumer.ts': [ + "import { missingKey } from '@missing/key';", + "import { emptyKey } from '@empty/key';", + "import { invalidKey } from '@invalid/key';", + "import * as noFile from '../missing';", + 'const missingAlias = missingKey;', + 'const emptyAlias = emptyKey;', + 'const invalidAlias = invalidKey;', + 'const namespaceMissing = noFile.key;', + 'const computedMissing = namespaceMissing[propertyName];', + 'const outOfBounds = tupleFactory()[5];', + "function tupleFactory() { return [['zero'] as const] as const; }", + ].join('\n'), + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + const consumerPath = path.join(root, 'app/src/consumer.ts'); + const consumerAst = parseSource(await readFile(consumerPath, 'utf8'), consumerPath); + const index = buildSymbolIndex(new Map([[consumerPath, consumerAst]])); + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, index, root); + const expectSegments = (name: string, expected: string[]) => { + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, name), { defaultMode: 'exact' }, resolver)), + ).toEqual(expected); + }; + + expectSegments('missingAlias', ['$missingKey']); + expectSegments('emptyAlias', ['$emptyKey']); + expectSegments('invalidAlias', ['$invalidKey']); + expectSegments('namespaceMissing', ['$noFile.key']); + expectSegments('computedMissing', ['$namespaceMissing.propertyName']); + expectSegments('outOfBounds', ['[[zero]].5']); + + resetResolverCache(); + }); + + it('resolves path alias sorting, package export fallbacks, and mutable local function aliases', async () => { + const { root, consumerPath, sources } = await makeAliasSortingWorkspace(); + const sourceMaps = new Map>(); + + for (const filePath of sources) { + sourceMaps.set(filePath, parseSource(await readFile(filePath, 'utf8'), filePath)); + } + + const consumerAst = sourceMaps.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing consumer AST'); + } + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, buildSymbolIndex(sourceMaps), root); + const expectSegments = (name: string, expected: string[]) => { + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, name), { defaultMode: 'exact' }, resolver)), + ).toEqual(expected); + }; + + expectSegments('alphaAlias', ['alpha']); + expectSegments('betaAlias', ['beta']); + expectSegments('sharedAlias', ['shared']); + expectSegments('sharedUtilAlias', ['shared-util']); + expectSegments('sharedItemAlias', ['$sharedItemKey']); + expectSegments('wildAlias', ['wild']); + expectSegments('defaultAlias', ['pkg-default', '$id']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'subAlias'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'localCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'arrowCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'directCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'defaultCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'namespaceCall'), { defaultMode: 'exact' }, resolver), + ).length, + ).toBeGreaterThan(0); + + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'mutableCall'), { defaultMode: 'exact' }, resolver), + ).join('/'), + ).toContain('mutableAlias'); + + expect(sourceMaps.size).toBeGreaterThan(0); + resetResolverCache(); + }); + + it('resolves function returns through star and renamed re-exports', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-reexport-fn-')); + const files = { + 'src/base.ts': [ + 'export function makeStarQueryKey(id: string) {', + " return ['star', id] as const;", + '}', + "export const aliasFactory = (kind: string) => ['alias', kind] as const;", + "let mutableFactory = () => ['old'] as const;", + "mutableFactory = () => ['new'] as const;", + 'export { mutableFactory };', + ].join('\n'), + 'src/barrel.ts': [ + "export * from '../base';", + "export { makeStarQueryKey as renamedQueryKey, aliasFactory as renamedAliasFactory } from '../base';", + ].join('\n'), + 'src/consumer.ts': [ + "import { aliasFactory, makeStarQueryKey, mutableFactory, renamedAliasFactory, renamedQueryKey } from '../barrel';", + "import * as barrel from '../barrel';", + "const starCall = makeStarQueryKey('one');", + "const renamedCall = renamedQueryKey('two');", + "const aliasCall = aliasFactory('three');", + "const renamedAliasCall = renamedAliasFactory('four');", + "const namespaceCall = barrel.makeStarQueryKey('five');", + 'const mutableCall = mutableFactory();', + ].join('\n'), + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + const sources = new Map>(); + for (const relativePath of Object.keys(files)) { + const filePath = path.join(root, relativePath); + sources.set(filePath, parseSource(await readFile(filePath, 'utf8'), filePath)); + } + + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = sources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing consumer AST'); + } + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, buildSymbolIndex(sources), root); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'starCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'renamedCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'aliasCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'renamedAliasCall'), { defaultMode: 'exact' }, resolver), + ).length, + ).toBeGreaterThan(0); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'namespaceCall'), { defaultMode: 'exact' }, resolver), + ).length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'mutableCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + + resetResolverCache(); + }); + + it('resolves nested wrapper chains, member access, and optional access in a local workspace', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-nested-')); + const files = { + 'src/base.ts': [ + "export const baseObject = { queryKey: ['base'] as const, nested: { queryKey: ['nested'] as const }, 1: ['one'] as const };", + 'export const wrapObject = (options: { queryKey: readonly unknown[] }) => options;', + "export const makeObject = () => ({ queryKey: ['call-object'] as const, nested: { queryKey: ['nested-object'] as const } });", + "export const makeArray = () => [['call-array'] as const, ['second-array'] as const] as const;", + "export const frozenObject = Object.freeze({ queryKey: ['frozen'] as const });", + 'export const aliasFactory = wrapObject;', + "export default function defaultFactory() { return { queryKey: ['default'] as const }; }", + ].join('\n'), + 'src/consumer.ts': [ + "import defaultFactory, * as base from '../base';", + "import { aliasFactory, baseObject, frozenObject, makeArray, makeObject, wrapObject } from '../base';", + 'const spreadObject = { ...baseObject };', + 'const spreadAlias = spreadObject.queryKey;', + 'const nestedAlias = baseObject.nested.queryKey;', + 'const numericAlias = baseObject[1];', + 'const callObject = makeObject().queryKey;', + 'const callNested = makeObject().nested.queryKey;', + 'const callArray = makeArray()[0];', + 'const callArraySecond = makeArray()[1];', + "const wrappedCall = wrapObject({ queryKey: ['wrapped'] as const }).queryKey;", + "const aliasWrappedCall = aliasFactory({ queryKey: ['alias-wrap'] as const }).queryKey;", + 'const frozenAlias = frozenObject.queryKey;', + 'const defaultAlias = defaultFactory().queryKey;', + 'const namespaceCall = base.makeObject().queryKey;', + 'const namespaceObject = base.baseObject.queryKey;', + 'const optionalAlias = baseObject?.queryKey;', + ].join('\n'), + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + const sources = new Map>(); + for (const relativePath of Object.keys(files)) { + const filePath = path.join(root, relativePath); + sources.set(filePath, parseSource(await readFile(filePath, 'utf8'), filePath)); + } + + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = sources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing consumer AST'); + } + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, buildSymbolIndex(sources), root); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'spreadAlias'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'nestedAlias'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'numericAlias'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'callObject'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'callNested'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'callArray'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'callArraySecond'), { defaultMode: 'exact' }, resolver), + ).length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'wrappedCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'aliasWrappedCall'), { defaultMode: 'exact' }, resolver), + ).length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'frozenAlias'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'defaultAlias'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'namespaceCall'), { defaultMode: 'exact' }, resolver), + ).length, + ).toBeGreaterThan(0); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'namespaceObject'), { defaultMode: 'exact' }, resolver), + ).length, + ).toBeGreaterThan(0); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'optionalAlias'), { defaultMode: 'exact' }, resolver), + ).length, + ).toBeGreaterThan(0); + + resetResolverCache(); + }); + + it('resolves local and namespace call results through identifier and member callees', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-call-result-')); + const files = { + 'src/base.ts': [ + "export const makeBaseKey = () => ['base'] as const;", + 'export const makeObject = () => ({ queryKey: makeBaseKey() });', + 'export const nested = { makeBaseKey };', + ].join('\n'), + 'src/consumer.ts': [ + "import * as base from '../base';", + "const directFn = () => ['direct'] as const;", + 'const aliasFn = directFn;', + 'const objectHolder = { fn: directFn };', + 'const aliasHolder = { fn: aliasFn };', + 'const callDirect = directFn();', + 'const callAlias = aliasFn();', + 'const callObject = objectHolder.fn();', + 'const callAliasObject = aliasHolder.fn();', + 'const callNamespace = base.makeBaseKey();', + 'const callNamespaceObject = base.makeObject();', + ].join('\n'), + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + const sources = new Map>(); + for (const relativePath of Object.keys(files)) { + const filePath = path.join(root, relativePath); + sources.set(filePath, parseSource(await readFile(filePath, 'utf8'), filePath)); + } + + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = sources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing consumer AST'); + } + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, buildSymbolIndex(sources), root); + const callCallee = (name: string) => { + const callExpression = variableInit(consumerAst, name); + if (!callExpression || !isCallExpression(callExpression)) { + throw new Error(`Missing call expression ${name}`); + } + return callExpression.callee; + }; + + expect( + normalizeQueryKey(resolver.resolveCallResult(callCallee('callDirect')), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: ['direct'], + }); + expect( + normalizeQueryKey(resolver.resolveCallResult(callCallee('callAlias')), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: ['direct'], + }); + expect( + normalizeQueryKey(resolver.resolveCallResult(callCallee('callObject')), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: ['direct'], + }); + expect( + normalizeQueryKey(resolver.resolveCallResult(callCallee('callAliasObject')), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: ['direct'], + }); + expect( + normalizeQueryKey(resolver.resolveCallResult(callCallee('callNamespace')), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: ['UNRESOLVED'], + }); + expect( + normalizeQueryKey( + resolver.resolveCallResult(callCallee('callNamespaceObject')), + { defaultMode: 'exact' }, + resolver, + ), + ).toMatchObject({ + segments: ['UNRESOLVED'], + }); + + resetResolverCache(); + }); + + it('resolves workspace-search factories, package members, and nested wrapper paths', async () => { + const { root, consumerPath, sources } = await makeResolutionWorkspace(); + const parsedSources = new Map>(); + for (const relativePath of sources) { + const filePath = path.join(root, relativePath); + parsedSources.set(filePath, parseSource(await readFile(filePath, 'utf8'), filePath)); + } + + const consumerAst = parsedSources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing consumer AST'); + } + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, buildSymbolIndex(parsedSources), root); + const expectSegments = (name: string, expected: string[]) => { + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, name), { defaultMode: 'exact' }, resolver)), + ).toEqual(expected); + }; + + const uniqueCall = variableInit(consumerAst, 'uniqueFromWorkspace'); + if (!uniqueCall || !isCallExpression(uniqueCall)) { + throw new Error('Missing uniqueFromWorkspace call'); + } + const sharedCall = variableInit(consumerAst, 'sharedFromWorkspace'); + if (!sharedCall || !isCallExpression(sharedCall)) { + throw new Error('Missing sharedFromWorkspace call'); + } + + expect( + normalizeQueryKey(resolver.resolveCallResult(uniqueCall.callee), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: ['unique', '$seed'], + }); + expect(resolver.resolveCallResult(sharedCall.callee)).toBeUndefined(); + + expectSegments('packageCall', ['pkg', '$id']); + expectSegments('packageDefaultCall', ['pkg-default', '$id']); + expectSegments('namespaceCall', ['pkg', '$id']); + expectSegments('namespaceDefaultCall', ['pkg-default', '$id']); + expectSegments('packageObjectKey', ['pkg-object']); + expectSegments('packageNestedKey', ['pkg-nested']); + expectSegments('packageListKey', ['pkg-array']); + expectSegments('packageCallKey', ['pkg-call']); + expectSegments('aliasValue', ['literal']); + expectSegments('literalValue', ['literal']); + expectSegments('identityObjectKey', ['factory-object']); + expectSegments('identityArrayKey', ['call(identity).0']); + expectSegments('identityNestedCallKey', ['factory-object']); + expectSegments('refValue', ['ref']); + expectSegments('shallowValue', ['shallow']); + expectSegments('frozenValue', ['frozen']); + expectSegments('wrappedValue', ['wrapped']); + expectSegments('nestedValue', ['nested-deep']); + expectSegments('nestedCallValue', ['factory-object']); + expectSegments('factoryValue', ['factory-object']); + expectSegments('factoryNestedValue', ['factory-nested']); + expectSegments('factoryListValue', ['zero']); + expectSegments('factoryCallValue', ['factory-call']); + expectSegments('computedValue', ['[nested].computedName']); + expectSegments('numericValue', ['[nested].numericIndex']); + expectSegments('pkgAlias', ['pkg-key']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'subAlias'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + + const packageNamespaceCallee = variableInit(consumerAst, 'namespaceCall'); + if (!packageNamespaceCallee || !isCallExpression(packageNamespaceCallee)) { + throw new Error('Missing namespaceCall'); + } + expect( + normalizeQueryKey(resolver.resolveCallResult(packageNamespaceCallee.callee), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: ['pkg', '$id'], + }); + + const packageDefaultCallee = variableInit(consumerAst, 'packageDefaultCall'); + if (!packageDefaultCallee || !isCallExpression(packageDefaultCallee)) { + throw new Error('Missing packageDefaultCall'); + } + expect( + normalizeQueryKey(resolver.resolveCallResult(packageDefaultCallee.callee), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: ['pkg-default', '$id'], + }); + + resetResolverCache(); + }); + + it('inlines function arguments through declaration, expression, and wrapper call chains', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-hints-')); + const valuesPath = path.join(root, 'src/values.ts'); + const consumerPath = path.join(root, 'src/consumer.ts'); + const files = { + 'package.json': JSON.stringify({ name: 'hints-workspace' }), + 'src/values.ts': [ + "export const objectAlias = { queryKey: ['base'] as const, nested: { queryKey: ['nested'] as const }, tuple: [['tuple'] as const] as const, call: () => ['call'] as const, alias: ['alias'] as const };", + 'export const nestedAlias = objectAlias.nested;', + "export const arrayAlias = [['array'] as const, ['second'] as const] as const;", + 'export function declReturn(id: string, suffix: string) {', + ' return [id, suffix, { queryKey: [id] as const }, Object.freeze({ queryKey: [suffix] as const }).queryKey, `' + + '$' + + '{id}-' + + '$' + + '{suffix}`] as const;', + '}', + 'export const exprReturn = function exprReturn(id: string, suffix: string) {', + ' return [id, suffix, { queryKey: [suffix] as const }, [id, suffix], (id, suffix)] as const;', + '};', + 'export const arrowReturn = (id: string, suffix: string) => [id, suffix, { queryKey: [id] as const }, [suffix] as const] as const;', + 'export const templateReturn = (id: string) => [`template-' + '$' + '{id}`] as const;', + 'export const sequenceReturn = (id: string, suffix: string) => [(id, suffix)] as const;', + 'export const identity = (value: T) => value;', + ].join('\n'), + 'src/consumer.ts': [ + "import { arrayAlias, arrowReturn, declReturn, exprReturn, identity, nestedAlias, objectAlias, sequenceReturn, templateReturn } from '../values';", + "const declCall = declReturn('left', 'right');", + "const exprCall = exprReturn('left', 'right');", + "const arrowCall = arrowReturn('left', 'right');", + "const templateCall = templateReturn('left');", + "const sequenceCall = sequenceReturn('left', 'right');", + 'const nestedObjectKey = objectAlias.nested.queryKey;', + 'const nestedAliasKey = nestedAlias.queryKey;', + 'const tupleKey = objectAlias.tuple[0];', + 'const callKey = objectAlias.call();', + 'const arrayFirst = arrayAlias[0];', + "const identityCall = identity({ queryKey: ['identity'] as const }).queryKey;", + "const computedKey = objectAlias['queryKey'];", + 'const aliasKey = objectAlias.alias;', + ].join('\n'), + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + const consumerAst = parseSource(await readFile(consumerPath, 'utf8'), consumerPath); + const valuesAst = parseSource(await readFile(valuesPath, 'utf8'), valuesPath); + const index = buildSymbolIndex( + new Map([ + [consumerPath, consumerAst], + [valuesPath, valuesAst], + ]), + ); + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, index, root); + + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'declCall'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['call(declReturn)']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'exprCall'), { defaultMode: 'exact' }, resolver)), + ).toEqual(expect.any(Array)); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'arrowCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'templateCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'sequenceCall'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(expect.any(Array)); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'nestedObjectKey'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(expect.any(Array)); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'nestedAliasKey'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(expect.any(Array)); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'tupleKey'), { defaultMode: 'exact' }, resolver)), + ).toEqual(expect.any(Array)); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'callKey'), { defaultMode: 'exact' }, resolver)), + ).toEqual(expect.any(Array)); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'arrayFirst'), { defaultMode: 'exact' }, resolver)), + ).toEqual(expect.any(Array)); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'identityCall'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(expect.any(Array)); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'computedKey'), { defaultMode: 'exact' }, resolver)), + ).toEqual(expect.any(Array)); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'aliasKey'), { defaultMode: 'exact' }, resolver)), + ).toEqual(expect.any(Array)); + + const declCall = variableInit(consumerAst, 'declCall'); + if (!declCall || !isCallExpression(declCall)) { + throw new Error('Missing declCall'); + } + expect( + normalizeQueryKey(resolver.resolveCallResult(declCall.callee), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + segments: expect.any(Array), + }); + + resetResolverCache(); + }); + + it('handles alias config edge cases without narrowing resolution scope', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-config-')); + const absoluteTargetDir = path.join(root, 'shared'); + const files = { + 'shared/abs.ts': "export const absoluteKey = ['absolute'] as const;", + 'app/tsconfig.json': [ + '{', + ' "extends": "./base",', + ' "compilerOptions": {', + ' "baseUrl": ".",', + ' "paths": {', + ` "@abs/*": ["${absoluteTargetDir.split('\\').join('/')}/*"],`, + ' "@empty/*": [],', + ' "@mixed/*": [false, "src/*"],', + ' "": ["src/ignored"]', + ' }', + ' }', + '}', + ].join('\n'), + 'app/base.json': [ + '{', + ' "extends": " ",', + ' "compilerOptions": {', + ' "baseUrl": ".",', + ' "paths": {', + ' "@base/*": ["src/base/*"],', + ' },', + ' },', + '}', + ].join('\n'), + 'app/src/base/key.ts': "export const baseKey = ['base'] as const;", + 'app/src/mixed.ts': "export const mixedKey = ['mixed'] as const;", + 'app/src/consumer.ts': [ + "import { absoluteKey } from '@abs/abs';", + "import { baseKey } from '@base/key';", + "import { mixedKey } from '@mixed/mixed';", + 'const absoluteAlias = absoluteKey;', + 'const baseAlias = baseKey;', + 'const mixedAlias = mixedKey;', + ].join('\n'), + 'invalid/tsconfig.json': '', + 'invalid/src/consumer.ts': [ + "import { missingKey } from '@missing/key';", + 'const missingAlias = missingKey;', + ].join('\n'), + 'circular/a/tsconfig.json': JSON.stringify({ extends: '../b/tsconfig.json' }), + 'circular/b/tsconfig.json': JSON.stringify({ extends: '../a/tsconfig.json' }), + 'circular/a/src/consumer.ts': "const localKey = ['local'] as const;", + }; + + const parsedSources = new Map>(); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + if (relativePath.endsWith('.ts')) { + parsedSources.set(filePath, parseSource(content, filePath)); + } + } + + resetResolverCache(); + const index = buildSymbolIndex(parsedSources); + const consumerPath = path.join(root, 'app/src/consumer.ts'); + const consumerAst = parsedSources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing config consumer'); + } + const resolver = createQueryKeyResolver(consumerPath, index, root); + + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'absoluteAlias'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(['absolute']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'baseAlias'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['base']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'mixedAlias'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['mixed']); + + const invalidPath = path.join(root, 'invalid/src/consumer.ts'); + const invalidAst = parsedSources.get(invalidPath); + if (!invalidAst) { + throw new Error('Missing invalid consumer'); + } + const invalidResolver = createQueryKeyResolver(invalidPath, index, root); + expect( + normalizeQueryKey(variableInit(invalidAst, 'missingAlias'), { defaultMode: 'exact' }, invalidResolver), + ).toMatchObject({ + source: 'expression', + resolution: 'dynamic', + }); + + const circularPath = path.join(root, 'circular/a/src/consumer.ts'); + const circularAst = parsedSources.get(circularPath); + if (!circularAst) { + throw new Error('Missing circular consumer'); + } + const circularResolver = createQueryKeyResolver(circularPath, index, root); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(circularAst, 'localKey'), { defaultMode: 'exact' }, circularResolver), + ), + ).toEqual(['local']); + + resetResolverCache(); + }); + + it('applies complex function argument hints through resolver call resolution', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-hints-')); + const files = { + 'package.json': JSON.stringify({ name: 'hint-workspace' }), + 'src/consumer.ts': [ + 'const identity = (value) => value;', + 'const holder = { key: ["holder"] as const };', + 'function complexFactory(source, prop, fn, arg, fallback, flag, yes, no, rest) {', + ' return [', + ' source[prop],', + ' fn(arg),', + ' `' + '$' + '{arg}` ,', + ' !flag,', + ' arg + fallback,', + ' flag && fallback,', + ' flag ? yes : no,', + ' (fallback, arg),', + ' (arg),', + ' ...rest,', + ' { ...source, copied: arg },', + ' ] as const;', + '}', + 'function returnsWrappedArray() {', + ' return identity([["nested-array"] as const, , ["second"] as const] as const);', + '}', + 'function returnsWrappedObject() {', + ' return identity({ queryKey: ["nested-object"] as const });', + '}', + "const arrowFactory = (value = 'arrow') => ['arrow', value] as const;", + 'const nonFunctionValue = ["plain"] as const;', + 'const makeValueQueryKey = ["value-query"] as const;', + 'const makeNoReturnQueryKey = () => {};', + "const makeArrowQueryKey = (id: string) => ['arrow-query', id] as const;", + "const localArrowFunctionQueryKey = () => ['local-arrow-function'] as const;", + "let mutableArrowFunctionQueryKey = () => ['mutable-arrow-function'] as const;", + "mutableArrowFunctionQueryKey = () => ['changed-arrow-function'] as const;", + 'const complexCall = complexFactory(holder, "key", identity, "arg", "fallback", true, "yes", "no", ["tail"] as const);', + 'const aliasFactory = complexFactory;', + 'const arrowFactoryAlias = arrowFactory;', + 'const plainAlias = nonFunctionValue;', + 'const aliasCall = aliasFactory(holder, "key", identity, "alias", "fallback", false, "yes", "no", ["tail"] as const);', + 'const arrowCall = arrowFactoryAlias("value");', + 'const plainCall = plainAlias();', + 'const nestedArrayValue = returnsWrappedArray()[0];', + 'const nestedArrayHole = returnsWrappedArray()[1];', + 'const nestedObjectValue = returnsWrappedObject().queryKey;', + ].join('\n'), + 'src/a.ts': "export const makeTieQueryKey = (id: string) => ['a', id] as const;", + 'src/b.ts': "export const makeTieQueryKey = (id: string) => ['b', id] as const;", + }; + + const parsedSources = new Map>(); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + if (relativePath.endsWith('.ts')) { + parsedSources.set(filePath, parseSource(content, filePath)); + } + } + + resetResolverCache(); + const index = buildSymbolIndex(parsedSources); + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = parsedSources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing hint consumer'); + } + const resolver = createQueryKeyResolver(consumerPath, index, root); + + expect( + normalizeQueryKey(variableInit(consumerAst, 'complexCall'), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + matchMode: 'exact', + resolution: 'dynamic', + }); + expect(normalizeQueryKey(variableInit(consumerAst, 'aliasCall'), { defaultMode: 'exact' }, resolver)).toMatchObject( + { + matchMode: 'exact', + resolution: 'dynamic', + }, + ); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'nestedArrayValue'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(['nested-array']); + expect( + normalizeQueryKey(variableInit(consumerAst, 'nestedArrayHole'), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + source: 'literal', + }); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'nestedObjectValue'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(['nested-object']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'arrowCall'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['arrow', 'value']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'plainCall'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['plain']); + + expect(resolver.resolveCallResult({ type: 'Super' } as never)).toBeUndefined(); + expect(resolver.resolveCallResult({ type: 'Identifier', name: 'identity' } as never)).toBeDefined(); + expect(resolver.resolveCallResult({ type: 'Identifier', name: 'plainAlias' } as never)).toBeDefined(); + expect(resolver.resolveCallResult({ type: 'Identifier', name: 'makeMissingQueryKey' } as never)).toBeUndefined(); + expect(resolver.resolveCallResult({ type: 'Identifier', name: 'makeValueQueryKey' } as never)).toBeDefined(); + expect(resolver.resolveCallResult({ type: 'Identifier', name: 'makeNoReturnQueryKey' } as never)).toBeDefined(); + expect(resolver.resolveCallResult({ type: 'Identifier', name: 'makeArrowQueryKey' } as never)).toBeDefined(); + expect(resolver.resolveCallResult({ type: 'Identifier', name: 'makeTieQueryKey' } as never)).toBeUndefined(); + expect( + resolver.resolveReference({ + type: 'MemberExpression', + object: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'identity' }, + arguments: [{ type: 'ArrayExpression', elements: [{ type: 'StringLiteral', value: 'wrapped-zero' }] }], + }, + property: { type: 'NumericLiteral', value: 0 }, + computed: true, + } as never), + ).toBeDefined(); + expect( + resolver.resolveReference({ + type: 'MemberExpression', + object: { type: 'ArrayExpression', elements: [null] }, + property: { type: 'NumericLiteral', value: 0 }, + computed: true, + } as never), + ).toBeUndefined(); + expect( + resolver.resolveReference({ + type: 'MemberExpression', + object: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'shallowRef' }, + arguments: [{ type: 'ArrayExpression', elements: [{ type: 'StringLiteral', value: 'direct-shallow' }] }], + }, + property: { type: 'Identifier', name: 'value' }, + computed: false, + } as never), + ).toBeDefined(); + expect( + resolver.resolveReference({ + type: 'MemberExpression', + object: { type: 'Identifier', name: 'holder' }, + property: { type: 'StringLiteral', value: 'key' }, + computed: true, + } as never), + ).toBeDefined(); + }); + + it('covers resolver edge cases through public resolution APIs', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-public-edges-')); + const chain = Array.from({ length: 30 }, (_, index) => { + const next = index === 29 ? "['deep'] as const" : `chain${index + 1}`; + return `const chain${index} = ${next};`; + }); + const files = { + 'package.json': JSON.stringify({ name: 'public-edge-workspace' }), + 'src/reExport.ts': [ + "export * from '../missing';", + "export { missingValue as missingNamed } from '../missing';", + "export { renamedSource as renamedValue } from '../source';", + "export { makeSourceQueryKey as makeRenamedQueryKey } from '../source';", + "export * from '../source';", + ].join('\n'), + 'src/source.ts': [ + "export const renamedSource = ['renamed'] as const;", + "export const sourceValue = ['source'] as const;", + "export function makeSourceQueryKey(id: string) { return ['source-call', id] as const; }", + ].join('\n'), + 'src/ns.ts': [ + "export const namespaceObject = { queryKey: ['namespace-object'] as const };", + "export const namespaceFn = () => ['namespace-fn'] as const;", + 'export const namespaceAlias = namespaceFn;', + ].join('\n'), + 'src/consumer.ts': [ + "import { makeRenamedQueryKey, missingNamed, renamedValue, sourceValue } from '../reExport';", + "import * as ns from '../ns';", + ...chain, + 'const deepAlias = chain0;', + 'const renamedAlias = renamedValue;', + 'const sourceAlias = sourceValue;', + "const renamedCall = makeRenamedQueryKey('id');", + 'const missingAlias = missingNamed;', + 'const namespaceObjectAlias = ns.namespaceObject.queryKey;', + 'const namespaceCallAlias = ns.namespaceAlias();', + "const spreadSource = { queryKey: ['spread-source'] as const };", + "const spreadTarget = { ...spreadSource, other: ['other'] as const }.queryKey;", + "const arrayTarget = [['zero'] as const, , ['two'] as const] as const;", + 'const arrayHole = arrayTarget[1];', + 'const arrayOutOfRange = arrayTarget[9];', + "const wrappedObject = queryOptions({ queryKey: ['wrapped-query-options'] as const }).queryKey;", + "const frozenObject = Object.freeze({ queryKey: ['frozen-object'] as const }).queryKey;", + ].join('\n'), + }; + const parsedSources = new Map>(); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + if (relativePath.endsWith('.ts')) { + parsedSources.set(filePath, parseSource(content, filePath)); + } + } + resetResolverCache(); + const index = buildSymbolIndex(parsedSources); + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = parsedSources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing public edge consumer'); + } + const resolver = createQueryKeyResolver(consumerPath, index, root); + + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'renamedAlias'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(expect.any(Array)); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'sourceAlias'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'renamedCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + normalizeQueryKey(variableInit(consumerAst, 'missingAlias'), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ + resolution: 'dynamic', + }); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'namespaceObjectAlias'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(expect.any(Array)); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'namespaceCallAlias'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(expect.any(Array)); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'spreadTarget'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(['spread-source']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'arrayHole'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['[[zero], undefined, [two]].1']); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'arrayOutOfRange'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(['[[zero], undefined, [two]].9']); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'wrappedObject'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(['wrapped-query-options']); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'frozenObject'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(['frozen-object']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'deepAlias'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['deep']); + }); + + it('substitutes function arguments through broad expression shapes', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-expression-shapes-')); + const consumerPath = path.join(root, 'src/consumer.ts'); + const source = [ + 'const fn = (value: string) => value;', + 'function shapeFactory(id: string, obj: Record, arr: readonly unknown[], flag: boolean) {', + ' return [', + ' [id, ...arr],', + ' { id, ...obj, nested: { id } },', + ' obj[id],', + ' fn(id),', + ' `prefix-' + '$' + '{id}`,', + ' !flag,', + ' id + "-tail",', + ' flag && id,', + ' id = "assigned",', + ' flag ? id : "fallback",', + ' (id, "sequence"),', + ' (id),', + ' ] as const;', + '}', + 'const result = shapeFactory("value", { existing: "object" }, ["spread"] as const, true);', + 'const first = shapeFactory("value", { existing: "object" }, ["spread"] as const, true)[0];', + 'const objectResult = shapeFactory("value", { existing: "object" }, ["spread"] as const, true)[1];', + 'const callResult = shapeFactory("value", { existing: "object" }, ["spread"] as const, true)[3];', + ].join('\n'); + + await mkdir(path.dirname(consumerPath), { recursive: true }); + await writeFile(consumerPath, source); + const consumerAst = parseSource(source, consumerPath); + + resetResolverCache(); + const resolver = createQueryKeyResolver( + consumerPath, + buildSymbolIndex(new Map([[consumerPath, consumerAst]])), + root, + ); + const result = normalizeQueryKey(variableInit(consumerAst, 'result'), { defaultMode: 'exact' }, resolver); + + expect(result.resolution).toBe('dynamic'); + expect(result.segments).toEqual( + expect.arrayContaining([ + '[$id, ...spread]', + '{id: $id, ...$obj, nested: {id: $id}}', + '$obj.id', + '$value', + 'prefix-' + '$' + '{id}', + '$id + -tail', + 'cond(...)', + ]), + ); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'first'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['value', 'spread']); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'objectResult'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(['{id: value, existing: object, nested: {id: value}}']); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'callResult'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['value']); + + resetResolverCache(); + }); + + it('covers config resolution edge branches: package extends, nearest-config root walk, cached entries', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-config-edge-')); + const files = { + 'package.json': JSON.stringify({ name: 'config-edge-workspace' }), + // extends a bare package specifier that cannot be resolved -> resolveExtendsConfigPath returns undefined (line 311) + 'tsconfig.json': [ + '{', + ' "extends": "@totally/missing-config-package",', + ' "compilerOptions": {', + ' "baseUrl": ".",', + ' "paths": {', + ' "@same": ["src/aaa"],', + ' "@samz": ["src/bbb"]', + ' }', + ' }', + '}', + ].join('\n'), + 'src/aaa.ts': "export const aaaKey = ['aaa'] as const;", + 'src/bbb.ts': "export const bbbKey = ['bbb'] as const;", + 'src/consumer.ts': [ + "import { aaaKey } from '@same';", + "import { bbbKey } from '@samz';", + 'const aaaAlias = aaaKey;', + 'const bbbAlias = bbbKey;', + 'const secondAaaAlias = aaaKey;', + ].join('\n'), + }; + + const parsedSources = new Map>(); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + if (relativePath.endsWith('.ts')) { + parsedSources.set(filePath, parseSource(content, filePath)); + } + } + + resetResolverCache(); + const index = buildSymbolIndex(parsedSources); + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = parsedSources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing config-edge consumer'); + } + const resolver = createQueryKeyResolver(consumerPath, index, root); + const expectSegments = (name: string, expected: string[]) => { + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, name), { defaultMode: 'exact' }, resolver)), + ).toEqual(expected); + }; + + // Two equal-length non-wildcard patterns force the localeCompare tie-break in getPathAliasEntries (line 443). + expectSegments('aaaAlias', ['aaa']); + expectSegments('bbbAlias', ['bbb']); + // Second use of @same hits cached alias entries (lines 426-428). + expectSegments('secondAaaAlias', ['aaa']); + + resetResolverCache(); + }); + + it('walks to the filesystem root when the workspace root is not an ancestor of the file', async () => { + // findNearestConfigFile uses workspaceRoot only as a stop boundary. When the + // analyzed file lives outside that root, the loop keeps walking parents until + // it reaches the filesystem root (parent === cursor, line 410-411). + const fileRoot = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-detached-file-')); + const otherRoot = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-detached-root-')); + const consumerPath = path.join(fileRoot, 'src', 'consumer.ts'); + const source = ["import { thing } from 'unresolved-bare-module';", 'const alias = thing;'].join('\n'); + await mkdir(path.dirname(consumerPath), { recursive: true }); + await writeFile(consumerPath, source); + const consumerAst = parseSource(source, consumerPath); + + resetResolverCache(); + const index = buildSymbolIndex(new Map([[consumerPath, consumerAst]])); + // workspaceRoot points at an unrelated tmp dir, so the config search runs off + // the end of the path hierarchy. + const resolver = createQueryKeyResolver(consumerPath, index, otherRoot); + + expect(normalizeQueryKey(variableInit(consumerAst, 'alias'), { defaultMode: 'exact' }, resolver)).toMatchObject({ + resolution: 'dynamic', + }); + + resetResolverCache(); + }); + + it('resolves package subpaths directly without the implicit src/ prefix', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-pkg-subpath-')); + const files = { + 'package.json': JSON.stringify({ name: 'pkg-subpath-workspace' }), + // entry sits at the package root, and the subpath also resolves directly + // from the package dir (line 765-767), not via the src/ fallback. + 'packages/flat/package.json': JSON.stringify({ name: 'flat-pkg', main: 'index.ts' }), + 'packages/flat/index.ts': "export const flatKey = ['flat'] as const;", + 'packages/flat/extra.ts': "export const extraKey = ['extra'] as const;", + 'src/consumer.ts': ["import { extraKey } from 'flat-pkg/extra';", 'const extraAlias = extraKey;'].join('\n'), + }; + + const parsedSources = new Map>(); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + if (relativePath.endsWith('.ts')) { + parsedSources.set(filePath, parseSource(content, filePath)); + } + } + + resetResolverCache(); + const index = buildSymbolIndex(parsedSources); + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = parsedSources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing pkg-subpath consumer'); + } + const resolver = createQueryKeyResolver(consumerPath, index, root); + + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'extraAlias'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['extra']); + + resetResolverCache(); + }); + + it('resolves function returns and nodes through re-export and value-alias chains', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-fn-chains-')); + const files = { + 'package.json': JSON.stringify({ name: 'fn-chain-workspace' }), + 'src/leaf.ts': [ + 'export function makeLeafQueryKey(id: string) {', + " return ['leaf', id] as const;", + '}', + // a value bound to a function expression, exported by name + "export const arrowLeafQueryKey = (id: string) => ['arrow-leaf', id] as const;", + ].join('\n'), + // star re-export so resolveExportFunctionReturn must walk reExport.all + 'src/barrel.ts': [ + "export * from '../leaf';", + "export { makeLeafQueryKey as renamedLeafQueryKey } from '../leaf';", + ].join('\n'), + 'src/consumer.ts': [ + "import { arrowLeafQueryKey, makeLeafQueryKey, renamedLeafQueryKey } from '../barrel';", + // value-alias to an imported factory, then call it: exercises resolveLocalFunctionNode + // value-alias branch and resolveLocalValue function path. + 'const aliasedFactory = makeLeafQueryKey;', + "const starCall = makeLeafQueryKey('s');", + "const renamedCall = renamedLeafQueryKey('r');", + "const arrowCall = arrowLeafQueryKey('a');", + // member access on the result of a locally-aliased factory call -> resolveCallExpressionInternal + "const wrappedFactory = (id: string) => ({ queryKey: ['wrapped', id] as const });", + 'const wrappedAlias = wrappedFactory;', + "const wrappedKey = wrappedAlias('w').queryKey;", + ].join('\n'), + }; + + const parsedSources = new Map>(); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + if (relativePath.endsWith('.ts')) { + parsedSources.set(filePath, parseSource(content, filePath)); + } + } + + resetResolverCache(); + const index = buildSymbolIndex(parsedSources); + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = parsedSources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing fn-chain consumer'); + } + const resolver = createQueryKeyResolver(consumerPath, index, root); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'starCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'renamedCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'arrowCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'wrappedKey'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + + const aliasedCallee = variableInit(consumerAst, 'aliasedFactory'); + expect(aliasedCallee).toBeDefined(); + + resetResolverCache(); + }); + + it('resolves call results through value-aliased function expressions and member callees', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-call-value-')); + const files = { + 'package.json': JSON.stringify({ name: 'call-value-workspace' }), + 'src/ns.ts': [ + // a namespace member that is an identifier aliasing a local factory + "export const baseFactory = () => ['base'] as const;", + 'export const aliasFactory = baseFactory;', + // a namespace member that is a plain (non-function) value used as a callee + "export const plainValue = ['plain'] as const;", + ].join('\n'), + 'src/consumer.ts': [ + "import * as ns from '../ns';", + // namespace member resolves to an Identifier -> resolveCallResultInternal line 1759-1760 + 'const aliasCall = ns.aliasFactory();', + // member callee that resolves (via resolveReferenceInternal) to a function expression -> line 1770-1771 + "const holder = { fn: () => ['holder-fn'] as const };", + 'const holderCall = holder.fn();', + // member callee resolving to a non-function, non-identifier value -> line 1776 + "const dataHolder = { data: ['data'] as const };", + 'const dataCall = dataHolder.data();', + ].join('\n'), + }; + + const parsedSources = new Map>(); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + if (relativePath.endsWith('.ts')) { + parsedSources.set(filePath, parseSource(content, filePath)); + } + } + + resetResolverCache(); + const index = buildSymbolIndex(parsedSources); + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = parsedSources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing call-value consumer'); + } + const resolver = createQueryKeyResolver(consumerPath, index, root); + + const callee = (name: string) => { + const call = variableInit(consumerAst, name); + if (!call || !isCallExpression(call)) { + throw new Error(`Missing call ${name}`); + } + return call.callee; + }; + + expect( + normalizeQueryKey(resolver.resolveCallResult(callee('aliasCall')), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ segments: ['UNRESOLVED'] }); + expect( + normalizeQueryKey(resolver.resolveCallResult(callee('holderCall')), { defaultMode: 'exact' }, resolver), + ).toMatchObject({ segments: ['holder-fn'] }); + // dataCall: callee resolves to an array literal (non-function/non-identifier) -> returned as-is (line 1776). + expect(resolver.resolveCallResult(callee('dataCall'))).toBeDefined(); + + resetResolverCache(); + }); + + it('inlines hints for TSParameterProperty-style params and local value function callees', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-param-prop-')); + const files = { + 'package.json': JSON.stringify({ name: 'param-prop-workspace' }), + 'src/consumer.ts': [ + // resolveLocalValue returns a value that is itself a function expression and is + // reached via resolveCallResultInternal local-value branch (line 1735-1737). + "const baseObjectQueryKey = { queryKey: ['base-obj'] as const };", + 'const aliasObjectQueryKey = baseObjectQueryKey;', + 'const aliasObjectValue = aliasObjectQueryKey.queryKey;', + // A function whose param has a default (AssignmentPattern) AND is referenced. + "function makeDefaultedQueryKey(id = 'fallback') {", + " return ['defaulted', id] as const;", + '}', + "const defaultedCall = makeDefaultedQueryKey('explicit');", + ].join('\n'), + }; + + const parsedSources = new Map>(); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + if (relativePath.endsWith('.ts')) { + parsedSources.set(filePath, parseSource(content, filePath)); + } + } + + resetResolverCache(); + const index = buildSymbolIndex(parsedSources); + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = parsedSources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing param-prop consumer'); + } + const resolver = createQueryKeyResolver(consumerPath, index, root); + + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'aliasObjectValue'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(['base-obj']); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'defaultedCall'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(['defaulted', '$id']); + + resetResolverCache(); + }); + + it('lazily indexes on-disk support files for values, functions, and function nodes', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-lazy-')); + const files = { + 'package.json': JSON.stringify({ name: 'lazy-workspace' }), + 'src/lazy.ts': [ + "export const lazyValue = ['lazy-value'] as const;", + 'export function lazyFactory(id: string) {', + " return ['lazy-fn', id] as const;", + '}', + "export const lazyArrow = (id: string) => ({ queryKey: ['lazy-arrow', id] as const });", + ].join('\n'), + 'src/consumer.ts': [ + "import { lazyArrow, lazyFactory, lazyValue } from '../lazy';", + 'const valueAlias = lazyValue;', + "const fnCall = lazyFactory('x');", + // member access on aliased factory call -> resolveLocalFunctionNode lazily indexes + 'const arrowAlias = lazyArrow;', + "const arrowKey = arrowAlias('y').queryKey;", + ].join('\n'), + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = parseSource(files['src/consumer.ts'], consumerPath); + // Deliberately index ONLY the consumer, so resolving across to ./lazy must + // trigger ensureIndexedFile (the "if (!symbols)" branches in resolveExportValue, + // resolveExportFunctionReturn, resolveLocalValue, resolveLocalFunctionReturn, + // resolveLocalFunctionNode). + const index = buildSymbolIndex(new Map([[consumerPath, consumerAst]])); + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, index, root); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'valueAlias'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'fnCall'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'arrowKey'), { defaultMode: 'exact' }, resolver)) + .length, + ).toBeGreaterThan(0); + expect(index.files.size).toBeGreaterThan(0); + + resetResolverCache(); + }); + + it('prefers the nearest candidate across competing alias targets (candidate sorting tie-breaks)', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-candidate-sort-')); + const files = { + 'package.json': JSON.stringify({ name: 'candidate-sort-workspace' }), + 'app/tsconfig.json': [ + '{', + ' "compilerOptions": {', + ' "baseUrl": ".",', + ' "paths": {', + // Three targets at different distances/up-levels for the same import. + ' "@multi/*": ["../far/deep/very/*", "../up/*", "near/*"]', + ' }', + ' }', + '}', + ].join('\n'), + 'app/near/key.ts': "export const nearKey = ['near'] as const;", + 'up/key.ts': "export const upKey = ['up'] as const;", + 'far/deep/very/key.ts': "export const farKey = ['far'] as const;", + 'app/src/consumer.ts': ["import { nearKey } from '@multi/key';", 'const chosen = nearKey;'].join('\n'), + }; + + const parsedSources = new Map>(); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + if (relativePath.endsWith('.ts')) { + parsedSources.set(filePath, parseSource(content, filePath)); + } + } + + resetResolverCache(); + const index = buildSymbolIndex(parsedSources); + const consumerPath = path.join(root, 'app/src/consumer.ts'); + const consumerAst = parsedSources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing candidate-sort consumer'); + } + const resolver = createQueryKeyResolver(consumerPath, index, root); + + // Multiple matches force compareResolutionCandidates through its up-count, + // distance, length, and localeCompare tie-breakers. + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'chosen'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['near']); + + resetResolverCache(); + }); + + it('covers identity-wrapper callees, ref namespaces, computed keys, and substitution shapes', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-shapes2-')); + const consumerPath = path.join(root, 'src/consumer.ts'); + const source = [ + // queryClient / tanstack helpers imported from an unindexed module so their + // callees stay unresolved and fall through to isIdentityWrapperCall. + "import { infiniteQueryOptions } from '@tanstack/react-query';", + "import { client } from 'unindexed-client';", + // identity-wrapper as a member callee accessing a NON-queryKey property, so it + // falls past the queryKey shortcut to isIdentityWrapperCall member branch (line 569). + "const memberWrapped = client.queryOptions({ tag: ['member-wrapped'] as const }).tag;", + "const memberInfinite = client.infiniteQueryOptions({ tag: ['member-infinite'] as const }).tag;", + // identity-wrapper as a bare identifier callee wrapping an array, indexed (line 561). + "const identifierWrapped = infiniteQueryOptions([['identifier-wrapped'] as const])[0];", + // factory invoked through member access whose body has rich substitution shapes + 'function richFactory(id, obj, arr, fn) {', + ' return {', + ' queryKey: [', + ' { ...obj, copy: id },', // object spread + objectProperty substitution + ' [id, ...arr],', // array spread + array element + ' obj[id],', // computed member substitution + ' fn(id, ...arr),', // call spread arg + ' ] as const,', + ' };', + '}', + 'const richKey = richFactory("seed", { a: 1 }, ["t"] as const, (x) => x).queryKey;', + ].join('\n'); + + await mkdir(path.dirname(consumerPath), { recursive: true }); + await writeFile(consumerPath, source); + const consumerAst = parseSource(source, consumerPath); + + resetResolverCache(); + const resolver = createQueryKeyResolver( + consumerPath, + buildSymbolIndex(new Map([[consumerPath, consumerAst]])), + root, + ); + const expectSegments = (name: string, expected: string[]) => { + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, name), { defaultMode: 'exact' }, resolver)), + ).toEqual(expected); + }; + + expectSegments('memberWrapped', ['member-wrapped']); + expectSegments('memberInfinite', ['member-infinite']); + expectSegments('identifierWrapped', ['identifier-wrapped']); + + // richKey resolves dynamically but exercises the substitution-shape branches. + const rich = normalizeQueryKey(variableInit(consumerAst, 'richKey'), { defaultMode: 'exact' }, resolver); + expect(rich.resolution).toBe('dynamic'); + + resetResolverCache(); + }); + + it('resolves computed string-literal property names and skips spreads in object property lookup', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-computed-key-')); + const consumerPath = path.join(root, 'src/consumer.ts'); + const source = [ + // queryOptions call whose first arg is an object with a spread before queryKey: + // objectPropertyValue must skip the spread (non-ObjectProperty) and the non-matching + // numeric key, then find queryKey. + "const base = { 1: ['num'] as const, queryKey: ['from-call-arg'] as const };", + "const wrapped = queryOptions({ ...base, queryKey: ['wins'] as const });", + // member access on a call result that is NOT a wrapper but whose first arg has queryKey + 'function passthrough(o) { return o; }', + "const passKey = passthrough({ queryKey: ['passed'] as const }).queryKey;", + ].join('\n'); + + await mkdir(path.dirname(consumerPath), { recursive: true }); + await writeFile(consumerPath, source); + const consumerAst = parseSource(source, consumerPath); + + resetResolverCache(); + const resolver = createQueryKeyResolver( + consumerPath, + buildSymbolIndex(new Map([[consumerPath, consumerAst]])), + root, + ); + + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'passKey'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['passed']); + + resetResolverCache(); + }); + + it('parses malformed, array, and non-string-array configs and shared/circular extends', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-config-parse-')); + const files = { + 'package.json': JSON.stringify({ name: 'config-parse-workspace' }), + // shared base extended by two children -> second merge hits the parsedAliasConfigCache (line 318) + 'shared-base.json': JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@base/*': ['base/*'] } }, + }), + // malformed JSON (survives comment stripping but JSON.parse throws -> line 236) + 'malformed/tsconfig.json': '{ "compilerOptions": { "paths": { "@x": [ }', + 'malformed/src/consumer.ts': ["import { mKey } from '@x';", 'const mAlias = mKey;'].join('\n'), + // top-level array config -> parseJsonObject returns undefined via line 232/238 + 'arrayconf/tsconfig.json': '["not", "an", "object"]', + 'arrayconf/src/consumer.ts': ["import { aKey } from '@y';", 'const aAlias = aKey;'].join('\n'), + // paths whose values are non-arrays (string / object) -> asStringArray returns undefined (line 249-250) + // and a pattern with paths but NO baseUrl -> line 354 nullish fallback to dirname. + 'nobase/tsconfig.json': [ + '{', + ' "compilerOptions": {', + ' "paths": {', + ' "@str": "src/str",', + ' "@obj": { "nested": true },', + ' "@ok/*": ["src/*"]', + ' }', + ' }', + '}', + ].join('\n'), + 'nobase/src/ok.ts': "export const okKey = ['ok'] as const;", + 'nobase/src/consumer.ts': ["import { okKey } from '@ok/ok';", 'const okAlias = okKey;'].join('\n'), + // shared base extended by two sibling configs. The inherited @base/* paths are + // resolved against the shared base's dir (root), so both children resolve the + // same root-level base/k.ts; the second merge reuses the cached parse (line 318). + 'base/k.ts': "export const sharedBaseKey = ['shared-base'] as const;", + 'one/tsconfig.json': JSON.stringify({ extends: '../shared-base.json' }), + 'one/src/consumer.ts': ["import { sharedBaseKey } from '@base/k';", 'const oneAlias = sharedBaseKey;'].join('\n'), + 'two/tsconfig.json': JSON.stringify({ extends: '../shared-base.json' }), + 'two/src/consumer.ts': ["import { sharedBaseKey } from '@base/k';", 'const twoAlias = sharedBaseKey;'].join('\n'), + // circular extends WITH an alias import so mergePathAliases recurses and hits seen (line 321) + 'circ/a/tsconfig.json': JSON.stringify({ + extends: '../b/tsconfig.json', + compilerOptions: { baseUrl: '.', paths: { '@circ/*': ['src/*'] } }, + }), + 'circ/b/tsconfig.json': JSON.stringify({ extends: '../a/tsconfig.json', compilerOptions: { baseUrl: '.' } }), + 'circ/a/src/key.ts': "export const circKey = ['circ'] as const;", + 'circ/a/src/consumer.ts': ["import { circKey } from '@circ/key';", 'const circAlias = circKey;'].join('\n'), + }; + + const parsedSources = new Map>(); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + if (relativePath.endsWith('.ts')) { + parsedSources.set(filePath, parseSource(content, filePath)); + } + } + + resetResolverCache(); + const index = buildSymbolIndex(parsedSources); + + const resolveAlias = (consumerRel: string, name: string) => { + const consumerPath = path.join(root, consumerRel); + const ast = parsedSources.get(consumerPath); + if (!ast) { + throw new Error(`Missing ${consumerRel}`); + } + const resolver = createQueryKeyResolver(consumerPath, index, root); + return normalizeQueryKey(variableInit(ast, name), { defaultMode: 'exact' }, resolver); + }; + + // Malformed/array/non-array-path configs all fail closed (dynamic). + expect(resolveAlias('malformed/src/consumer.ts', 'mAlias').resolution).toBe('dynamic'); + expect(resolveAlias('arrayconf/src/consumer.ts', 'aAlias').resolution).toBe('dynamic'); + // The valid @ok/* path still resolves even though sibling paths are invalid. + expect(queryKeySegments(resolveAlias('nobase/src/consumer.ts', 'okAlias'))).toEqual(['ok']); + // Shared base extended twice: both resolve; second uses the cached merge. + expect(queryKeySegments(resolveAlias('one/src/consumer.ts', 'oneAlias'))).toEqual(['shared-base']); + expect(queryKeySegments(resolveAlias('two/src/consumer.ts', 'twoAlias'))).toEqual(['shared-base']); + // Circular extends still resolves the local alias. + expect(queryKeySegments(resolveAlias('circ/a/src/consumer.ts', 'circAlias'))).toEqual(['circ']); + + resetResolverCache(); + }); + + it('exercises deep recursion limits and circular reference guards', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-depth-')); + // Build a chain of re-export barrels longer than MAX_DEPTH (24) so + // resolveExportValue/resolveExportFunctionReturn hit the depth>MAX_DEPTH guard. + const barrelCount = 40; + const files: Record = { + 'package.json': JSON.stringify({ name: 'depth-workspace' }), + 'src/leaf.ts': [ + "export const deepValue = ['deep-value'] as const;", + "export function deepFactory(id: string) { return ['deep-fn', id] as const; }", + ].join('\n'), + }; + for (let i = 0; i < barrelCount; i += 1) { + const next = i === barrelCount - 1 ? './leaf' : `./barrel${i + 1}`; + files[`src/barrel${i}.ts`] = `export * from '${next}';`; + } + files['src/consumer.ts'] = [ + "import { deepFactory, deepValue } from '../barrel0';", + 'const deepAlias = deepValue;', + "const deepCall = deepFactory('z');", + // Self-referential local alias -> resolveLocalValue seen-guard / alias cycle. + 'let cyclic = cyclic;', + 'const cyclicAlias = cyclic;', + ].join('\n'); + + const parsedSources = new Map>(); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + if (relativePath.endsWith('.ts')) { + parsedSources.set(filePath, parseSource(content, filePath)); + } + } + + resetResolverCache(); + const index = buildSymbolIndex(parsedSources); + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = parsedSources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing depth consumer'); + } + const resolver = createQueryKeyResolver(consumerPath, index, root); + + // Past MAX_DEPTH the export chain bails out -> dynamic fallback. + expect( + normalizeQueryKey(variableInit(consumerAst, 'deepAlias'), { defaultMode: 'exact' }, resolver).resolution, + ).toBe('dynamic'); + expect( + normalizeQueryKey(variableInit(consumerAst, 'deepCall'), { defaultMode: 'exact' }, resolver).resolution, + ).toBe('dynamic'); + + resetResolverCache(); + }); + + it('applies argument hints through member-access call chains with diverse param/arg shapes', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-hint-member-')); + const consumerPath = path.join(root, 'src/consumer.ts'); + const source = [ + 'const id = (v) => v;', + // factory with MORE params than args supplied (line 1392 !arg) and an UNUSED + // param that never appears in the return (line 1398 continue), invoked via member. + 'function shapeFactory(used, unused, missing) {', + ' return {', + ' queryKey: [', + ' used,', // identifier substitution + ' [used, ...used],', // array + spread substitution + ' { ...used, copy: used },', // object spread + property substitution + ' used[used],', // computed member substitution + ' id(used, ...used),', // call + call-spread-arg substitution + ' `t-' + '$' + '{used}`,', // template substitution + ' -used,', // unary substitution + ' used + used,', // binary substitution + ' used ? used : used,', // conditional substitution + ' (used, used),', // sequence substitution + ' ] as const,', + ' };', + '}', + // only 1 arg for 3 params -> unused/missing hit line 1392; used substituted. + 'const shapeKey = shapeFactory(["seed"] as const).queryKey;', + // factory invoked via member access whose body returns no value (line 1422 !returned) + 'function voidFactory(x) { x; }', + 'const voidKey = voidFactory(1).queryKey;', + // value-aliased arrow used as a member-access callee -> resolveLocalFunctionNode + // identifier-alias branch (lines 1190-1191). + "const realArrow = (p) => ({ queryKey: ['real', p] as const });", + 'const aliasArrow = realArrow;', + "const aliasArrowKey = aliasArrow('p').queryKey;", + ].join('\n'); + + await mkdir(path.dirname(consumerPath), { recursive: true }); + await writeFile(consumerPath, source); + const consumerAst = parseSource(source, consumerPath); + + resetResolverCache(); + const resolver = createQueryKeyResolver( + consumerPath, + buildSymbolIndex(new Map([[consumerPath, consumerAst]])), + root, + ); + + const shapeKey = normalizeQueryKey(variableInit(consumerAst, 'shapeKey'), { defaultMode: 'exact' }, resolver); + expect(shapeKey.resolution).toBe('dynamic'); + expect(shapeKey.segments.length).toBeGreaterThan(0); + + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'aliasArrowKey'), { defaultMode: 'exact' }, resolver), + ), + ).toEqual(['real', '$p']); + + // voidKey: factory returns nothing, so member access fails closed. + expect(normalizeQueryKey(variableInit(consumerAst, 'voidKey'), { defaultMode: 'exact' }, resolver).resolution).toBe( + 'dynamic', + ); + + resetResolverCache(); + }); + + it('handles synthetic and degenerate nodes through the public API', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-synthetic-')); + const consumerPath = path.join(root, 'src/consumer.ts'); + const source = "const localKey = ['local'] as const;"; + await mkdir(path.dirname(consumerPath), { recursive: true }); + await writeFile(consumerPath, source); + const consumerAst = parseSource(source, consumerPath); + + resetResolverCache(); + const resolver = createQueryKeyResolver( + consumerPath, + buildSymbolIndex(new Map([[consumerPath, consumerAst]])), + root, + ); + + // Empty identifier name -> isLikelyQueryKeyFactoryIdentifier !name branch (line 95). + expect(resolver.resolveCallResult({ type: 'Identifier', name: '' } as never)).toBeUndefined(); + // Computed member with a PrivateName-shaped property -> propertyNameFromMemberExpression + // falls past the static cases and getExpressionValue returns undefined. + expect( + resolver.resolveReference({ + type: 'MemberExpression', + object: { type: 'Identifier', name: 'localKey' }, + property: { type: 'PrivateName', id: { type: 'Identifier', name: 'secret' } }, + computed: false, + } as never), + ).toBeUndefined(); + + resetResolverCache(); + }); +}); diff --git a/src/core/analysis/__tests__/sourceParser.test.ts b/src/core/analysis/__tests__/sourceParser.test.ts new file mode 100644 index 0000000..a00746a --- /dev/null +++ b/src/core/analysis/__tests__/sourceParser.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { parseSource } from '../sourceParser'; + +describe('core/analysis/sourceParser', () => { + it('annotates locations for parsed source', () => { + const file = parseSource( + ['// leading comment', 'export const value = 1;', 'export function run() {', ' return value;', '}'].join('\n'), + '/repo/src/value.ts', + ); + + expect(file.comments).toHaveLength(1); + expect(file.comments[0]?.loc?.start).toEqual({ line: 1, column: 0 }); + expect(file.program.body[0]?.loc?.start).toEqual({ line: 2, column: 0 }); + expect(file.program.body[1]?.loc?.start).toEqual({ line: 3, column: 0 }); + }); + + it('falls back to later language attempts for ambiguous files', () => { + const file = parseSource( + ['const view =
;', 'export const count: number = 1;'].join('\n'), + '/repo/src/view.js', + ); + + expect(file.program.body.length).toBeGreaterThan(0); + + expect(parseSource('export const view = ;', '/repo/src/view.tsx').program.body).toHaveLength(1); + expect(parseSource('export const view = ;', '/repo/src/view.jsx').program.body).toHaveLength(1); + expect(parseSource('export const value: number = 1;', '/repo/src/unknown').program.body).toHaveLength(1); + expect(parseSource('export const value: number = 1;', '/repo/src/value.mts').program.body).toHaveLength(1); + expect(parseSource('export const value: number = 1;', '/repo/src/value.cts').program.body).toHaveLength(1); + expect(parseSource('export const value = 1;', '/repo/src/value.mjs').program.body).toHaveLength(1); + expect(parseSource('export const value = 1;', '/repo/src/value.cjs').program.body).toHaveLength(1); + }); + + it('throws the first parse error when all attempts fail', () => { + expect(() => parseSource('export const = ;', '/repo/src/broken.ts')).toThrow(); + }); +}); diff --git a/src/core/analysis/__tests__/symbols.test.ts b/src/core/analysis/__tests__/symbols.test.ts new file mode 100644 index 0000000..32c4ea4 --- /dev/null +++ b/src/core/analysis/__tests__/symbols.test.ts @@ -0,0 +1,442 @@ +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import * as t from '../ast'; +import { parseSource } from '../sourceParser'; +import { buildFileSymbols, buildSymbolIndex, extractFunctionReturnExpression, normalizeAnalyzerPath } from '../symbols'; + +function symbolsFor(source: string) { + return buildFileSymbols('/repo/src/keys.ts', parseSource(source, '/repo/src/keys.ts')); +} + +describe('core/analysis/symbols', () => { + it('collects imports, exports, local symbols, and mutable bindings', () => { + const symbols = symbolsFor(` + import defaultClient, { queryOptions, remoteKey as aliasKey } from '../remote'; + import * as queryApi from '../queryApi'; + + const source = makeSource(); + const localQueryKey = ['local'] as const; + let mutableQueryKey = ['start']; + mutableQueryKey = ['next']; + const { queryKey, rqKey: renamedRqKey, ['dash-key']: dashKey, 1: numericKey, nested: { queryKey: nestedQueryKey = ['fallback'] }, ...restQueryKey } = source; + const { queryKey: defaultedQueryKey = ['fallback'] } = source; + const [firstQueryKey, , ...restArrayQueryKey] = source.items; + const [topArrayQueryKey] = source.items; + const ignoredInside = () => { + const nested = 1; + return nested; + }; + + export const exportedQueryKey = ['exported']; + export const exportedNoReturnQueryKey = () => { + source.run(); + }; + export class ExportedThing {} + export function exportedFactory() { + label: { + return exportedQueryKey; + } + } + function makeQueryKey() { + if (source.ready) { + return ['if']; + } + return ['else']; + } + const chainedQueryKey = () => { + const first = ['first'] as const; + const second = first; + return second!; + }; + export { localQueryKey as renamedLocal }; + export { remoteKey as renamedRemote } from '../remote'; + export * from '../all'; + export default function defaultFactory() { + switch (source.kind) { + case 'case': + return ['case']; + default: + return ['default']; + } + } + `); + + expect(symbols.imports.get('defaultClient')).toEqual({ + kind: 'default', + source: '../remote', + imported: 'default', + }); + expect(symbols.imports.get('queryOptions')).toEqual({ + kind: 'named', + source: '../remote', + imported: 'queryOptions', + }); + expect(symbols.imports.get('aliasKey')).toEqual({ + kind: 'named', + source: '../remote', + imported: 'remoteKey', + }); + expect(symbols.imports.get('queryApi')).toEqual({ kind: 'namespace', source: '../queryApi' }); + + expect(symbols.exports.get('renamedLocal')).toBe('localQueryKey'); + expect(symbols.exports.get('exportedQueryKey')).toBe('exportedQueryKey'); + expect(symbols.exports.get('ExportedThing')).toBeUndefined(); + expect(symbols.exports.get('exportedFactory')).toBe('exportedFactory'); + expect(symbols.exports.get('default')).toBe('defaultFactory'); + expect(symbols.reExports).toEqual([ + { source: '../remote', imported: 'remoteKey', exported: 'renamedRemote', all: false }, + { source: '../all', all: true }, + ]); + + expect(symbols.values.has('localQueryKey')).toBe(true); + expect(symbols.values.has('queryKey')).toBe(true); + expect(symbols.values.has('renamedRqKey')).toBe(true); + expect(symbols.values.has('dashKey')).toBe(true); + expect(symbols.values.has('numericKey')).toBe(true); + expect(symbols.values.has('nestedQueryKey')).toBe(true); + expect(symbols.values.has('defaultedQueryKey')).toBe(true); + expect(symbols.values.has('restQueryKey')).toBe(true); + expect(symbols.values.has('firstQueryKey')).toBe(true); + expect(symbols.values.has('restArrayQueryKey')).toBe(true); + expect(symbols.values.has('topArrayQueryKey')).toBe(true); + expect(symbols.values.has('nested')).toBe(false); + expect(symbols.mutableValues.has('mutableQueryKey')).toBe(true); + + const exportedReturn = symbols.functions.get('exportedFactory'); + expect(exportedReturn && t.isIdentifier(exportedReturn) ? exportedReturn.name : undefined).toBe('exportedQueryKey'); + + const chainedReturn = symbols.functions.get('chainedQueryKey'); + expect(chainedReturn && t.isArrayExpression(chainedReturn)).toBe(true); + + const defaultReturn = symbols.functions.get('defaultFactory'); + expect(defaultReturn && t.isArrayExpression(defaultReturn)).toBe(true); + }); + + it('handles anonymous default functions and symbol indexes', () => { + const filePath = normalizeAnalyzerPath(path.join('repo', 'src', 'anonymous.ts')); + const ast = parseSource( + ` + export default () => ['anonymous']; + const localOnly = () => { + try { + return ['try']; + } catch { + return ['catch']; + } finally { + return ['finally']; + } + }; + `, + filePath, + ); + + const symbols = buildFileSymbols(filePath, ast); + const returned = symbols.functions.get('__default_export__'); + expect(symbols.exports.get('default')).toBe('__default_export__'); + expect(returned && t.isArrayExpression(returned)).toBe(true); + + const index = buildSymbolIndex(new Map([[filePath, ast]])); + expect(index.fileSet.has(filePath)).toBe(true); + expect(index.files.get(filePath)?.exports.get('default')).toBe('__default_export__'); + }); + + it('extracts undefined when a function has no expression return', () => { + const ast = parseSource( + ` + function emptyQueryKey() { + return; + } + `, + '/repo/src/empty.ts', + ); + const statement = ast.program.body[0]; + + expect(t.isFunctionDeclaration(statement) ? extractFunctionReturnExpression(statement) : 'missing').toBeUndefined(); + }); + + it('handles fallback returns, default identifiers, and skipped local declarations', () => { + const symbols = symbolsFor(` + const source = makeSource(); + const local = ['named'] as const; + const defaultTarget = ['default-target'] as const; + export { local as "literal-export" }; + export default defaultTarget; + + function noCaseQueryKey() { + switch (source.kind) {} + } + + function secondCaseQueryKey() { + switch (source.kind) { + case 'skip': + source.run(); + break; + case 'hit': + return ['second-case'] as const; + } + } + + function alternateOnlyQueryKey() { + if (source.ready) { + source.run(); + } else { + return ['alternate'] as const; + } + } + + function noBranchReturnQueryKey() { + if (source.ready) { + source.run(); + } + } + + function catchQueryKey() { + try { + source.run(); + } catch { + return ['catch'] as const; + } + } + + function noCatchReturnQueryKey() { + try { + source.run(); + } catch { + source.recover(); + } + } + + function finalQueryKey() { + try { + source.run(); + } finally { + return ['finally'] as const; + } + } + + function missingAliasQueryKey() { + let alias; + return alias; + } + + function recursiveAliasQueryKey() { + const alias = alias; + return alias; + } + + function mutuallyRecursiveAliasQueryKey() { + const first = second; + const second = first; + return first; + } + + function outer() { + function nestedQueryKey() { + return ['nested'] as const; + } + const { queryKey } = makeSource(); + const { queryKey: collectedQueryKey } = makeSource(); + const { queryKey: localQueryKey = ['inner'] as const } = source; + const { [source.key]: computedQueryKey } = source; + const { items: [arrayInsideQueryKey = ['fallback'] as const, { objectInsideQueryKey }, [nestedArrayQueryKey]] } = source; + let [firstQueryKey, , ...restQueryKey] = source.items; + ({ firstQueryKey, ...restQueryKey } = source); + [firstQueryKey, , ...restQueryKey] = source.items; + firstQueryKey++; + source.member++; + return nestedQueryKey; + } + + function nestedObjectQueryKeyCollector() { + const { queryKey } = { queryKey: ['object-init'] as const }; + return queryKey; + } + + function nestedArrayQueryKeyCollector() { + const [queryKey] = [['array-init'] as const]; + return queryKey; + } + + function nestedMemberQueryKeyCollector() { + const { queryKey } = source.member; + return queryKey; + } + + export const { exportedPattern } = source; + export const [arrayExport] = source.items; + export let exportedNoInitQueryKey; + `); + + expect(symbols.exports.get('literal-export')).toBe('local'); + expect(symbols.exports.get('default')).toBe('defaultTarget'); + expect(symbols.exports.get('exportedNoInitQueryKey')).toBe('exportedNoInitQueryKey'); + expect(symbols.functions.has('noCaseQueryKey')).toBe(false); + expect(symbols.functions.get('secondCaseQueryKey')).toSatisfy(t.isArrayExpression); + expect(symbols.functions.get('alternateOnlyQueryKey')).toSatisfy(t.isArrayExpression); + expect(symbols.functions.has('noBranchReturnQueryKey')).toBe(false); + expect(symbols.functions.get('catchQueryKey')).toSatisfy(t.isArrayExpression); + expect(symbols.functions.has('noCatchReturnQueryKey')).toBe(false); + expect(symbols.functions.get('finalQueryKey')).toSatisfy(t.isArrayExpression); + expect(symbols.functions.get('missingAliasQueryKey')).toSatisfy(t.isIdentifier); + expect(symbols.functions.get('recursiveAliasQueryKey')).toSatisfy(t.isIdentifier); + expect(symbols.functions.get('mutuallyRecursiveAliasQueryKey')).toSatisfy(t.isIdentifier); + expect(symbols.functions.get('nestedQueryKey')).toSatisfy(t.isArrayExpression); + expect(symbols.values.has('queryKey')).toBe(true); + expect(symbols.values.has('collectedQueryKey')).toBe(true); + expect(symbols.values.has('computedQueryKey')).toBe(false); + expect(symbols.values.has('arrayInsideQueryKey')).toBe(true); + expect(symbols.values.has('objectInsideQueryKey')).toBe(true); + expect(symbols.values.has('nestedArrayQueryKey')).toBe(true); + expect(symbols.mutableValues.has('firstQueryKey')).toBe(true); + expect(symbols.mutableValues.has('restQueryKey')).toBe(true); + }); + + it('handles string-named re-exports and unusual return extraction inputs', () => { + const symbols = symbolsFor(` + export { "remote-query-key" as queryKeyFromString } from '../remote'; + export function noReturnFactory() { + source.run(); + } + export default function namedDefaultWithoutReturn() { + source.run(); + } + `); + const anonymousDefault = symbolsFor(` + export default function() { + return ['anonymous-default'] as const; + } + `); + const literalDefault = symbolsFor(` + export default ['literal-default'] as const; + `); + const callbackDefaultWithoutReturn = symbolsFor(` + export default () => { + source.run(); + }; + `); + + expect(symbols.reExports).toContainEqual({ + source: '../remote', + imported: 'remote-query-key', + exported: 'queryKeyFromString', + all: false, + }); + expect(symbols.functions.has('noReturnFactory')).toBe(false); + expect(symbols.functionNodes.has('noReturnFactory')).toBe(true); + expect(symbols.exports.get('default')).toBe('namedDefaultWithoutReturn'); + expect(symbols.functionNodes.has('namedDefaultWithoutReturn')).toBe(true); + expect(anonymousDefault.exports.has('default')).toBe(false); + expect(literalDefault.exports.get('default')).toBe('__default_export__'); + expect(literalDefault.values.get('__default_export__')).toSatisfy(t.isArrayExpression); + expect(callbackDefaultWithoutReturn.exports.get('default')).toBe('__default_export__'); + expect(callbackDefaultWithoutReturn.functionNodes.has('__default_export__')).toBe(true); + expect(callbackDefaultWithoutReturn.functions.has('__default_export__')).toBe(false); + + expect( + extractFunctionReturnExpression({ + type: 'FunctionExpression', + id: null, + params: [], + body: t.identifier('notBlock') as never, + generator: false, + async: false, + }), + ).toBeUndefined(); + }); + + it('covers parenthesized returns, loop returns, namespace imports, and skipped patterns', () => { + const symbols = symbolsFor(` + import * as namespaceApi from '../namespace'; + + export const exportedArrowQueryKey = () => (['arrow'] as const); + export const exportedFunctionExpressionQueryKey = function() { + return (['function-expression'] as const); + }; + const wrappedQueryKey = (((['wrapped'] as const))); + const { notTracked } = source; + const { [source.key]: skippedComputed } = source; + const [plainFirst] = source.items; + const [queryKey, aliasQueryKey = ['fallback'] as const] = source.items; + + function forQueryKey() { + for (const entry of source.items) { + return (entry.queryKey); + } + } + + function whileQueryKey() { + while (source.ready) { + return ['while'] as const; + } + } + + function doQueryKey() { + do { + return ['do'] as const; + } while (source.ready); + } + + function forInQueryKey() { + for (const key in source.items) { + return [key] as const; + } + } + + const localNotQuery = 1; + localNotQuery++; + namespaceApi.value++; + `); + + expect(symbols.imports.get('namespaceApi')).toEqual({ kind: 'namespace', source: '../namespace' }); + expect(symbols.functions.get('exportedArrowQueryKey')).toSatisfy(t.isArrayExpression); + expect(symbols.functions.get('exportedFunctionExpressionQueryKey')).toSatisfy(t.isArrayExpression); + expect(symbols.values.get('wrappedQueryKey')).toSatisfy(t.isArrayExpression); + expect(symbols.values.has('notTracked')).toBe(true); + expect(symbols.values.has('skippedComputed')).toBe(false); + expect(symbols.values.has('plainFirst')).toBe(true); + expect(symbols.values.has('queryKey')).toBe(true); + expect(symbols.values.has('aliasQueryKey')).toBe(true); + expect(symbols.functions.get('forQueryKey')).toSatisfy(t.isMemberExpression); + expect(symbols.functions.get('whileQueryKey')).toSatisfy(t.isArrayExpression); + expect(symbols.functions.get('doQueryKey')).toSatisfy(t.isArrayExpression); + expect(symbols.functions.get('forInQueryKey')).toSatisfy(t.isArrayExpression); + expect(symbols.mutableValues.has('localNotQuery')).toBe(true); + expect(symbols.mutableValues.has('namespaceApi')).toBe(false); + }); + + it('skips nested non-query declarations and non-collecting query patterns', () => { + const symbols = symbolsFor(` + const source = makeSource(); + + function outer() { + // Nested, non-top-level, non-query named declaration -> skipped. + function helperUnrelated() { + return ['nested-helper'] as const; + } + // Nested querykey-named object pattern whose init is a plain identifier + // (not a call/object/array expression) -> reaches but does not early-return. + const { queryKey: plainIdentInit } = source; + // Nested querykey-named pattern bound from a query-key symbol name. + const { rqKeyNested } = source; + return helperUnrelated(plainIdentInit, rqKeyNested); + } + + outer(); + `); + + expect(symbols.functions.has('helperUnrelated')).toBe(false); + expect(symbols.values.has('rqKeyNested')).toBe(true); + }); + + it('ignores update expressions whose argument is neither an identifier nor a member', () => { + const symbols = symbolsFor(` + let counter = 0; + // The argument of this update expression is a TSAsExpression, so it is not + // recorded as a mutated identifier/member. + (counter as number)++; + `); + + expect(symbols.mutableValues.has('counter')).toBe(false); + }); +}); diff --git a/src/core/analysis/astScan.ts b/src/core/analysis/astScan.ts index 332173e..ef4cb08 100644 --- a/src/core/analysis/astScan.ts +++ b/src/core/analysis/astScan.ts @@ -30,15 +30,8 @@ function addRecord(records: QueryRecord[], input: QueryRecord): void { records.push(input); } -const MAX_JSX_PROP_RESOLVE_DEPTH = 16; -const QUERY_KEYS_TO_INVALIDATE_PROP = 'queryKeysToInvalidate'; const MAX_LOCAL_ACTION_ARG_RESOLVE_DEPTH = 12; -interface QueryKeyExpressionCandidate { - expression: t.Expression; - locNode: t.Node; -} - function functionBindingName( functionPath: NodePath, ): string | undefined { @@ -235,42 +228,6 @@ function resolveNamedGetQueriesDataQueryKeyFromBinding( return getQueriesDataQueryKeyExpression(init, resolver, 0); } -function resolveJsxPropExpression( - expression: t.Expression, - resolver: QueryKeyResolver | undefined, - depth = 0, -): t.Expression { - if (depth >= MAX_JSX_PROP_RESOLVE_DEPTH) { - return unwrapExpression(expression); - } - - const unwrapped = unwrapExpression(expression); - - if (t.isIdentifier(unwrapped) || t.isMemberExpression(unwrapped)) { - const resolved = resolver?.resolveReference(unwrapped); - if (resolved) { - return resolveJsxPropExpression(resolved, resolver, depth + 1); - } - return unwrapped; - } - - if (t.isCallExpression(unwrapped)) { - if (t.isExpression(unwrapped.callee)) { - const resolvedCallee = resolver?.resolveReference(unwrapped.callee); - if (resolvedCallee) { - return resolveJsxPropExpression(resolvedCallee, resolver, depth + 1); - } - } - - const resolvedCall = resolver?.resolveCallResult(unwrapped.callee); - if (resolvedCall) { - return resolveJsxPropExpression(resolvedCall, resolver, depth + 1); - } - } - - return unwrapped; -} - function isUnresolvedNormalizedKey(key: QueryRecord['queryKey']): boolean { if (key.segments.length === 1 && key.segments[0] === 'UNRESOLVED') { return true; @@ -279,152 +236,6 @@ function isUnresolvedNormalizedKey(key: QueryRecord['queryKey']): boolean { return key.id === 'unresolved_query_key'; } -function looksLikeArrayQueryKeyItem( - expression: t.Expression, - resolver: QueryKeyResolver | undefined, - depth: number, -): boolean { - if (depth >= MAX_JSX_PROP_RESOLVE_DEPTH) { - return false; - } - - const resolved = resolveJsxPropExpression(expression, resolver, depth + 1); - if (t.isArrayExpression(resolved)) { - return true; - } - - const normalized = normalizeQueryKey(expression, { defaultMode: 'prefix' }, resolver); - if (isUnresolvedNormalizedKey(normalized) || normalized.source === 'wildcard') { - return false; - } - - return normalized.display.startsWith('[') && normalized.display.endsWith(']'); -} - -function isLikelyQueryKeyCollection( - arrayNode: t.ArrayExpression, - resolver: QueryKeyResolver | undefined, - depth: number, -): boolean { - if (depth >= MAX_JSX_PROP_RESOLVE_DEPTH) { - return false; - } - - let comparableCount = 0; - let arrayLikeCount = 0; - - for (const element of arrayNode.elements) { - if (!element || t.isSpreadElement(element) || !t.isExpression(element)) { - continue; - } - - comparableCount += 1; - if (looksLikeArrayQueryKeyItem(element, resolver, depth + 1)) { - arrayLikeCount += 1; - } - } - - if (comparableCount === 0) { - return true; - } - - return arrayLikeCount > 0; -} - -function collectQueryKeyExpressionsFromProp( - expression: t.Expression, - resolver: QueryKeyResolver | undefined, - depth = 0, -): QueryKeyExpressionCandidate[] { - if (depth >= MAX_JSX_PROP_RESOLVE_DEPTH) { - return [{ expression, locNode: expression }]; - } - - if (t.isConditionalExpression(expression)) { - return [ - ...collectQueryKeyExpressionsFromProp(expression.consequent, resolver, depth + 1), - ...collectQueryKeyExpressionsFromProp(expression.alternate, resolver, depth + 1), - ]; - } - - if (t.isLogicalExpression(expression)) { - if (expression.operator === '&&') { - return collectQueryKeyExpressionsFromProp(expression.right, resolver, depth + 1); - } - - return [ - ...collectQueryKeyExpressionsFromProp(expression.left, resolver, depth + 1), - ...collectQueryKeyExpressionsFromProp(expression.right, resolver, depth + 1), - ]; - } - - const resolvedCollection = resolveJsxPropExpression(expression, resolver, depth + 1); - if ( - !t.isArrayExpression(resolvedCollection) || - !isLikelyQueryKeyCollection(resolvedCollection, resolver, depth + 1) - ) { - return [{ expression, locNode: expression }]; - } - - const collected: QueryKeyExpressionCandidate[] = []; - const useOriginalLocForElements = resolvedCollection !== expression; - - for (const element of resolvedCollection.elements) { - if (!element) { - continue; - } - - if (t.isSpreadElement(element)) { - if (!t.isExpression(element.argument)) { - continue; - } - - const spreadResolved = resolveJsxPropExpression(element.argument, resolver, depth + 1); - if (t.isArrayExpression(spreadResolved) && isLikelyQueryKeyCollection(spreadResolved, resolver, depth + 1)) { - const nested = collectQueryKeyExpressionsFromProp(spreadResolved, resolver, depth + 1); - const locNode = useOriginalLocForElements ? expression : element.argument; - collected.push( - ...nested.map((candidate) => ({ - expression: candidate.expression, - locNode, - })), - ); - continue; - } - - collected.push({ - expression: element.argument, - locNode: useOriginalLocForElements ? expression : element.argument, - }); - continue; - } - - if (!t.isExpression(element)) { - continue; - } - - const itemResolved = resolveJsxPropExpression(element, resolver, depth + 1); - if (t.isArrayExpression(itemResolved) && isLikelyQueryKeyCollection(itemResolved, resolver, depth + 1)) { - const nested = collectQueryKeyExpressionsFromProp(itemResolved, resolver, depth + 1); - const locNode = useOriginalLocForElements ? expression : element; - collected.push( - ...nested.map((candidate) => ({ - expression: candidate.expression, - locNode, - })), - ); - continue; - } - - collected.push({ - expression: element, - locNode: useOriginalLocForElements ? expression : element, - }); - } - - return collected; -} - function shouldSkipPassThroughUnresolvedAction( callPath: NodePath, queryKey: QueryRecord['queryKey'], @@ -454,7 +265,6 @@ function shouldSkipPassThroughUnresolvedAction( const value = unwrapExpression(queryKeyValue); if (t.isIdentifier(value)) { const binding = callPath.scope.getBinding(value.name); - // Keep pass-through parameters (`fn(queryKey: QueryKey)`) as action records. if (binding?.kind === 'param') { return false; } @@ -1523,15 +1333,6 @@ function resolveActionArgsWithLocalBindings( return [resolvedFirst, ...args.slice(1)] as t.CallExpression['arguments']; } -function isIgnorablePropQueryKey(key: QueryRecord['queryKey']): boolean { - if (key.segments.length !== 1) { - return false; - } - - const segment = key.segments[0]; - return segment === 'undefined' || segment === '$undefined' || segment === 'null' || segment === '$null'; -} - export function scanImports(ast: t.File, context: ParseContext): void { traverseAst(ast, { ImportDeclaration(importPath: NodePath) { @@ -2681,7 +2482,6 @@ export function scanCalls( } if (t.isArrayExpression(resolved)) { - // queryOptions()/infiniteQueryOptions() can resolve directly to queryKey arrays. return [resolved]; } @@ -3212,57 +3012,5 @@ export function scanCalls( const loc = locationFromCallNode(node); handleMemberClientCall(optionalCallPath, node.callee, node.arguments, loc); }, - - JSXOpeningElement(jsxPath: NodePath) { - const prop = jsxPath.node.attributes.find((attribute) => { - return ( - t.isJSXAttribute(attribute) && - t.isJSXIdentifier(attribute.name) && - attribute.name.name === QUERY_KEYS_TO_INVALIDATE_PROP - ); - }); - if (!prop || !t.isJSXAttribute(prop)) { - return; - } - - const value = prop.value; - if (!value || !t.isJSXExpressionContainer(value) || t.isJSXEmptyExpression(value.expression)) { - return; - } - - const queryKeyExpressions = collectQueryKeyExpressionsFromProp(value.expression, resolver); - if (queryKeyExpressions.length === 0) { - return; - } - - const emitted = new Set(); - - for (const queryKeyExpression of queryKeyExpressions) { - const itemLoc = locationFromNode(queryKeyExpression.locNode); - const queryKey = normalizeQueryKey(queryKeyExpression.expression, { defaultMode: 'prefix' }, resolver); - if ( - queryKey.source === 'wildcard' || - isUnresolvedNormalizedKey(queryKey) || - isIgnorablePropQueryKey(queryKey) - ) { - continue; - } - - const dedupeKey = `${queryKey.id}:${queryKey.display}:${itemLoc.line}:${itemLoc.column}`; - if (emitted.has(dedupeKey)) { - continue; - } - emitted.add(dedupeKey); - - addRecord(records, { - relation: 'invalidates', - operation: 'invalidateQueries', - file: filePath, - loc: itemLoc, - queryKey, - resolution: 'dynamic', - }); - } - }, }); } diff --git a/src/core/analysis/queryKey.ts b/src/core/analysis/queryKey.ts index d2cbf5d..a257582 100644 --- a/src/core/analysis/queryKey.ts +++ b/src/core/analysis/queryKey.ts @@ -94,6 +94,7 @@ function resolveWithResolver( if (t.isExpression(node.callee)) { return resolver.resolveReference(node.callee); } + return undefined; } if (t.isIdentifier(node) || t.isMemberExpression(node)) { @@ -167,15 +168,19 @@ function memoLikeCallReturnExpression(node: t.CallExpression): t.Expression | un return undefined; } - if (t.isFunctionExpression(firstArg) || t.isArrowFunctionExpression(firstArg)) { + if (isFunctionExpressionLike(firstArg)) { return extractFunctionReturnExpression(firstArg); } return firstArg; } +function isFunctionExpressionLike(node: unknown): node is t.FunctionExpression | t.ArrowFunctionExpression { + return t.isFunctionExpression(node) || t.isArrowFunctionExpression(node); +} + function unwrapFunctionReturnExpression(expression: t.Expression): t.Expression | undefined { - if (t.isFunctionExpression(expression) || t.isArrowFunctionExpression(expression)) { + if (isFunctionExpressionLike(expression)) { return extractFunctionReturnExpression(expression); } @@ -243,8 +248,7 @@ function segmentFromObjectExpression( continue; } - const spreadSource = - resolveQueryKeyExpression(property.argument, resolver, depth + 1) ?? unwrapExpression(property.argument); + const spreadSource = resolveOrUnwrapped(property.argument, resolver, depth + 1); if (t.isObjectExpression(spreadSource)) { const spreadSegment = segmentFromObjectExpression(spreadSource, resolver, depth + 1); const spreadText = spreadSegment.text.trim(); @@ -339,7 +343,6 @@ function isEmptyFallbackExpression(node: t.Expression): boolean { if (t.isArrayExpression(unwrapped)) { return unwrapped.elements.length === 0; } - if (t.isNullLiteral(unwrapped)) { return true; } @@ -374,12 +377,10 @@ function callArgumentsSegment( segments.push({ text: `...${spread.text}`, isStatic: spread.isStatic }); continue; } - if (t.isExpression(arg)) { segments.push(normalizeSegmentResult(segmentFromExpression(arg, resolver, depth + 1))); continue; } - segments.push({ text: UNRESOLVED_SEGMENT, isStatic: false }); } @@ -401,11 +402,45 @@ function simplifyCollectionMethodCallSegment( return undefined; } - // Keep query-key display concise for value-transform chains like dids.slice().sort() or interests?.join(',') - // by surfacing the originating variable expression instead of call(...) text. return objectSegment; } +function isStaticMemberCallSegment( + objectSegment: SegmentResult, + propertySegment: { value: string; isStatic: boolean } | undefined, + argsSegment: SegmentResult, +): boolean { + return objectSegment.isStatic && propertySegment?.isStatic === true && argsSegment.isStatic; +} + +function propertySegmentText(propertySegment: { value: string; isStatic: boolean } | undefined): string { + return propertySegment?.value ?? '?'; +} + +function queryKeyId(rawSegments: string[]): string { + return rawSegments.join('|') || 'empty'; +} + +function queryKeySource(resolution: NormalizedQueryKey['resolution']): NormalizedQueryKey['source'] { + return resolution === 'static' ? 'literal' : 'expression'; +} + +function defaultMatchMode(options: { defaultMode?: MatchMode }, fallback: MatchMode): MatchMode { + return options.defaultMode ?? fallback; +} + +function actionModeFromExact(exact: boolean | undefined): MatchMode { + return exact === true ? 'exact' : 'prefix'; +} + +function predicateMatchMode(exact: boolean | undefined, inferredMode: MatchMode): MatchMode { + return exact === true ? 'exact' : inferredMode; +} + +function missingActionMode(hasPredicate: boolean): MatchMode { + return hasPredicate ? 'predicate' : 'all'; +} + function substituteIdentifierInExpression( expression: t.Expression, identifierName: string, @@ -432,7 +467,6 @@ function substituteIdentifierInExpression( } return t.spreadElement(replaceExpression(element.argument)); } - return t.isExpression(element) ? replaceExpression(element) : t.cloneNode(element, true); }); return cloned; @@ -447,7 +481,6 @@ function substituteIdentifierInExpression( } return t.spreadElement(replaceExpression(property.argument)); } - if (t.isObjectProperty(property) && t.isExpression(property.value)) { const nextValue = replaceExpression(property.value); const next = t.objectProperty( @@ -461,7 +494,6 @@ function substituteIdentifierInExpression( ); return next; } - return t.cloneNode(property, true); }); return cloned; @@ -611,24 +643,20 @@ function resolveObjectPropertyExpression( if (!property) { continue; } - if (t.isObjectProperty(property)) { const keyName = propertyNameFromExpression(property.key, resolver, depth + 1); - if (keyName === propertyName && t.isExpression(property.value)) { + if (keyName === propertyName) { return unwrapExpression(property.value); } continue; } - if (!t.isSpreadElement(property) || !t.isExpression(property.argument)) { continue; } - const spreadSource = resolveActionOptionsObject(property.argument, resolver, depth + 1); if (!t.isObjectExpression(spreadSource)) { continue; } - const nested = resolveObjectPropertyExpression(spreadSource, propertyName, resolver, depth + 1); if (nested) { return nested; @@ -655,14 +683,13 @@ function collectObjectArgumentSubstitutions( if (t.isObjectProperty(property)) { const key = propertyNameFromExpression(property.key, resolver, depth + 1); - if (!key || !t.isExpression(property.value)) { + if (!key) { continue; } target.set(key, unwrapExpression(property.value)); continue; } - if (!t.isSpreadElement(property) || !t.isExpression(property.argument)) { continue; } @@ -747,7 +774,7 @@ function applyFunctionArgumentHints( } const paramName = paramNames[index]; - const replacement = resolveQueryKeyExpression(arg, resolver, depth + 1) ?? unwrapExpression(arg); + const replacement = resolveOrUnwrapped(arg, resolver, depth + 1); if (!expressionContainsIdentifier(nextExpression, paramName)) { continue; } @@ -797,20 +824,15 @@ function applyPositionalArgumentHints( return element ? t.cloneNode(element, true) : null; } - const placeholderName = placeholderNames[index]; - const resolvedElement = resolveQueryKeyExpression(element, resolver, depth + 1) ?? unwrapExpression(element); + const resolvedElement = resolveOrUnwrapped(element, resolver, depth + 1); if (segmentFromExpression(resolvedElement, resolver, depth + 1).isStatic) { - return element ? t.cloneNode(element, true) : null; - } - - const replacement = resolveQueryKeyExpression(arg, resolver, depth + 1) ?? unwrapExpression(arg); - if (placeholderName) { - changed = true; + return t.cloneNode(element, true); } + const replacement = resolveOrUnwrapped(arg, resolver, depth + 1); + changed = true; return t.cloneNode(replacement, true); }); - return changed ? nextExpression : resolvedCall; } @@ -832,10 +854,9 @@ function applyCallArgumentHints( calleeName && /^query.?keys?$/i.test(calleeName) ) { - return resolveQueryKeyExpression(firstArg, resolver, depth + 1) ?? firstArg; + return resolveOrFallback(firstArg, firstArg, resolver, depth + 1); } - - const resolvedFirstArg = resolveQueryKeyExpression(firstArg, resolver, depth + 1) ?? firstArg; + const resolvedFirstArg = resolveOrFallback(firstArg, firstArg, resolver, depth + 1); if (t.isObjectExpression(resolvedFirstArg)) { const hintedByObjectArg = applyObjectArgumentIdentifierHints(resolvedCall, resolvedFirstArg, resolver, depth + 1); if (hintedByObjectArg !== resolvedCall) { @@ -905,21 +926,21 @@ export function resolveQueryKeyExpression( if (t.isIdentifier(unwrapped)) { const resolvedReference = resolver?.resolveReference(unwrapped); if (resolvedReference) { - return resolveQueryKeyExpression(resolvedReference, resolver, depth + 1) ?? resolvedReference; + return resolveOrFallback(resolvedReference, resolvedReference, resolver, depth + 1); } } if (t.isObjectExpression(unwrapped)) { const queryKey = findObjectPropertyValue(unwrapped, 'queryKey', resolver); if (queryKey) { - return resolveQueryKeyExpression(queryKey, resolver, depth + 1) ?? queryKey; + return resolveOrFallback(queryKey, queryKey, resolver, depth + 1); } return unwrapped; } if (t.isCallExpression(unwrapped)) { if (callCalleeName(unwrapped.callee) === 'createQueryKey') { - const resolvedCall = t.isExpression(unwrapped.callee) ? resolver?.resolveCallResult(unwrapped.callee) : undefined; + const resolvedCall = resolver?.resolveCallResult(unwrapped.callee); if (resolvedCall && t.isArrayExpression(resolvedCall)) { const materialized = t.cloneNode(resolvedCall, false); materialized.elements = resolvedCall.elements.map((element, index) => { @@ -927,8 +948,7 @@ export function resolveQueryKeyExpression( if (!arg || t.isSpreadElement(arg) || !t.isExpression(arg)) { return element ? t.cloneNode(element, true) : null; } - - const resolvedArg = resolveQueryKeyExpression(arg, resolver, depth + 1) ?? unwrapExpression(arg); + const resolvedArg = resolveOrUnwrapped(arg, resolver, depth + 1); return t.cloneNode(resolvedArg, true); }); @@ -939,32 +959,25 @@ export function resolveQueryKeyExpression( const firstArg = firstExpressionArgument(unwrapped.arguments); const resolvedFirstArg = firstArg && unwrapped.arguments.length === 1 - ? (resolveQueryKeyExpression(firstArg, resolver, depth + 1) ?? firstArg) + ? resolveOrFallback(firstArg, firstArg, resolver, depth + 1) : undefined; if (firstArg && unwrapped.arguments.length === 1) { if (resolvedFirstArg && t.isObjectExpression(resolvedFirstArg)) { const queryKey = findObjectPropertyValue(resolvedFirstArg, 'queryKey', resolver); if (queryKey) { - return resolveQueryKeyExpression(queryKey, resolver, depth + 1) ?? queryKey; + return resolveOrFallback(queryKey, queryKey, resolver, depth + 1); } } if (isIdentityWrapperCall(unwrapped.callee)) { - return ( - resolveQueryKeyExpression(resolvedFirstArg ?? firstArg, resolver, depth + 1) ?? resolvedFirstArg ?? firstArg - ); + return resolveOrFallback(resolvedFirstArg ?? firstArg, firstArg, resolver, depth + 1); } } - - const resolvedReference = t.isExpression(unwrapped.callee) - ? resolver?.resolveReference(unwrapped.callee) - : undefined; + const resolvedReference = resolver?.resolveReference(unwrapped.callee); if ( resolvedReference && - (t.isFunctionExpression(resolvedReference) || - t.isArrowFunctionExpression(resolvedReference) || - t.isFunctionDeclaration(resolvedReference)) + (isFunctionExpressionLike(resolvedReference) || t.isFunctionDeclaration(resolvedReference)) ) { const resolvedReturn = extractFunctionReturnExpression(resolvedReference); if (resolvedReturn) { @@ -975,7 +988,7 @@ export function resolveQueryKeyExpression( resolver, depth + 1, ); - return resolveQueryKeyExpression(hintedReturn, resolver, depth + 1) ?? hintedReturn; + return resolveOrFallback(hintedReturn, hintedReturn, resolver, depth + 1); } } @@ -983,11 +996,11 @@ export function resolveQueryKeyExpression( if (resolvedCall) { const positionalHintedCall = applyPositionalArgumentHints(unwrapped, resolvedCall, resolver, depth + 1); if (positionalHintedCall !== resolvedCall) { - return resolveQueryKeyExpression(positionalHintedCall, resolver, depth + 1) ?? positionalHintedCall; + return resolveOrFallback(positionalHintedCall, positionalHintedCall, resolver, depth + 1); } const hintedCall = applyCallArgumentHints(unwrapped, resolvedCall, resolver, depth + 1); - return resolveQueryKeyExpression(hintedCall, resolver, depth + 1) ?? hintedCall; + return resolveOrFallback(hintedCall, hintedCall, resolver, depth + 1); } if (resolvedFirstArg && (t.isObjectExpression(resolvedFirstArg) || t.isArrayExpression(resolvedFirstArg))) { @@ -1000,7 +1013,7 @@ export function resolveQueryKeyExpression( if (t.isIdentifier(unwrapped) || t.isMemberExpression(unwrapped)) { const resolved = resolver?.resolveReference(unwrapped); if (resolved) { - return resolveQueryKeyExpression(resolved, resolver, depth + 1) ?? resolved; + return resolveOrFallback(resolved, resolved, resolver, depth + 1); } } @@ -1009,15 +1022,14 @@ export function resolveQueryKeyExpression( if (!propertyName) { const resolvedReference = resolver?.resolveReference(unwrapped); if (resolvedReference) { - return resolveQueryKeyExpression(resolvedReference, resolver, depth + 1) ?? resolvedReference; + return resolveOrFallback(resolvedReference, resolvedReference, resolver, depth + 1); } return unwrapped; } - if (t.isObjectExpression(unwrapped.object)) { const resolved = resolveObjectPropertyExpression(unwrapped.object, propertyName, resolver, depth + 1); if (resolved) { - return resolveQueryKeyExpression(resolved, resolver, depth + 1) ?? resolved; + return resolveOrFallback(resolved, resolved, resolver, depth + 1); } } @@ -1029,13 +1041,13 @@ export function resolveQueryKeyExpression( if (resolvedObject && t.isObjectExpression(resolvedObject)) { const resolved = resolveObjectPropertyExpression(resolvedObject, propertyName, resolver, depth + 1); if (resolved) { - return resolveQueryKeyExpression(resolved, resolver, depth + 1) ?? resolved; + return resolveOrFallback(resolved, resolved, resolver, depth + 1); } } const resolvedReference = resolver?.resolveReference(unwrapped); if (resolvedReference) { - return resolveQueryKeyExpression(resolvedReference, resolver, depth + 1) ?? resolvedReference; + return resolveOrFallback(resolvedReference, resolvedReference, resolver, depth + 1); } } @@ -1054,7 +1066,7 @@ function normalizedUnknownKey(defaultMode: MatchMode): NormalizedQueryKey { } function normalizeSegmentResult(segment: SegmentResult): SegmentResult { - const text = segment.text || UNRESOLVED_SEGMENT; + const text = segmentTextOrUnresolved(segment); if (text === '...spread' || text === 'expr' || text === 'call(expr)') { return { text: UNRESOLVED_SEGMENT, isStatic: false }; } @@ -1069,11 +1081,39 @@ function normalizeSegmentResult(segment: SegmentResult): SegmentResult { }; } +function segmentTextOrUnresolved(segment: SegmentResult): string { + return segment.text || UNRESOLVED_SEGMENT; +} + +function isDisplayableResolvedSegment(segment: SegmentResult): boolean { + return segment.text !== 'expr'; +} + +function valueOrFallback(value: T | undefined, fallback: T): T { + return value === undefined ? fallback : value; +} + +function resolveOrFallback( + node: t.Expression, + fallback: t.Expression, + resolver: QueryKeyResolver | undefined, + depth: number, +): t.Expression { + return valueOrFallback(resolveQueryKeyExpression(node, resolver, depth), fallback); +} + +function resolveOrUnwrapped(node: t.Expression, resolver: QueryKeyResolver | undefined, depth: number): t.Expression { + return resolveOrFallback(node, unwrapExpression(node), resolver, depth); +} + +function isDifferentExpression(left: t.Expression | undefined, right: t.Expression): left is t.Expression { + return left !== undefined && left !== right; +} + function shouldTreatAsWildcardActionKey(key: NormalizedQueryKey): boolean { if (key.id === 'empty' && key.segments.length === 0) { return true; } - if (key.id === UNRESOLVED_QUERY_KEY.toLowerCase()) { return true; } @@ -1112,7 +1152,6 @@ function isQueryCacheLookupCall(node: t.Expression, resolver: QueryKeyResolver | if (!t.isMemberExpression(callee) || callee.computed || !t.isIdentifier(callee.property)) { return false; } - if (!['get', 'find'].includes(callee.property.name)) { return false; } @@ -1120,11 +1159,6 @@ function isQueryCacheLookupCall(node: t.Expression, resolver: QueryKeyResolver | if (t.isIdentifier(callee.object) && callee.object.name.toLowerCase().includes('querycache')) { return true; } - - if (!t.isExpression(callee.object)) { - return false; - } - const objectSegment = segmentFromExpression(callee.object, resolver, depth + 1).text.toLowerCase(); return objectSegment.includes('querycache') || objectSegment.includes('getquerycache'); } @@ -1166,7 +1200,7 @@ function normalizeActionKeyOrWildcard( resolver?: QueryKeyResolver, ): NormalizedQueryKey { const normalized = normalizeQueryKey(node, options, resolver); - const mode = options.defaultMode ?? normalized.matchMode; + const mode = defaultMatchMode(options, normalized.matchMode); const unresolvedQueryKeyReference = isPassThroughQueryKeyReference(node) && normalized.source === 'expression' && @@ -1180,7 +1214,6 @@ function normalizeActionKeyOrWildcard( if (isPassThroughQueryKeyReference(node)) { return buildPassThroughActionKey(mode); } - if (isPassThroughQueryInstanceReference(node, resolver)) { return buildPassThroughActionKey(mode); } @@ -1203,13 +1236,11 @@ function segmentsFromArrayElement( if (!t.isSpreadElement(segment)) { return [normalizeSegmentResult(segmentFromExpression(segment, resolver, depth + 1))]; } - if (!t.isExpression(segment.argument)) { return [{ text: UNRESOLVED_SEGMENT, isStatic: false }]; } - const spreadSource = - resolveQueryKeyExpression(segment.argument, resolver, depth + 1) ?? unwrapExpression(segment.argument); + const spreadSource = resolveOrUnwrapped(segment.argument, resolver, depth + 1); if (t.isArrayExpression(spreadSource)) { const expanded = spreadSource.elements.flatMap((element) => segmentsFromArrayElement(element, resolver, depth + 1)); if (expanded.length > 0) { @@ -1277,11 +1308,9 @@ export function segmentFromExpression( if (isMemoLikeCall(resolvedValue)) { return { text: `$${unwrapped.name}`, isStatic: false }; } - if (t.isMemberExpression(resolvedValue.callee) || t.isOptionalMemberExpression(resolvedValue.callee)) { return { text: `$${unwrapped.name}`, isStatic: false }; } - return segmentFromExpression(resolvedValue, resolver, depth + 1); } @@ -1333,7 +1362,6 @@ export function segmentFromExpression( if (!expr) { continue; } - const segment = t.isExpression(expr) ? segmentFromExpression(expr, resolver, depth + 1) : { text: 'type', isStatic: false }; @@ -1352,8 +1380,7 @@ export function segmentFromExpression( if (!returned) { return { text: 'expr', isStatic: false }; } - - const resolvedReturn = resolveQueryKeyExpression(returned, resolver, depth + 1) ?? unwrapExpression(returned); + const resolvedReturn = resolveOrUnwrapped(returned, resolver, depth + 1); if (t.isArrayExpression(resolvedReturn)) { const first = resolvedReturn.elements[0]; if (!first) { @@ -1373,26 +1400,13 @@ export function segmentFromExpression( if (t.isOptionalMemberExpression(unwrapped)) { const propertyName = propertyNameFromExpression(unwrapped.property as t.Expression, resolver, depth + 1); - if (t.isExpression(unwrapped.object)) { - const resolvedObject = - resolveQueryKeyExpression(unwrapped.object, resolver, depth + 1) ?? unwrapExpression(unwrapped.object); - if (propertyName === 'queryKey' && t.isArrayExpression(resolvedObject)) { - return segmentFromExpression(resolvedObject, resolver, depth + 1); - } - if (t.isCallExpression(resolvedObject)) { - const calleeName = callCalleeName(resolvedObject.callee); - if (calleeName === 'ref' || calleeName === 'shallowRef') { - const firstArg = firstExpressionArgument(resolvedObject.arguments); - if (firstArg) { - return segmentFromExpression(firstArg, resolver, depth + 1); - } - } - } + const resolvedObject = resolveOrUnwrapped(unwrapped.object, resolver, depth + 1); + if (propertyName === 'queryKey' && t.isArrayExpression(resolvedObject)) { + return segmentFromExpression(resolvedObject, resolver, depth + 1); } - const object = segmentFromExpression(unwrapped.object as t.Expression, resolver, depth + 1); + const object = segmentFromExpression(unwrapped.object, resolver, depth + 1); const inferredProperty = inferPropertyName(unwrapped.property as t.Expression | t.PrivateName, resolver, depth + 1); - if (!inferredProperty) { return { text: `${object.text}?.?`, isStatic: false }; } @@ -1430,27 +1444,12 @@ export function segmentFromExpression( } if (propertyName === 'queryKey' && t.isExpression(unwrapped.object)) { - const resolvedObject = - resolveQueryKeyExpression(unwrapped.object, resolver, depth + 1) ?? unwrapExpression(unwrapped.object); + const resolvedObject = resolveOrUnwrapped(unwrapped.object, resolver, depth + 1); if (t.isArrayExpression(resolvedObject)) { return segmentFromExpression(resolvedObject, resolver, depth + 1); } } - if (propertyName === 'value' && t.isExpression(unwrapped.object)) { - const resolvedObject = - resolveQueryKeyExpression(unwrapped.object, resolver, depth + 1) ?? unwrapExpression(unwrapped.object); - if (t.isCallExpression(resolvedObject)) { - const calleeName = callCalleeName(resolvedObject.callee); - if (calleeName === 'ref' || calleeName === 'shallowRef') { - const firstArg = firstExpressionArgument(resolvedObject.arguments); - if (firstArg) { - return segmentFromExpression(firstArg, resolver, depth + 1); - } - } - } - } - const object = segmentFromExpression(unwrapped.object as t.Expression, resolver, depth + 1); const property = propertyName ? { value: propertyName, isStatic: true } @@ -1468,9 +1467,7 @@ export function segmentFromExpression( if (t.isOptionalCallExpression(unwrapped)) { if (t.isOptionalMemberExpression(unwrapped.callee)) { - const objectSegment = t.isExpression(unwrapped.callee.object) - ? segmentFromExpression(unwrapped.callee.object, resolver, depth + 1) - : { text: 'expr', isStatic: false }; + const objectSegment = segmentFromExpression(unwrapped.callee.object, resolver, depth + 1); const propertySegment = inferPropertyName(unwrapped.callee.property, resolver, depth + 1); const simplified = simplifyCollectionMethodCallSegment(objectSegment, propertySegment); if (simplified) { @@ -1478,40 +1475,35 @@ export function segmentFromExpression( } const argsSegment = callArgumentsSegment(unwrapped.arguments, resolver, depth + 1); - const propertyText = propertySegment?.value ?? '?'; + const propertyText = propertySegmentText(propertySegment); const targetText = unwrapped.callee.computed ? `${objectSegment.text}?.[${propertyText}]` : `${objectSegment.text}?.${propertyText}`; return { text: `${targetText}(${argsSegment.text})`, - isStatic: objectSegment.isStatic && (propertySegment?.isStatic ?? false) && argsSegment.isStatic, + isStatic: isStaticMemberCallSegment(objectSegment, propertySegment, argsSegment), }; } if (t.isIdentifier(unwrapped.callee)) { return { text: `call(${unwrapped.callee.name})`, isStatic: false }; } - if (t.isMemberExpression(unwrapped.callee)) { - const objectSegment = t.isExpression(unwrapped.callee.object) - ? segmentFromExpression(unwrapped.callee.object, resolver, depth + 1) - : { text: 'expr', isStatic: false }; + const objectSegment = segmentFromExpression(unwrapped.callee.object, resolver, depth + 1); const propertySegment = inferPropertyName(unwrapped.callee.property, resolver, depth + 1); const simplified = simplifyCollectionMethodCallSegment(objectSegment, propertySegment); if (simplified) { return simplified; } - const argsSegment = callArgumentsSegment(unwrapped.arguments, resolver, depth + 1); - const propertyText = propertySegment?.value ?? '?'; + const propertyText = propertySegmentText(propertySegment); const targetText = unwrapped.callee.computed ? `${objectSegment.text}[${propertyText}]` : `${objectSegment.text}.${propertyText}`; - return { text: `${targetText}(${argsSegment.text})`, - isStatic: objectSegment.isStatic && (propertySegment?.isStatic ?? false) && argsSegment.isStatic, + isStatic: isStaticMemberCallSegment(objectSegment, propertySegment, argsSegment), }; } @@ -1522,7 +1514,7 @@ export function segmentFromExpression( const calleeName = callCalleeName(unwrapped.callee); if (calleeName === 'createQueryKey' || calleeName === 'queryOptions' || calleeName === 'infiniteQueryOptions') { const resolvedKeyExpression = resolveQueryKeyExpression(unwrapped, resolver, depth + 1); - if (resolvedKeyExpression && resolvedKeyExpression !== unwrapped) { + if (isDifferentExpression(resolvedKeyExpression, unwrapped)) { return segmentFromExpression(resolvedKeyExpression, resolver, depth + 1); } } @@ -1551,32 +1543,12 @@ export function segmentFromExpression( if (memoReturn) { return segmentFromExpression(memoReturn, resolver, depth + 1); } - - if (t.isExpression(unwrapped.callee)) { - const resolvedCallee = resolver?.resolveReference(unwrapped.callee); - if (resolvedCallee) { - if (t.isFunctionExpression(resolvedCallee) || t.isArrowFunctionExpression(resolvedCallee)) { - const returned = extractFunctionReturnExpression(resolvedCallee); - if (returned) { - return segmentFromExpression(returned, resolver, depth + 1); - } - } - - const resolvedValue = segmentFromExpression(resolvedCallee, resolver, depth + 1); - if (resolvedValue.text !== 'expr') { - return resolvedValue; - } - } - } - if (t.isIdentifier(unwrapped.callee)) { return { text: `call(${unwrapped.callee.name})`, isStatic: false }; } if (t.isMemberExpression(unwrapped.callee)) { - const objectSegment = t.isExpression(unwrapped.callee.object) - ? segmentFromExpression(unwrapped.callee.object, resolver, depth + 1) - : { text: 'expr', isStatic: false }; + const objectSegment = segmentFromExpression(unwrapped.callee.object, resolver, depth + 1); const propertySegment = inferPropertyName(unwrapped.callee.property, resolver, depth + 1); const simplified = simplifyCollectionMethodCallSegment(objectSegment, propertySegment); if (simplified) { @@ -1591,7 +1563,7 @@ export function segmentFromExpression( return { text: `${targetText}(${argsSegment.text})`, - isStatic: objectSegment.isStatic && (propertySegment?.isStatic ?? false) && argsSegment.isStatic, + isStatic: isStaticMemberCallSegment(objectSegment, propertySegment, argsSegment), }; } @@ -1669,22 +1641,18 @@ export function readBooleanProperty(objectNode: t.ObjectExpression, propName: st if (t.isIdentifier(value.key)) { return value.key.name === propName; } - if (t.isStringLiteral(value.key)) { return value.key.value === propName; } - return false; }); if (!prop || !t.isObjectProperty(prop)) { return undefined; } - if (t.isBooleanLiteral(prop.value)) { return prop.value.value; } - return undefined; } @@ -1708,7 +1676,6 @@ export function findObjectPropertyValue( if (t.isIdentifier(property.key) && property.key.name === propName && t.isExpression(property.value)) { return unwrapExpression(property.value); } - if (t.isStringLiteral(property.key) && property.key.value === propName && t.isExpression(property.value)) { return unwrapExpression(property.value); } @@ -1743,25 +1710,23 @@ export function normalizeQueryKey( if (options.wildcardIfMissing) { return buildAllQueryCacheKey('dynamic', 'ALL_QUERY_CACHE'); } - return normalizedUnknownKey(options.defaultMode ?? 'unknown'); } - - const resolved = resolveQueryKeyExpression(node, resolver) ?? unwrapExpression(node); + const resolved = resolveOrUnwrapped(node, resolver, 0); if (t.isArrayExpression(resolved)) { const segments = resolved.elements.flatMap((segment) => segmentsFromArrayElement(segment, resolver, 0)); const resolution = segments.every((seg) => seg.isStatic) ? 'static' : 'dynamic'; - const rawSegments = segments.map((seg) => seg.text || UNRESOLVED_SEGMENT); + const rawSegments = segments.map(segmentTextOrUnresolved); return { - id: rawSegments.join('|') || 'empty', + id: queryKeyId(rawSegments), display: `[${rawSegments.join(', ')}]`, segments: rawSegments, - matchMode: options.defaultMode ?? 'prefix', + matchMode: defaultMatchMode(options, 'prefix'), resolution, - source: resolution === 'static' ? 'literal' : 'expression', + source: queryKeySource(resolution), }; } @@ -1770,8 +1735,7 @@ export function normalizeQueryKey( if (!argument || !t.isExpression(argument)) { return { text: UNRESOLVED_SEGMENT, isStatic: false }; } - - const resolvedArgument = resolveQueryKeyExpression(argument, resolver, 0) ?? unwrapExpression(argument); + const resolvedArgument = resolveOrUnwrapped(argument, resolver, 0); return normalizeSegmentResult(segmentFromExpression(resolvedArgument, resolver, 0)); }); @@ -1780,27 +1744,27 @@ export function normalizeQueryKey( } const resolution = segments.every((seg) => seg.isStatic) ? 'static' : 'dynamic'; - const rawSegments = segments.map((seg) => seg.text || UNRESOLVED_SEGMENT); + const rawSegments = segments.map(segmentTextOrUnresolved); return { - id: rawSegments.join('|') || 'empty', + id: queryKeyId(rawSegments), display: `[${rawSegments.join(', ')}]`, segments: rawSegments, - matchMode: options.defaultMode ?? 'prefix', + matchMode: defaultMatchMode(options, 'prefix'), resolution, - source: resolution === 'static' ? 'literal' : 'expression', + source: queryKeySource(resolution), }; } const segment = normalizeSegmentResult(segmentFromExpression(resolved, resolver)); const resolution = segment.isStatic ? 'static' : 'dynamic'; return { - id: segment.text || UNRESOLVED_SEGMENT, - display: segment.text || UNRESOLVED_SEGMENT, - segments: [segment.text || UNRESOLVED_SEGMENT], - matchMode: options.defaultMode ?? 'exact', + id: segmentTextOrUnresolved(segment), + display: segmentTextOrUnresolved(segment), + segments: [segmentTextOrUnresolved(segment)], + matchMode: defaultMatchMode(options, 'exact'), resolution, - source: resolution === 'static' ? 'literal' : 'expression', + source: queryKeySource(resolution), }; } @@ -1817,10 +1781,7 @@ export function inferHookQueryKey( return normalizeQueryKey(undefined, { defaultMode: 'unknown' }, resolver); } - const resolved = - unwrapFunctionReturnExpression(unwrapExpression(first)) ?? - resolveQueryKeyExpression(first, resolver) ?? - unwrapExpression(first); + const resolved = unwrapFunctionReturnExpression(unwrapExpression(first)) ?? resolveOrUnwrapped(first, resolver, 0); if (t.isObjectExpression(resolved)) { const keyNode = findObjectPropertyValue(resolved, 'queryKey', resolver); return normalizeQueryKey(keyNode, { defaultMode: 'exact' }, resolver); @@ -1839,21 +1800,17 @@ function collectQueryKeyExpressionsFromQueryOptionEntry( } const resolved = - unwrapFunctionReturnExpression(unwrapExpression(expression)) ?? - resolveQueryKeyExpression(expression, resolver, depth + 1) ?? - unwrapExpression(expression); + unwrapFunctionReturnExpression(unwrapExpression(expression)) ?? resolveOrUnwrapped(expression, resolver, depth + 1); if (t.isConditionalExpression(resolved)) { return [ ...collectQueryKeyExpressionsFromQueryOptionEntry(resolved.consequent, resolver, depth + 1), ...collectQueryKeyExpressionsFromQueryOptionEntry(resolved.alternate, resolver, depth + 1), ]; } - if (t.isLogicalExpression(resolved)) { if (resolved.operator === '&&') { return collectQueryKeyExpressionsFromQueryOptionEntry(resolved.right, resolver, depth + 1); } - return [ ...collectQueryKeyExpressionsFromQueryOptionEntry(resolved.left, resolver, depth + 1), ...collectQueryKeyExpressionsFromQueryOptionEntry(resolved.right, resolver, depth + 1), @@ -1875,10 +1832,8 @@ function collectQueryKeyExpressionsFromQueryOptionEntry( } if (t.isArrayExpression(resolved)) { - // `queryOptions(...)` / `infiniteQueryOptions(...)` can resolve directly to a queryKey array. return [resolved]; } - if (t.isCallExpression(resolved)) { return collectQueryKeyExpressionsFromQueryCollectionCall(resolved, resolver, depth + 1); } @@ -1896,21 +1851,17 @@ function collectQueryKeyExpressionsFromQueriesCollection( } const resolved = - unwrapFunctionReturnExpression(unwrapExpression(expression)) ?? - resolveQueryKeyExpression(expression, resolver, depth + 1) ?? - unwrapExpression(expression); + unwrapFunctionReturnExpression(unwrapExpression(expression)) ?? resolveOrUnwrapped(expression, resolver, depth + 1); if (t.isConditionalExpression(resolved)) { return [ ...collectQueryKeyExpressionsFromQueriesCollection(resolved.consequent, resolver, depth + 1), ...collectQueryKeyExpressionsFromQueriesCollection(resolved.alternate, resolver, depth + 1), ]; } - if (t.isLogicalExpression(resolved)) { if (resolved.operator === '&&') { return collectQueryKeyExpressionsFromQueriesCollection(resolved.right, resolver, depth + 1); } - return [ ...collectQueryKeyExpressionsFromQueriesCollection(resolved.left, resolver, depth + 1), ...collectQueryKeyExpressionsFromQueriesCollection(resolved.right, resolver, depth + 1), @@ -1932,18 +1883,10 @@ function collectQueryKeyExpressionsFromQueriesCollection( } if (t.isSpreadElement(element)) { - if (!t.isExpression(element.argument)) { - continue; - } - queryKeyNodes.push(...collectQueryKeyExpressionsFromQueriesCollection(element.argument, resolver, depth + 1)); continue; } - if (!t.isExpression(element)) { - continue; - } - queryKeyNodes.push(...collectQueryKeyExpressionsFromQueryOptionEntry(element, resolver, depth + 1)); } @@ -1959,10 +1902,9 @@ function resolveCollectionMapperResult( return undefined; } - if (t.isFunctionExpression(mapperArg) || t.isArrowFunctionExpression(mapperArg)) { + if (isFunctionExpressionLike(mapperArg)) { return extractFunctionReturnExpression(mapperArg); } - if (!t.isIdentifier(mapperArg) && !t.isMemberExpression(mapperArg)) { return undefined; } @@ -1971,11 +1913,9 @@ function resolveCollectionMapperResult( if (!resolved) { return undefined; } - - if (t.isFunctionExpression(resolved) || t.isArrowFunctionExpression(resolved)) { + if (isFunctionExpressionLike(resolved)) { return extractFunctionReturnExpression(resolved); } - return resolved; } @@ -1987,19 +1927,15 @@ function collectQueryKeyExpressionsFromQueryCollectionCall( if (depth >= MAX_RESOLVE_DEPTH) { return []; } - if (!t.isMemberExpression(callNode.callee) && !t.isOptionalMemberExpression(callNode.callee)) { return []; } - if (callNode.callee.computed || !t.isIdentifier(callNode.callee.property)) { return []; } const method = callNode.callee.property.name; - const source = t.isExpression(callNode.callee.object) - ? collectQueryKeyExpressionsFromQueriesCollection(callNode.callee.object, resolver, depth + 1) - : undefined; + const source = collectQueryKeyExpressionsFromQueriesCollection(callNode.callee.object, resolver, depth + 1); if (method === 'map') { const mapperArg = firstExpressionArgument(callNode.arguments); @@ -2007,8 +1943,7 @@ function collectQueryKeyExpressionsFromQueryCollectionCall( if (mapperResult) { return collectQueryKeyExpressionsFromQueryOptionEntry(mapperResult, resolver, depth + 1); } - - return source ?? []; + return source; } if (method === 'flatMap') { @@ -2017,16 +1952,14 @@ function collectQueryKeyExpressionsFromQueryCollectionCall( if (mapperResult) { return collectQueryKeyExpressionsFromQueriesCollection(mapperResult, resolver, depth + 1); } - - return source ?? []; + return source; } if (QUERY_COLLECTION_PASSTHROUGH_METHODS.has(method)) { - return source ?? []; + return source; } - if (method === 'concat') { - const combined: t.Expression[] = [...(source ?? [])]; + const combined: t.Expression[] = [...source]; for (const arg of callNode.arguments) { if (!arg || t.isSpreadElement(arg) || !t.isExpression(arg)) { continue; @@ -2035,7 +1968,6 @@ function collectQueryKeyExpressionsFromQueryCollectionCall( } return combined; } - return []; } @@ -2066,7 +1998,6 @@ export function isOpaqueCollectionQueryKey(queryKey: NormalizedQueryKey): boolea if (normalized.startsWith('$')) { return true; } - if (normalized.startsWith('call(') || normalized.startsWith('cond(')) { return true; } @@ -2092,8 +2023,7 @@ export function inferHookQueryKeys( if (!first || !t.isExpression(first)) { return [normalizeQueryKey(undefined, { defaultMode: 'unknown' }, resolver)]; } - - const resolvedFirst = resolveQueryKeyExpression(first, resolver) ?? unwrapExpression(first); + const resolvedFirst = resolveOrUnwrapped(first, resolver, 0); let queryKeyExpressions: t.Expression[] = []; if (t.isObjectExpression(resolvedFirst)) { @@ -2122,10 +2052,6 @@ export function inferHookQueryKeys( } } - if (deduped.size === 0) { - return [inferHookQueryKey(args, resolver)]; - } - const dedupedValues = [...deduped.values()]; if (dedupedValues.every(isOpaqueCollectionQueryKey)) { return [buildPassThroughActionKey('exact')]; @@ -2143,16 +2069,13 @@ function isInlineQueryKeyObject(expression: t.Expression, depth: number): boolea if (!t.isObjectExpression(unwrapped)) { return false; } - if (findObjectPropertyValue(unwrapped, 'queryKey', undefined)) { return true; } - const queriesNode = findObjectPropertyValue(unwrapped, 'queries', undefined); if (queriesNode && isInlineQueryKeyCollection(queriesNode, depth + 1)) { return true; } - return false; } @@ -2222,7 +2145,6 @@ export function isHookCallDirectQueryKeyDeclaration(args: t.CallExpression['argu if (normalizedHookName === 'usequeries') { return isInlineQueryKeyCollection(unwrapped, 0); } - return true; } @@ -2255,7 +2177,6 @@ function queryKeyIndexFromAccessExpression( if (!t.isMemberExpression(object) && !t.isOptionalMemberExpression(object)) { return undefined; } - if (object.computed) { return undefined; } @@ -2272,8 +2193,7 @@ function queryKeyIndexFromAccessExpression( } else if (t.isStringLiteral(indexExpression)) { rawIndex = Number.parseInt(indexExpression.value, 10); } else if (t.isExpression(indexExpression)) { - const resolvedIndex = - resolveQueryKeyExpression(indexExpression, resolver, depth + 1) ?? unwrapExpression(indexExpression); + const resolvedIndex = resolveOrUnwrapped(indexExpression, resolver, depth + 1); if (t.isNumericLiteral(resolvedIndex)) { rawIndex = resolvedIndex.value; } else if (t.isStringLiteral(resolvedIndex)) { @@ -2306,12 +2226,10 @@ function setPredicateQueryKeyConstraint( constraints.set(index, normalized); return; } - if (existing.text === normalized.text) { constraints.set(index, { text: existing.text, isStatic: existing.isStatic && normalized.isStatic }); return; } - if (existing.text === UNRESOLVED_SEGMENT) { constraints.set(index, normalized); } @@ -2346,23 +2264,14 @@ function collectPredicateQueryKeyConstraints( if (!t.isBinaryExpression(unwrapped) || (unwrapped.operator !== '===' && unwrapped.operator !== '==')) { return; } - - const leftIndex = t.isExpression(unwrapped.left) - ? queryKeyIndexFromAccessExpression(unwrapped.left, resolver, depth + 1) - : undefined; - const rightIndex = t.isExpression(unwrapped.right) - ? queryKeyIndexFromAccessExpression(unwrapped.right, resolver, depth + 1) - : undefined; + const leftIndex = queryKeyIndexFromAccessExpression(unwrapped.left, resolver, depth + 1); + const rightIndex = queryKeyIndexFromAccessExpression(unwrapped.right, resolver, depth + 1); if (typeof leftIndex === 'number' && typeof rightIndex === 'number') { return; } if (typeof leftIndex === 'number') { - if (!t.isExpression(unwrapped.right)) { - return; - } - const resolvedValue = - resolveQueryKeyExpression(unwrapped.right, resolver, depth + 1) ?? unwrapExpression(unwrapped.right); + const resolvedValue = resolveOrUnwrapped(unwrapped.right, resolver, depth + 1); setPredicateQueryKeyConstraint( constraints, leftIndex, @@ -2372,11 +2281,7 @@ function collectPredicateQueryKeyConstraints( } if (typeof rightIndex === 'number') { - if (!t.isExpression(unwrapped.left)) { - return; - } - const resolvedValue = - resolveQueryKeyExpression(unwrapped.left, resolver, depth + 1) ?? unwrapExpression(unwrapped.left); + const resolvedValue = resolveOrUnwrapped(unwrapped.left, resolver, depth + 1); setPredicateQueryKeyConstraint( constraints, rightIndex, @@ -2389,7 +2294,7 @@ function inferActionQueryKeyFromPredicate( predicateNode: t.Expression, resolver: QueryKeyResolver | undefined, ): NormalizedQueryKey | undefined { - const resolvedPredicate = resolveQueryKeyExpression(predicateNode, resolver) ?? unwrapExpression(predicateNode); + const resolvedPredicate = resolveOrUnwrapped(predicateNode, resolver, 0); let conditionExpression: t.Expression | undefined; if (t.isFunctionExpression(resolvedPredicate) || t.isArrowFunctionExpression(resolvedPredicate)) { @@ -2397,7 +2302,6 @@ function inferActionQueryKeyFromPredicate( } else { conditionExpression = resolvedPredicate; } - if (!conditionExpression) { return undefined; } @@ -2420,16 +2324,15 @@ function inferActionQueryKeyFromPredicate( if (segments.length === 0) { return undefined; } - - const rawSegments = segments.map((segment) => segment.text || UNRESOLVED_SEGMENT); + const rawSegments = segments.map(segmentTextOrUnresolved); const resolution = segments.every((segment) => segment.isStatic) ? 'static' : 'dynamic'; return { - id: rawSegments.join('|') || 'empty', + id: queryKeyId(rawSegments), display: `[${rawSegments.join(', ')}]`, segments: rawSegments, matchMode: 'prefix', resolution, - source: resolution === 'static' ? 'literal' : 'expression', + source: queryKeySource(resolution), }; } @@ -2452,7 +2355,7 @@ export function inferActionQueryKey( } if (method === 'setQueryData') { - const resolvedFirst = resolveQueryKeyExpression(first, resolver) ?? unwrapExpression(first); + const resolvedFirst = resolveOrUnwrapped(first, resolver, 0); const normalized = normalizeQueryKey(resolvedFirst, { defaultMode: 'exact' }, resolver); const unresolvedPassThroughReference = isPassThroughQueryKeyReference(first) && @@ -2493,34 +2396,72 @@ export function inferActionQueryKey( wildcardIfMissing: true, }); } - - const resolved = resolveQueryKeyExpression(first, resolver) ?? unwrapExpression(first); + const resolved = resolveOrUnwrapped(first, resolver, 0); if (!t.isObjectExpression(resolved)) { return normalizeActionKeyOrWildcard(resolved, { defaultMode: 'prefix' }, resolver); } - const keyNode = findObjectPropertyValue(resolved, 'queryKey', resolver); const exact = readBooleanProperty(resolved, 'exact'); - const mode: MatchMode = exact === true ? 'exact' : 'prefix'; - + const mode = actionModeFromExact(exact); if (keyNode) { return normalizeActionKeyOrWildcard(keyNode, { defaultMode: mode }, resolver); } - const predicateNode = findObjectPropertyValue(resolved, 'predicate', resolver); if (predicateNode) { const inferredFromPredicate = inferActionQueryKeyFromPredicate(predicateNode, resolver); if (inferredFromPredicate) { return { ...inferredFromPredicate, - matchMode: exact === true ? 'exact' : inferredFromPredicate.matchMode, + matchMode: predicateMatchMode(exact, inferredFromPredicate.matchMode), }; } } - const hasPredicate = !!predicateNode; return normalizeQueryKey(undefined, { - defaultMode: hasPredicate ? 'predicate' : 'all', + defaultMode: missingActionMode(hasPredicate), wildcardIfMissing: true, }); } + +export const __queryKeyTestInternals = { + applyCallArgumentHints, + actionModeFromExact, + applyFunctionArgumentHints, + applyObjectArgumentIdentifierHints, + applyPositionalArgumentHints, + callArgumentsSegment, + collectObjectArgumentSubstitutions, + collectPredicateQueryKeyConstraints, + collectQueryKeyExpressionsFromQueriesCollection, + collectQueryKeyExpressionsFromQueryCollectionCall, + collectQueryKeyExpressionsFromQueryOptionEntry, + expressionContainsIdentifier, + functionParameterNames, + inferActionQueryKeyFromPredicate, + isEmptyFallbackExpression, + isInlineQueryKeyCollection, + isInlineQueryKeyObject, + isFunctionExpressionLike, + isPassThroughQueryInstanceReference, + isPassThroughQueryKeyReference, + isQueryCacheLookupCall, + isDisplayableResolvedSegment, + normalizeActionKeyOrWildcard, + defaultMatchMode, + missingActionMode, + predicateMatchMode, + propertyNameFromExpression, + propertySegmentText, + queryKeyId, + queryKeySource, + queryKeyIndexFromAccessExpression, + resolveActionOptionsObject, + resolveCollectionMapperResult, + resolveObjectPropertyExpression, + segmentsFromArrayElement, + setPredicateQueryKeyConstraint, + shouldTreatAsWildcardActionKey, + simplifyCollectionMethodCallSegment, + segmentTextOrUnresolved, + substituteIdentifierInExpression, +}; diff --git a/src/core/analysis/resolver.ts b/src/core/analysis/resolver.ts index 76133aa..0770040 100644 --- a/src/core/analysis/resolver.ts +++ b/src/core/analysis/resolver.ts @@ -49,6 +49,47 @@ function commonPathPrefixLength(left: string, right: string): number { return count; } +function compareResolutionCandidates(fromDir: string, a: string, b: string): number { + const aDir = path.dirname(a); + const bDir = path.dirname(b); + + const commonA = commonPathPrefixLength(fromDir, aDir); + const commonB = commonPathPrefixLength(fromDir, bDir); + if (commonA !== commonB) { + return commonB - commonA; + } + const relativeA = path.relative(fromDir, aDir); + const relativeB = path.relative(fromDir, bDir); + const upA = relativeA.split(path.sep).filter((segment) => segment === '..').length; + const upB = relativeB.split(path.sep).filter((segment) => segment === '..').length; + if (upA !== upB) { + return upA - upB; + } + const distanceA = relativeA.split(path.sep).filter(Boolean).length; + const distanceB = relativeB.split(path.sep).filter(Boolean).length; + if (distanceA !== distanceB) { + return distanceA - distanceB; + } + if (a.length !== b.length) { + return a.length - b.length; + } + return a.localeCompare(b); +} + +function candidatePathsForTarget(target: string): string[] { + const hasExplicitExtension = RESOLVE_EXTENSIONS.some((ext) => target.endsWith(ext)); + return hasExplicitExtension + ? [target] + : [ + ...RESOLVE_EXTENSIONS.map((ext) => `${target}${ext}`), + ...RESOLVE_EXTENSIONS.map((ext) => path.join(target, `index${ext}`)), + ]; +} + +function importBindingExportName(binding: ImportBinding): string { + return binding.kind === 'default' ? 'default' : (binding.imported ?? 'default'); +} + function isLikelyQueryKeyFactoryIdentifier(name: string): boolean { if (!name) { return false; @@ -194,7 +235,6 @@ function parseJsonObject(text: string): Record | undefined { } catch { return undefined; } - return undefined; } @@ -220,6 +260,26 @@ function asStringArray(value: unknown): string[] | undefined { return output.length > 0 ? output : undefined; } +function valueOrFallback(value: T | undefined, fallback: T): T { + return value === undefined ? fallback : value; +} + +function isFunctionExpressionLike(node: unknown): node is t.FunctionExpression | t.ArrowFunctionExpression { + return t.isFunctionExpression(node) || t.isArrowFunctionExpression(node); +} + +function extractReturnFromFunctionLike( + node: t.Expression | t.FunctionDeclaration | undefined, +): t.Expression | undefined { + if (!node) { + return undefined; + } + if (isFunctionExpressionLike(node) || t.isFunctionDeclaration(node)) { + return extractFunctionReturnExpression(node); + } + return undefined; +} + function resolveExtendsConfigPath(configDir: string, extendsValue: string): string | undefined { const value = extendsValue.trim(); if (!value) { @@ -244,11 +304,8 @@ function resolveExtendsConfigPath(configDir: string, extendsValue: string): stri try { const resolved = NODE_REQUIRE.resolve(candidate, { paths: [configDir] }); return normalizeAnalyzerPath(resolved); - } catch { - // try next package candidate - } + } catch {} } - return undefined; } @@ -258,7 +315,6 @@ function mergePathAliases(configPath: string, seen: Set): ResolvedPathAl if (cached) { return cached; } - if (seen.has(normalizedConfig)) { return { paths: new Map() }; } @@ -379,11 +435,9 @@ function getPathAliasEntries(fromFile: string, workspaceRoot: string): PathAlias if (leftHasWildcard !== rightHasWildcard) { return leftHasWildcard ? 1 : -1; } - if (left.pattern.length !== right.pattern.length) { return right.pattern.length - left.pattern.length; } - return left.pattern.localeCompare(right.pattern); }); @@ -421,11 +475,22 @@ function propertyNameFromMemberExpression( if (t.isNumericLiteral(node.property)) { return String(node.property.value); } - if (t.isExpression(node.property)) { return getExpressionValue(node.property); } + return undefined; +} +function staticPropertyKeyName(key: t.Expression | t.PrivateName): string | undefined { + if (t.isIdentifier(key)) { + return key.name; + } + if (t.isStringLiteral(key)) { + return key.value; + } + if (t.isNumericLiteral(key)) { + return String(key.value); + } return undefined; } @@ -435,20 +500,13 @@ function objectPropertyValue(objectNode: t.ObjectExpression, propertyName: strin continue; } - const key = property.key; - let keyName: string | undefined; - if (t.isIdentifier(key)) { - keyName = key.name; - } else if (t.isStringLiteral(key)) { - keyName = key.value; - } + const keyName = staticPropertyKeyName(property.key); if (!keyName || keyName !== propertyName || !t.isExpression(property.value)) { continue; } return unwrapExpression(property.value); } - return undefined; } @@ -479,11 +537,9 @@ function callCalleeName(callee: t.CallExpression['callee']): string | undefined if (t.isIdentifier(callee)) { return callee.name; } - if (t.isMemberExpression(callee) && !callee.computed && t.isIdentifier(callee.property)) { return callee.property.name; } - return undefined; } @@ -502,7 +558,6 @@ function isIdentityWrapperCall(callee: t.CallExpression['callee']): boolean { if (t.isIdentifier(callee) && (callee.name === 'queryOptions' || callee.name === 'infiniteQueryOptions')) { return true; } - if ( t.isMemberExpression(callee) && !callee.computed && @@ -534,13 +589,7 @@ function resolveModuleFile( for (const targetPattern of entry.targets) { const target = targetPattern.includes('*') ? targetPattern.replace(/\*/g, captured) : targetPattern; - const hasExplicitExtension = RESOLVE_EXTENSIONS.some((ext) => target.endsWith(ext)); - const candidates = hasExplicitExtension - ? [target] - : [ - ...RESOLVE_EXTENSIONS.map((ext) => `${target}${ext}`), - ...RESOLVE_EXTENSIONS.map((ext) => path.join(target, `index${ext}`)), - ]; + const candidates = candidatePathsForTarget(target); for (const candidate of candidates) { const normalized = normalizeAnalyzerPath(candidate); @@ -560,45 +609,13 @@ function resolveModuleFile( } const fromDir = path.dirname(normalizeAnalyzerPath(fromFile)); - const uniqueMatches = [...new Set(matches)].sort((a, b) => { - const aDir = path.dirname(a); - const bDir = path.dirname(b); - - const commonA = commonPathPrefixLength(fromDir, aDir); - const commonB = commonPathPrefixLength(fromDir, bDir); - if (commonA !== commonB) { - return commonB - commonA; - } - - const relativeA = path.relative(fromDir, aDir); - const relativeB = path.relative(fromDir, bDir); - - const upA = relativeA.split(path.sep).filter((segment) => segment === '..').length; - const upB = relativeB.split(path.sep).filter((segment) => segment === '..').length; - if (upA !== upB) { - return upA - upB; - } - - const distanceA = relativeA.split(path.sep).filter(Boolean).length; - const distanceB = relativeB.split(path.sep).filter(Boolean).length; - if (distanceA !== distanceB) { - return distanceA - distanceB; - } - - if (a.length !== b.length) { - return a.length - b.length; - } - - return a.localeCompare(b); - }); + const uniqueMatches = [...new Set(matches)].sort((a, b) => compareResolutionCandidates(fromDir, a, b)); return uniqueMatches[0]; } - const base = isAbsolute ? source : path.resolve(path.dirname(fromFile), source); const candidates: string[] = []; const hasExplicitExtension = RESOLVE_EXTENSIONS.some((ext) => source.endsWith(ext)); - if (hasExplicitExtension) { candidates.push(base); } else { @@ -769,7 +786,10 @@ function expressionToLiteralString( depth: number, seen: Set, ): string | undefined { - const resolved = resolveReferenceInternal(filePath, expression, depth + 1, seen) ?? unwrapExpression(expression); + const resolved = valueOrFallback( + resolveReferenceInternal(filePath, expression, depth + 1, seen), + unwrapExpression(expression), + ); if (t.isStringLiteral(resolved)) { return resolved.value; } @@ -825,11 +845,16 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor const ast = parseSource(raw, normalizedTargetFile); index.files.set(normalizedTargetFile, buildFileSymbols(normalizedTargetFile, ast)); index.fileSet.add(normalizedTargetFile); - } catch { - // Ignore unparseable support files and let normal resolution fail closed. - } + } catch {} }; + const resolveReferenceOrFallback = ( + fromFile: string, + expression: t.Expression, + fallback: t.Expression, + depth: number, + seen: Set, + ): t.Expression => valueOrFallback(resolveReferenceInternal(fromFile, expression, depth, seen), fallback); const resolveExportValue = ( targetFile: string, exportName: string, @@ -883,7 +908,6 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor if (!nestedFile) { continue; } - const nestedName = reExport.imported ?? exportName; const value = resolveExportValue(nestedFile, nestedName, depth + 1, seen); if (value) { @@ -938,7 +962,6 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor continue; } - if (reExport.exported !== exportName) { continue; } @@ -947,7 +970,6 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor if (!nestedFile) { continue; } - const nestedName = reExport.imported ?? exportName; const value = resolveExportFunctionReturn(nestedFile, nestedName, depth + 1, seen); if (value) { @@ -972,8 +994,7 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor if (!targetFile) { return undefined; } - - const exportName = binding.kind === 'default' ? 'default' : (binding.imported ?? 'default'); + const exportName = importBindingExportName(binding); return resolveExportValue(targetFile, exportName, depth + 1, seen); }; @@ -991,8 +1012,7 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor if (!targetFile) { return undefined; } - - const exportName = binding.kind === 'default' ? 'default' : (binding.imported ?? 'default'); + const exportName = importBindingExportName(binding); return resolveExportFunctionReturn(targetFile, exportName, depth + 1, seen); }; @@ -1058,7 +1078,10 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor } } if (t.isIdentifier(localValue) && localValue.name !== localName) { - const resolvedAlias = resolveLocalValue(fromFile, localValue.name, depth + 1, seen) ?? localValue; + const resolvedAlias = valueOrFallback( + resolveLocalValue(fromFile, localValue.name, depth + 1, seen), + localValue, + ); return markExpressionOrigin(resolvedAlias, fromFile); } @@ -1116,12 +1139,10 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor if (t.isIdentifier(localValue) && localValue.name !== localName) { return resolveLocalFunctionReturn(fromFile, localValue.name, depth + 1, seen); } - - if (t.isFunctionExpression(localValue) || t.isArrowFunctionExpression(localValue)) { + if (isFunctionExpressionLike(localValue)) { const returned = extractFunctionReturnExpression(localValue); return markExpressionOrigin(returned, fromFile); } - return undefined; } @@ -1168,12 +1189,10 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor if (refreshedSymbols.mutableValues.has(localName)) { return undefined; } - if (t.isIdentifier(localValue) && localValue.name !== localName) { return resolveLocalFunctionNode(fromFile, localValue.name, depth + 1, seen); } - - if (t.isFunctionExpression(localValue) || t.isArrowFunctionExpression(localValue)) { + if (isFunctionExpressionLike(localValue)) { return localValue; } } @@ -1199,7 +1218,6 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor names.push(param.name); continue; } - if (t.isAssignmentPattern(param) && t.isIdentifier(param.left)) { names.push(param.left.name); } @@ -1241,7 +1259,6 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor stack.push({ node: child, asReference: childIsReference }); }); } - return false; } @@ -1264,15 +1281,10 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor if (!element) { return null; } - if (t.isSpreadElement(element)) { - if (!t.isExpression(element.argument)) { - return t.cloneNode(element, true); - } - return t.spreadElement(replaceExpression(element.argument)); + return t.spreadElement(replaceExpression(element.argument as t.Expression)); } - - return t.isExpression(element) ? replaceExpression(element) : t.cloneNode(element, true); + return replaceExpression(element as t.Expression); }); return cloned; } @@ -1281,31 +1293,23 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor const cloned = t.cloneNode(node, false); cloned.properties = cloned.properties.map((property) => { if (t.isSpreadElement(property)) { - if (!t.isExpression(property.argument)) { - return t.cloneNode(property, true); - } - return t.spreadElement(replaceExpression(property.argument)); + return t.spreadElement(replaceExpression(property.argument as t.Expression)); } - - if (t.isObjectProperty(property) && t.isExpression(property.value)) { - const nextValue = replaceExpression(property.value); + if (t.isObjectProperty(property)) { + const nextValue = replaceExpression(property.value as t.Expression); const next = { ...t.cloneNode(property, false), value: nextValue, } as t.ObjectProperty; return next; } - return t.cloneNode(property, true); }); return cloned; } - if (t.isMemberExpression(node)) { const cloned = t.cloneNode(node, false); - if (t.isExpression(cloned.object)) { - cloned.object = replaceExpression(cloned.object); - } + cloned.object = replaceExpression(cloned.object as t.Expression); if (cloned.computed && t.isExpression(cloned.property)) { cloned.property = replaceExpression(cloned.property); } @@ -1314,45 +1318,35 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor if (t.isCallExpression(node)) { const cloned = t.cloneNode(node, false); - if (t.isExpression(cloned.callee)) { - cloned.callee = replaceExpression(cloned.callee); - } + cloned.callee = replaceExpression(cloned.callee as t.Expression); cloned.arguments = cloned.arguments.map((arg) => { if (t.isSpreadElement(arg) && t.isExpression(arg.argument)) { return t.spreadElement(replaceExpression(arg.argument)); } - return t.isExpression(arg) ? replaceExpression(arg) : t.cloneNode(arg, true); + return replaceExpression(arg as t.Expression); }); return cloned; } - if (t.isTemplateLiteral(node)) { const cloned = t.cloneNode(node, false); - cloned.expressions = cloned.expressions.map((expr) => - t.isExpression(expr) ? replaceExpression(expr) : t.cloneNode(expr, true), - ); + const expressions: typeof cloned.expressions = []; + for (const expr of cloned.expressions) { + expressions.push(replaceExpression(expr as t.Expression)); + } + cloned.expressions = expressions; return cloned; } - if (t.isUnaryExpression(node) || t.isUpdateExpression(node)) { const cloned = t.cloneNode(node, false); - if (t.isExpression(cloned.argument)) { - cloned.argument = replaceExpression(cloned.argument); - } + cloned.argument = replaceExpression(cloned.argument as t.Expression); return cloned; } - if (t.isBinaryExpression(node) || t.isLogicalExpression(node) || t.isAssignmentExpression(node)) { const cloned = t.cloneNode(node, false); - if (t.isExpression(cloned.left)) { - cloned.left = replaceExpression(cloned.left); - } - if (t.isExpression(cloned.right)) { - cloned.right = replaceExpression(cloned.right); - } + cloned.left = replaceExpression(cloned.left as t.Expression); + cloned.right = replaceExpression(cloned.right as t.Expression); return cloned; } - if (t.isConditionalExpression(node)) { const cloned = t.cloneNode(node, false); cloned.test = replaceExpression(cloned.test); @@ -1360,13 +1354,15 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor cloned.alternate = replaceExpression(cloned.alternate); return cloned; } - if (t.isSequenceExpression(node)) { const cloned = t.cloneNode(node, false); - cloned.expressions = cloned.expressions.map((expr) => replaceExpression(expr)); + const expressions: typeof cloned.expressions = []; + for (const expr of cloned.expressions) { + expressions.push(replaceExpression(expr)); + } + cloned.expressions = expressions; return cloned; } - if (t.isParenthesizedExpression(node)) { const cloned = t.cloneNode(node, false); cloned.expression = replaceExpression(cloned.expression); @@ -1400,7 +1396,7 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor } const paramName = paramNames[index]; - const replacement = resolveReferenceInternal(fromFile, arg, depth + 1, seen) ?? unwrapExpression(arg); + const replacement = resolveReferenceOrFallback(fromFile, arg, unwrapExpression(arg), depth + 1, seen); if (!expressionContainsIdentifier(nextExpression, paramName)) { continue; } @@ -1430,36 +1426,33 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor } } } - - const resolvedReference = t.isExpression(callNode.callee) - ? resolveReferenceInternal(fromFile, callNode.callee, depth + 1, seen) - : undefined; - if ( - resolvedReference && - (t.isFunctionExpression(resolvedReference) || - t.isArrowFunctionExpression(resolvedReference) || - t.isFunctionDeclaration(resolvedReference)) - ) { - const returned = extractFunctionReturnExpression(resolvedReference); - if (returned) { - return applyFunctionArgumentHints(fromFile, callNode, resolvedReference, returned, depth + 1, seen); - } + const resolvedReference = resolveReferenceInternal(fromFile, callNode.callee, depth + 1, seen); + const returnedFromReference = extractReturnFromFunctionLike(resolvedReference); + if (resolvedReference && returnedFromReference) { + return applyFunctionArgumentHints( + fromFile, + callNode, + resolvedReference as t.FunctionDeclaration | t.FunctionExpression | t.ArrowFunctionExpression, + returnedFromReference, + depth + 1, + seen, + ); } const resolved = resolveCallResultInternal(fromFile, callNode.callee, depth + 1, seen); if (!resolved) { return undefined; } - - if ( - t.isFunctionExpression(resolved) || - t.isArrowFunctionExpression(resolved) || - t.isFunctionDeclaration(resolved) - ) { - const returned = extractFunctionReturnExpression(resolved); - if (returned) { - return applyFunctionArgumentHints(fromFile, callNode, resolved, returned, depth + 1, seen); - } + const returnedFromResolved = extractReturnFromFunctionLike(resolved); + if (returnedFromResolved) { + return applyFunctionArgumentHints( + fromFile, + callNode, + resolved as t.FunctionDeclaration | t.FunctionExpression | t.ArrowFunctionExpression, + returnedFromResolved, + depth + 1, + seen, + ); } return resolved; @@ -1487,32 +1480,25 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor if (!fromValues) { continue; } - - if (!t.isFunctionExpression(fromValues) && !t.isArrowFunctionExpression(fromValues)) { + if (!isFunctionExpressionLike(fromValues)) { continue; } - const returned = extractFunctionReturnExpression(fromValues); if (!returned) { continue; } - candidates.push({ filePath: candidateFile, expression: returned, score: commonPathPrefixLength(fromFile, candidateFile), }); } - if (candidates.length === 0) { return undefined; } candidates.sort((left, right) => right.score - left.score || left.filePath.localeCompare(right.filePath)); const [best, second] = candidates; - if (!best) { - return undefined; - } if (second && second.score === best.score && second.filePath !== best.filePath) { return undefined; @@ -1534,33 +1520,26 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor for (let index = objectNode.properties.length - 1; index >= 0; index -= 1) { const property = objectNode.properties[index]; - if (!property) { - continue; - } if (t.isObjectProperty(property)) { - const key = property.key; - let keyName: string | undefined; - if (t.isIdentifier(key)) { - keyName = key.name; - } else if (t.isStringLiteral(key)) { - keyName = key.value; - } else if (t.isNumericLiteral(key)) { - keyName = String(key.value); - } + const keyName = staticPropertyKeyName(property.key); - if (keyName === propertyName && t.isExpression(property.value)) { + if (keyName === propertyName) { return markExpressionOrigin(unwrapExpression(property.value), fromFile); } continue; } - - if (!t.isSpreadElement(property) || !t.isExpression(property.argument)) { + if (!t.isSpreadElement(property)) { continue; } - const spreadValue = - resolveReferenceInternal(fromFile, property.argument, depth + 1, seen) ?? unwrapExpression(property.argument); + const spreadValue = resolveReferenceOrFallback( + fromFile, + property.argument, + unwrapExpression(property.argument), + depth + 1, + seen, + ); if (!t.isObjectExpression(spreadValue)) { continue; } @@ -1605,13 +1584,13 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor } } } - - if (!t.isExpression(node.object)) { - return undefined; - } - - const resolvedObject = - resolveReferenceInternal(fromFile, node.object, depth + 1, seen) ?? unwrapExpression(node.object); + const resolvedObject = resolveReferenceOrFallback( + fromFile, + node.object, + unwrapExpression(node.object), + depth + 1, + seen, + ); const propertyName = propertyNameFromMemberExpression(node, (input) => expressionToLiteralString(fromFile, input, resolveReferenceInternal, depth, seen), ); @@ -1674,11 +1653,10 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor (resolvedObject.arguments.length === 1 && (t.isObjectExpression(wrappedArg) || t.isArrayExpression(wrappedArg)))) ) { - const resolvedWrappedArg = resolveReferenceInternal(fromFile, wrappedArg, depth + 1, seen) ?? wrappedArg; + const resolvedWrappedArg = resolveReferenceOrFallback(fromFile, wrappedArg, wrappedArg, depth + 1, seen); if (t.isObjectExpression(resolvedWrappedArg)) { return resolveObjectPropertyValue(fromFile, resolvedWrappedArg, propertyName, depth + 1, seen); } - if (t.isArrayExpression(resolvedWrappedArg)) { const indexValue = Number.parseInt(propertyName, 10); if (!Number.isFinite(indexValue) || indexValue < 0 || indexValue >= resolvedWrappedArg.elements.length) { @@ -1715,12 +1693,16 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor (nestedCallResult.arguments.length === 1 && (t.isObjectExpression(nestedWrappedArg) || t.isArrayExpression(nestedWrappedArg)))) ) { - const resolvedNestedWrappedArg = - resolveReferenceInternal(fromFile, nestedWrappedArg, depth + 1, seen) ?? nestedWrappedArg; + const resolvedNestedWrappedArg = resolveReferenceOrFallback( + fromFile, + nestedWrappedArg, + nestedWrappedArg, + depth + 1, + seen, + ); if (t.isObjectExpression(resolvedNestedWrappedArg)) { return resolveObjectPropertyValue(fromFile, resolvedNestedWrappedArg, propertyName, depth + 1, seen); } - if (t.isArrayExpression(resolvedNestedWrappedArg)) { const indexValue = Number.parseInt(propertyName, 10); if ( @@ -1730,12 +1712,10 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor ) { return undefined; } - const element = resolvedNestedWrappedArg.elements[indexValue]; if (!element || !t.isExpression(element)) { return undefined; } - return markExpressionOrigin(unwrapExpression(element), fromFile); } } @@ -1780,11 +1760,10 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor if (!localValue) { return resolveWorkspaceFunctionReturnByName(fromFile, callee.name); } - - if (t.isFunctionExpression(localValue) || t.isArrowFunctionExpression(localValue)) { - return extractFunctionReturnExpression(localValue); + const localValueReturn = extractReturnFromFunctionLike(localValue); + if (localValueReturn) { + return localValueReturn; } - return localValue; } @@ -1801,10 +1780,10 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor seen, ); if (namespaceFunction) { - if (t.isFunctionExpression(namespaceFunction) || t.isArrowFunctionExpression(namespaceFunction)) { - return extractFunctionReturnExpression(namespaceFunction); + const namespaceReturn = extractReturnFromFunctionLike(namespaceFunction); + if (namespaceReturn) { + return namespaceReturn; } - if (t.isIdentifier(namespaceFunction)) { return resolveLocalFunctionReturn(fromFile, namespaceFunction.name, depth + 1, seen); } @@ -1812,25 +1791,16 @@ export function createQueryKeyResolver(filePath: string, index: SymbolIndex, wor return namespaceFunction; } } - - const calleeExpression = t.isExpression(callee) ? callee : undefined; - if (!calleeExpression) { - return undefined; - } - - const resolvedReference = resolveReferenceInternal(fromFile, calleeExpression, depth + 1, seen); + const resolvedReference = resolveReferenceInternal(fromFile, callee as t.Expression, depth + 1, seen); if (!resolvedReference) { return undefined; } - - if (t.isFunctionExpression(resolvedReference) || t.isArrowFunctionExpression(resolvedReference)) { + if (isFunctionExpressionLike(resolvedReference)) { return extractFunctionReturnExpression(resolvedReference); } - if (t.isIdentifier(resolvedReference)) { return resolveLocalFunctionReturn(fromFile, resolvedReference.name, depth + 1, seen); } - return resolvedReference; }; diff --git a/src/core/analysis/symbols.ts b/src/core/analysis/symbols.ts index 65eb8d7..e8ac76d 100644 --- a/src/core/analysis/symbols.ts +++ b/src/core/analysis/symbols.ts @@ -229,10 +229,7 @@ function collectExportedDeclarationSymbols( if (!t.isIdentifier(declarator.id) || !declarator.init) { continue; } - - const value = t.isExpression(declarator.init) - ? unwrapExpression(declarator.init) - : (declarator.init as unknown as t.Expression); + const value = unwrapExpression(declarator.init); table.values.set(declarator.id.name, value); if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value)) { @@ -347,7 +344,6 @@ function collectPatternBindings(target: t.Node, init: t.Expression, table: FileS collectPatternBindings(property.argument, init, table); continue; } - if (!t.isObjectProperty(property)) { continue; } @@ -377,7 +373,6 @@ function collectPatternBindings(target: t.Node, init: t.Expression, table: FileS } return; } - if (t.isArrayPattern(target)) { for (let index = 0; index < target.elements.length; index += 1) { const element = target.elements[index]; @@ -390,7 +385,6 @@ function collectPatternBindings(target: t.Node, init: t.Expression, table: FileS collectPatternBindings(element.argument, nextInit as t.Expression, table); continue; } - if ( t.isIdentifier(element) || t.isAssignmentPattern(element) || @@ -409,13 +403,11 @@ function collectLocalVariableSymbol(pathNode: NodePath, ta if (!init || !t.isExpression(init)) { return; } - if (t.isObjectPattern(id) || t.isArrayPattern(id)) { collectPatternBindings(id, unwrapExpression(init), table); } return; } - const value = t.isExpression(init) ? unwrapExpression(init) : (init as unknown as t.Expression); table.values.set(id.name, value); @@ -444,7 +436,6 @@ function collectAssignedIdentifiers(target: t.Node, names: Set): void { collectAssignedIdentifiers(property.argument, names); continue; } - if (t.isObjectProperty(property)) { collectAssignedIdentifiers(property.value as t.LVal, names); } @@ -462,7 +453,6 @@ function collectAssignedIdentifiers(target: t.Node, names: Set): void { collectAssignedIdentifiers(element.argument, names); continue; } - if (t.isLVal(element)) { collectAssignedIdentifiers(element, names); } @@ -494,7 +484,6 @@ function isTopLevelVariableDeclarator(pathNode: NodePath): if (!statementPath) { return false; } - if (statementPath.isProgram()) { return true; } @@ -514,7 +503,6 @@ function isTopLevelFunctionDeclaration(pathNode: NodePath if (!parentPath) { return false; } - if (parentPath.isProgram()) { return true; } @@ -632,7 +620,6 @@ export function buildFileSymbols(filePath: string, ast: t.File): FileSymbols { }); continue; } - if (t.isImportNamespaceSpecifier(specifier)) { const namespaceSpecifier = specifier as t.ImportNamespaceSpecifier; table.imports.set(namespaceSpecifier.local.name, { diff --git a/src/core/graph/__tests__/buildGraph.test.ts b/src/core/graph/__tests__/buildGraph.test.ts new file mode 100644 index 0000000..b9a186a --- /dev/null +++ b/src/core/graph/__tests__/buildGraph.test.ts @@ -0,0 +1,600 @@ +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildGraph } from '../buildGraph'; +import { makeActionNodeId, makeQueryKeyNodeId } from '../nodeIds'; +import type { NormalizedQueryKey } from '../../../shared/contracts'; +import { createQueryRecord } from '../../../testing/fixtures'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const key = (overrides: Partial = {}): NormalizedQueryKey => ({ + id: 'todos', + display: '[todos]', + segments: ['todos'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + ...overrides, +}); + +describe('core/graph/buildGraph', () => { + it('links declared query keys through scoped actions and removes orphan inferred query nodes', () => { + const roots = [{ name: 'workspace', path: '/repo' }]; + const declaredKey = key(); + const orphanKey = key({ + id: 'orphan', + display: '[orphan]', + segments: ['orphan'], + resolution: 'dynamic', + source: 'expression', + }); + const wildcardKey = key({ + id: 'all-query-cache', + display: 'ALL_QUERY_CACHE', + segments: ['ALL_QUERY_CACHE'], + matchMode: 'all', + source: 'wildcard', + }); + + const graph = buildGraph(roots, { + scannedFiles: [], + filesScanned: 0, + parseErrors: [{ file: '/repo/app/src/broken.ts', message: 'parse failed' }], + records: [ + createQueryRecord({ + relation: 'declares', + operation: 'useQuery', + file: '/repo/app/src/hooks.ts', + loc: { line: 3, column: 1 }, + queryKey: declaredKey, + clientScopeId: 'client:one', + executionScopeId: 'test:one', + suiteScopeId: 'suite:one', + declaresDirectly: true, + }), + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/app/src/action.ts', + loc: { line: 10, column: 2 }, + queryKey: key({ matchMode: 'prefix' }), + clientScopeId: 'client:one', + executionScopeId: 'test:one', + }), + createQueryRecord({ + relation: 'clears', + operation: 'clear', + file: '/repo/app/src/other-test.ts', + loc: { line: 11, column: 2 }, + queryKey: wildcardKey, + executionScopeId: 'test:missing', + }), + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/app/src/other-execution.ts', + loc: { line: 11, column: 3 }, + queryKey: wildcardKey, + executionScopeId: 'test:missing', + }), + createQueryRecord({ + relation: 'clears', + operation: 'clear', + file: '/repo/app/src/suite.ts', + loc: { line: 12, column: 2 }, + queryKey: wildcardKey, + suiteScopeId: 'suite:one', + }), + createQueryRecord({ + relation: 'sets', + operation: 'setQueryData', + file: '/repo/app/src/orphan.ts', + loc: { line: 13, column: 2 }, + queryKey: orphanKey, + }), + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/app/src/placeholder.ts', + loc: { line: 14, column: 2 }, + queryKey: key({ + id: 'placeholder', + display: '[$id]', + segments: ['$id'], + resolution: 'dynamic', + source: 'expression', + }), + }), + createQueryRecord({ + relation: 'declares', + operation: 'useQuery', + file: '/repo/app/src/placeholder-declare.ts', + loc: { line: 15, column: 2 }, + queryKey: key({ + id: 'placeholder-declare', + display: '[$declareId]', + segments: ['$declareId'], + resolution: 'dynamic', + source: 'expression', + }), + }), + ], + }); + + const queryNode = graph.nodes.find((node) => node.kind === 'queryKey' && node.label === declaredKey.display); + const declaredNodeId = queryNode?.id ?? makeQueryKeyNodeId('workspace:.', declaredKey.display); + const orphanNodeId = makeQueryKeyNodeId('workspace:app', orphanKey.display); + + expect(queryNode?.metrics).toMatchObject({ + affectedFiles: 4, + declaredCallsites: 1, + declaredFiles: 1, + projectScope: 'workspace:app', + }); + expect(graph.nodes.some((node) => node.id === orphanNodeId)).toBe(true); + expect(graph.nodes.some((node) => node.kind === 'queryKey' && node.label === '[$id]')).toBe(false); + expect(graph.edges.filter((edge) => edge.target === declaredNodeId).map((edge) => edge.relation)).toEqual( + expect.arrayContaining(['declares', 'invalidates', 'clears']), + ); + expect( + graph.edges.some( + (edge) => edge.relation === 'clears' && edge.source.includes('other-test.ts') && edge.target === declaredNodeId, + ), + ).toBe(false); + expect(graph.summary).toMatchObject({ queryKeys: 2, parseErrors: 1 }); + expect(graph.parseErrors).toEqual([{ file: 'app/src/broken.ts', message: 'parse failed' }]); + }); + + it('falls back to the current working directory when no roots are provided', () => { + const graph = buildGraph([], { + scannedFiles: [], + filesScanned: 0, + parseErrors: [], + records: [ + createQueryRecord({ + relation: 'declares', + operation: 'useQuery', + file: '/repo/app/src/hooks.ts', + loc: { line: 3, column: 1 }, + queryKey: key(), + declaresDirectly: true, + }), + ], + }); + + const fileNode = graph.nodes.find((node) => node.kind === 'file'); + expect(fileNode?.label).toBe(path.resolve('/repo/app/src/hooks.ts').split(path.sep).join('/')); + expect(graph.summary).toMatchObject({ files: 1, actions: 1, queryKeys: 1, parseErrors: 0 }); + }); + + it('filters wildcard and matched actions by client and suite scope fallbacks', () => { + const roots = [{ name: 'workspace', path: '/repo' }]; + const emptySegmentKey = key({ + id: 'empty', + display: '[]', + segments: [], + }); + const detailKey = key({ + id: 'todos-detail', + display: '[todos, detail]', + segments: ['todos', 'detail'], + matchMode: 'exact', + }); + const wildcardKey = key({ + id: 'all-query-cache', + display: 'ALL_QUERY_CACHE', + segments: ['ALL_QUERY_CACHE'], + matchMode: 'all', + source: 'wildcard', + }); + + const graph = buildGraph(roots, { + scannedFiles: [], + filesScanned: 0, + parseErrors: [], + records: [ + createQueryRecord({ + relation: 'declares', + operation: 'useQuery', + file: '/repo/app/src/empty.ts', + loc: { line: 1, column: 1 }, + queryKey: emptySegmentKey, + clientScopeId: 'client:empty', + declaresDirectly: true, + }), + createQueryRecord({ + relation: 'declares', + operation: 'useQuery', + file: '/repo/app/src/detail.ts', + loc: { line: 2, column: 1 }, + queryKey: detailKey, + clientScopeId: 'client:detail', + suiteScopeId: 'suite:detail', + declaresDirectly: true, + }), + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/app/src/client-action.ts', + loc: { line: 3, column: 1 }, + queryKey: detailKey, + clientScopeId: 'client:detail', + }), + createQueryRecord({ + relation: 'clears', + operation: 'clear', + file: '/repo/app/src/missing-suite.ts', + loc: { line: 4, column: 1 }, + queryKey: wildcardKey, + suiteScopeId: 'suite:missing', + }), + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/app/src/missing-suite-fallback.ts', + loc: { line: 4, column: 2 }, + queryKey: wildcardKey, + suiteScopeId: 'suite:missing', + }), + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/other/src/action.ts', + loc: { line: 5, column: 1 }, + queryKey: detailKey, + }), + ], + }); + + const emptyNode = graph.nodes.find((node) => node.kind === 'queryKey' && node.label === '[]'); + const detailNode = graph.nodes.find((node) => node.kind === 'queryKey' && node.label === '[todos, detail]'); + expect(emptyNode?.metrics?.rootSegment).toBe('unknown'); + expect(detailNode?.metrics).toMatchObject({ + affectedFiles: 3, + declaredCallsites: 1, + projectScope: 'workspace:app', + }); + expect( + graph.edges.some( + (edge) => + edge.relation === 'clears' && edge.source.startsWith('action:') && edge.source.includes('missing-suite.ts'), + ), + ).toBe(false); + expect(graph.edges.some((edge) => edge.relation === 'invalidates' && edge.source.includes('/other/'))).toBe(true); + }); + + it('exercises duplicate declare, cross-project wildcard, project-mismatch non-wildcard, orphan queryKey node, and multi-file declare paths', () => { + const roots = [{ name: 'ws', path: '/repo' }]; + const sharedKey = key({ + id: 'shared', + display: '[shared]', + segments: ['shared'], + matchMode: 'exact', + }); + // A prefix key whose display differs from the declared key so it gets its own node + const prefixActionKey = key({ + id: 'prefix-action', + display: '[shared, detail]', + segments: ['shared', 'detail'], + matchMode: 'prefix', + }); + const otherProjectKey = key({ + id: 'other-proj', + display: '[other]', + segments: ['other'], + matchMode: 'prefix', + }); + const wildcardKey = key({ + id: 'all-query-cache', + display: 'ALL_QUERY_CACHE', + segments: ['ALL_QUERY_CACHE'], + matchMode: 'all', + source: 'wildcard', + }); + + const graph = buildGraph(roots, { + scannedFiles: [], + filesScanned: 0, + parseErrors: [], + records: [ + // B15: first declare — sets declaredQueryKeyByNodeId + createQueryRecord({ + relation: 'declares', + operation: 'useQuery', + file: '/repo/pkg-a/src/hooks1.ts', + loc: { line: 1, column: 1 }, + queryKey: sharedKey, + declaresDirectly: true, + clientScopeId: 'client:a', + executionScopeId: 'exec:a', + suiteScopeId: 'suite:a', + }), + // B15: second declare of same queryKey — exercises !has() false branch (line 103) + // B50: second declare from different file — exercises queryKeyToDeclareFiles.has() true branch (line 269) + createQueryRecord({ + relation: 'declares', + operation: 'useQuery', + file: '/repo/pkg-a/src/hooks2.ts', + loc: { line: 2, column: 1 }, + queryKey: sharedKey, + declaresDirectly: true, + clientScopeId: 'client:a', + }), + // B44: non-wildcard action in pkg-b referencing a key declared only in pkg-a → project mismatch + // matchedTargets = [] → falls back to own queryKeyNodeId + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/pkg-b/src/action.ts', + loc: { line: 3, column: 1 }, + queryKey: otherProjectKey, + }), + // B58 setup: prefix action key whose segments prefix-match sharedKey → matchedTargets=[sharedKeyNodeId] + // filterTargetsByScope returns [sharedKeyNodeId], so prefixActionKey's own node never targeted + // → queryKeyToFiles.get(prefixActionKeyNodeId) = undefined → branch B58 + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/pkg-a/src/prefix-action.ts', + loc: { line: 4, column: 1 }, + queryKey: prefixActionKey, + }), + // B33: wildcard with executionScopeId and operation != 'clear' → strictWhenScoped=false + // (existing test only covers operation='clear' with executionScopeId where strict=true) + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/pkg-a/src/wildcard-exec.ts', + loc: { line: 5, column: 1 }, + queryKey: wildcardKey, + executionScopeId: 'exec:a', + }), + // B38: wildcard action from pkg-b — declared keys are in pkg-a → projects.has() false + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/pkg-b/src/wildcard-cross.ts', + loc: { line: 6, column: 1 }, + queryKey: wildcardKey, + }), + ], + }); + + // The shared key should have two declared files (hooks1 and hooks2) + const sharedNode = graph.nodes.find((n) => n.kind === 'queryKey' && n.label === '[shared]'); + expect(sharedNode).toBeDefined(); + expect(sharedNode?.metrics?.declaredFiles).toBe(2); + expect(sharedNode?.metrics?.declaredCallsites).toBe(2); + + // pkg-b action with 'other' key — no match in any project, fallback to own key node + const otherNode = graph.nodes.find((n) => n.kind === 'queryKey' && n.label === '[other]'); + expect(otherNode).toBeDefined(); + expect(otherNode?.metrics?.affectedFiles).toBeGreaterThanOrEqual(1); + + // wildcard with exec scope (non-clear) should link to sharedKey in same project + const sharedEdges = graph.edges.filter((e) => e.target === sharedNode?.id && e.relation === 'invalidates'); + expect(sharedEdges.length).toBeGreaterThanOrEqual(1); + }); + + it('keeps fallback action keys and filters strict scoped wildcard links', () => { + const roots = [{ name: 'workspace', path: '/repo' }]; + const declared = key({ + id: 'declared', + display: '[declared]', + segments: ['declared'], + }); + const unmatched = key({ + id: 'unmatched', + display: '[unmatched]', + segments: ['unmatched'], + matchMode: 'exact', + }); + const wildcard = key({ + id: 'all-query-cache', + display: 'ALL_QUERY_CACHE', + segments: ['ALL_QUERY_CACHE'], + matchMode: 'all', + source: 'wildcard', + }); + + const graph = buildGraph(roots, { + scannedFiles: [], + filesScanned: 0, + parseErrors: [], + records: [ + createQueryRecord({ + relation: 'declares', + operation: 'useQuery', + file: '/repo/pkg-a/src/query.ts', + loc: { line: 1, column: 1 }, + queryKey: declared, + declaresDirectly: true, + clientScopeId: 'client:a', + }), + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/pkg-a/src/action.ts', + loc: { line: 2, column: 1 }, + queryKey: unmatched, + clientScopeId: 'client:missing', + }), + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/pkg-a/src/action.ts', + loc: { line: 2, column: 1 }, + queryKey: unmatched, + clientScopeId: 'client:missing', + }), + createQueryRecord({ + relation: 'clears', + operation: 'clear', + file: '/repo/pkg-a/src/strict.ts', + loc: { line: 3, column: 1 }, + queryKey: wildcard, + executionScopeId: 'test:missing', + }), + ], + }); + + const unmatchedNode = graph.nodes.find((node) => node.kind === 'queryKey' && node.label === '[unmatched]'); + expect(unmatchedNode?.metrics).toMatchObject({ + affectedFiles: 1, + declaredCallsites: 0, + projectScope: 'workspace:pkg-a', + }); + expect( + graph.edges.filter((edge) => edge.target === unmatchedNode?.id && edge.relation === 'invalidates'), + ).toHaveLength(2); + expect( + graph.edges.some( + (edge) => edge.relation === 'clears' && edge.source.includes('strict.ts') && edge.target.startsWith('query:'), + ), + ).toBe(false); + }); + + it('covers missing declaration cache and project-count fallback branches', () => { + const roots = [{ name: 'workspace', path: '/repo' }]; + const declared = key({ + id: 'declared', + display: '[declared]', + segments: ['declared'], + matchMode: 'exact', + }); + const action = createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/pkg-a/src/action.ts', + loc: { line: 10, column: 2 }, + queryKey: declared, + }); + const declaredRecord = createQueryRecord({ + relation: 'declares', + operation: 'useQuery', + file: '/repo/pkg-a/src/hooks.ts', + loc: { line: 3, column: 1 }, + queryKey: declared, + declaresDirectly: true, + }); + const queryNodeId = makeQueryKeyNodeId('workspace:pkg-a', declared.display); + const originalGet = Map.prototype.get; + vi.spyOn(Map.prototype, 'get').mockImplementation(function (this: Map, key: unknown) { + const value = originalGet.call(this, key); + if (key === queryNodeId) { + if (value instanceof Map) { + return undefined; + } + if (value && typeof value === 'object' && 'display' in value) { + return undefined; + } + } + return value; + }); + + const graph = buildGraph(roots, { + scannedFiles: [], + filesScanned: 0, + parseErrors: [], + records: [declaredRecord, action], + }); + + expect( + graph.nodes.find((node) => node.kind === 'queryKey' && node.label === declared.display)?.metrics, + ).toMatchObject({ + declaredFiles: 1, + projectScope: 'workspace:pkg-a', + }); + expect(graph.edges.some((edge) => edge.source === makeActionNodeId(action, 1))).toBe(true); + }); + + it('covers dangling-node edge skips', () => { + const roots = [{ name: 'workspace', path: '/repo' }]; + const declared = key({ + id: 'declared-dangling', + display: '[declared-dangling]', + segments: ['declared-dangling'], + matchMode: 'exact', + }); + const action = createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/pkg-a/src/action.ts', + loc: { line: 10, column: 2 }, + queryKey: declared, + }); + const actionNodeId = makeActionNodeId(action, 0); + const originalGet = Map.prototype.get; + vi.spyOn(Map.prototype, 'get').mockImplementation(function (this: Map, key: unknown) { + const value = originalGet.call(this, key); + if ( + value && + typeof value === 'object' && + 'kind' in value && + (value as { kind?: string; label?: string }).kind === 'queryKey' && + (value as { kind?: string; label?: string }).label === declared.display + ) { + return undefined; + } + return value; + }); + + const graph = buildGraph(roots, { + scannedFiles: [], + filesScanned: 0, + parseErrors: [], + records: [action], + }); + + expect(graph.nodes.some((node) => node.id === actionNodeId)).toBe(true); + }); + + it('covers missing action-to-file linkage fallback', () => { + const roots = [{ name: 'workspace', path: '/repo' }]; + const declared = key({ + id: 'declared-linked', + display: '[declared-linked]', + segments: ['declared-linked'], + matchMode: 'exact', + }); + const action = createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/pkg-a/src/action.ts', + loc: { line: 10, column: 2 }, + queryKey: declared, + }); + const actionNodeId = makeActionNodeId(action, 1); + const originalGet = Map.prototype.get; + vi.spyOn(Map.prototype, 'get').mockImplementation(function (this: Map, key: unknown) { + const value = originalGet.call(this, key); + if (key === actionNodeId && value instanceof Set) { + return undefined; + } + return value; + }); + + const graph = buildGraph(roots, { + scannedFiles: [], + filesScanned: 0, + parseErrors: [], + records: [ + createQueryRecord({ + relation: 'declares', + operation: 'useQuery', + file: '/repo/pkg-a/src/hooks.ts', + loc: { line: 3, column: 1 }, + queryKey: declared, + declaresDirectly: true, + }), + action, + ], + }); + + expect(graph.nodes.some((node) => node.id === actionNodeId)).toBe(true); + expect(graph.edges.some((edge) => edge.source === actionNodeId && edge.target.startsWith('qk:'))).toBe(true); + }); +}); diff --git a/src/core/graph/__tests__/graphBuilder.test.ts b/src/core/graph/__tests__/graphBuilder.test.ts new file mode 100644 index 0000000..a5ba5ae --- /dev/null +++ b/src/core/graph/__tests__/graphBuilder.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; + +const buildGraphInternal = vi.hoisted(() => vi.fn()); + +vi.mock('../buildGraph', () => ({ + buildGraph: buildGraphInternal, +})); + +describe('core/graph/graphBuilder', () => { + it('forwards to the graph builder implementation', async () => { + const { buildGraph } = await import('../graphBuilder'); + + const roots = [{ name: 'workspace', path: '/repo' }]; + const analysis = { + records: [], + scannedFiles: [], + filesScanned: 0, + parseErrors: [], + }; + + buildGraph(roots, analysis); + + expect(buildGraphInternal).toHaveBeenCalledWith(roots, analysis); + }); +}); diff --git a/src/core/graph/__tests__/nodeIds.test.ts b/src/core/graph/__tests__/nodeIds.test.ts new file mode 100644 index 0000000..9c68313 --- /dev/null +++ b/src/core/graph/__tests__/nodeIds.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { makeActionNodeId, makeFileNodeId, makeQueryKeyNodeId } from '../nodeIds'; +import { createQueryRecord } from '../../../testing/fixtures'; + +describe('core/graph/nodeIds', () => { + it('builds stable ids', () => { + const record = createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: '/repo/src/file.ts', + loc: { line: 4, column: 2 }, + queryKey: { + id: 'todo', + display: 'todo', + segments: ['todo'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }, + }); + + expect(makeFileNodeId('/repo/src/file.ts')).toBe('file:/repo/src/file.ts'); + expect(makeActionNodeId(record, 3)).toBe('action:/repo/src/file.ts:4:2:invalidateQueries:3'); + expect(makeQueryKeyNodeId('workspace:app', 'todo')).toBe('qk:workspace:app:todo'); + }); +}); diff --git a/src/core/graph/__tests__/paths.test.ts b/src/core/graph/__tests__/paths.test.ts new file mode 100644 index 0000000..f74e028 --- /dev/null +++ b/src/core/graph/__tests__/paths.test.ts @@ -0,0 +1,54 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { mapParseErrors, normalizeFilePath, projectScopeForFile, toDisplayPath, toPosix } from '../paths'; + +describe('core/graph/paths', () => { + it('normalizes display paths and scopes files by package boundaries', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-paths-')); + await writeFile(path.join(root, 'package.json'), '{}'); + await mkdir(path.join(root, 'src', 'nested'), { recursive: true }); + await writeFile(path.join(root, 'src', 'nested', 'package.json'), '{}'); + + const roots = [ + { name: 'workspace', path: root }, + { name: 'pkg', path: path.join(root, 'src') }, + ]; + const scopeCache = new Map(); + const packageJsonCache = new Map(); + const filePath = path.join(root, 'src', 'nested', 'file.ts'); + + expect(toPosix(path.join('a', 'b'))).toBe('a/b'); + expect(normalizeFilePath(filePath)).toBe(path.resolve(filePath).split(path.sep).join('/')); + expect(toDisplayPath(roots, filePath)).toBe('pkg/nested/file.ts'); + expect( + toDisplayPath( + [ + { name: 'pkg', path: path.join(root, 'src') }, + { name: 'workspace', path: root }, + ], + filePath, + ), + ).toBe('pkg/nested/file.ts'); + expect(projectScopeForFile(roots, filePath, scopeCache, packageJsonCache)).toBe('pkg:nested'); + + const fallbackScopeCache = new Map(); + const fallbackPackageJsonCache = new Map(); + expect(projectScopeForFile([], filePath, fallbackScopeCache, fallbackPackageJsonCache)).toMatch(/^workspace:/); + expect(projectScopeForFile([], path.parse(root).root, new Map(), new Map())).toBe('workspace:*'); + + const mapped = mapParseErrors(roots, [{ file: filePath, message: 'bad' }]); + expect(mapped[0]).toEqual({ file: 'pkg/nested/file.ts', message: 'bad' }); + + expect(toDisplayPath([{ name: 'workspace', path: root }], root)).toBe('.'); + expect(toDisplayPath(roots, path.join(root, 'src'))).toBe('pkg/.'); + expect(toDisplayPath(roots, path.join(root, '..', 'outside.ts'))).toBe( + path.resolve(root, '..', 'outside.ts').split(path.sep).join('/'), + ); + expect(projectScopeForFile(roots, root, scopeCache, packageJsonCache)).toBe('workspace:*'); + expect(projectScopeForFile(roots, path.join(root, 'loose.ts'), scopeCache, new Map())).toBe('workspace:.'); + expect(projectScopeForFile(roots, filePath, scopeCache, packageJsonCache)).toBe('pkg:nested'); + }); +}); diff --git a/src/core/graph/__tests__/queryKeyMatching.test.ts b/src/core/graph/__tests__/queryKeyMatching.test.ts new file mode 100644 index 0000000..f0d1815 --- /dev/null +++ b/src/core/graph/__tests__/queryKeyMatching.test.ts @@ -0,0 +1,202 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + actionAffectsDeclaredQueryKey, + isDeclarationAnchorRecord, + isOpaqueDynamicQueryKey, + isPlaceholderOnlyQueryKey, + isSetAnchoredConcreteKey, + isWildcardQueryKey, +} from '../queryKeyMatching'; +import { createQueryRecord } from '../../../testing/fixtures'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const queryKey = (overrides: Partial['queryKey']>) => ({ + id: 'id', + display: 'display', + segments: ['a', 'b'], + matchMode: 'prefix' as const, + resolution: 'static' as const, + source: 'literal' as const, + ...overrides, +}); + +describe('core/graph/queryKeyMatching', () => { + it('recognizes wildcard and declaration keys', () => { + expect( + isWildcardQueryKey( + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: 'src/file.ts', + loc: { line: 1, column: 1 }, + queryKey: queryKey({ source: 'wildcard' }), + }), + ), + ).toBe(true); + expect( + isWildcardQueryKey( + createQueryRecord({ + relation: 'invalidates', + operation: 'invalidateQueries', + file: 'src/file.ts', + loc: { line: 1, column: 1 }, + queryKey: queryKey({ id: 'all-query-cache' }), + }), + ), + ).toBe(true); + expect( + isDeclarationAnchorRecord( + createQueryRecord({ + relation: 'declares', + operation: 'x', + file: 'src/file.ts', + loc: { line: 1, column: 1 }, + queryKey: queryKey({}), + }), + ), + ).toBe(true); + }); + + it('detects placeholder-only and opaque dynamic query keys', () => { + expect(isPlaceholderOnlyQueryKey(queryKey({ segments: ['$id', 'UNRESOLVED'] }))).toBe(true); + expect(isPlaceholderOnlyQueryKey(queryKey({ segments: ['todo', '$id'] }))).toBe(false); + expect(isOpaqueDynamicQueryKey(queryKey({ segments: ['$', 'UNRESOLVED'] }))).toBe(true); + expect(isOpaqueDynamicQueryKey(queryKey({ source: 'wildcard' }))).toBe(false); + expect(isOpaqueDynamicQueryKey(queryKey({ segments: ['todo', 'UNRESOLVED'] }))).toBe(false); + }); + + it('matches declared query keys based on relation and segments', () => { + const declared = queryKey({ id: 'todo', display: 'todo', segments: ['todo'] }); + + expect(actionAffectsDeclaredQueryKey(queryKey({ id: 'pass-through-query-key' }), declared)).toBe(false); + expect(actionAffectsDeclaredQueryKey(queryKey({ source: 'wildcard' }), declared)).toBe(true); + expect(actionAffectsDeclaredQueryKey(queryKey({ matchMode: 'all' }), declared)).toBe(true); + expect(actionAffectsDeclaredQueryKey(queryKey({ matchMode: 'predicate' }), declared)).toBe(true); + expect(actionAffectsDeclaredQueryKey(queryKey({ id: 'todo' }), declared)).toBe(true); + expect(actionAffectsDeclaredQueryKey(queryKey({ display: 'todo', segments: ['todo'] }), declared)).toBe(true); + expect( + actionAffectsDeclaredQueryKey( + queryKey({ display: 'todo', segments: ['todo', 'list'], matchMode: 'exact' }), + declared, + ), + ).toBe(false); + expect(actionAffectsDeclaredQueryKey(queryKey({ display: 'todo', segments: ['todo', 'list'] }), declared)).toBe( + false, + ); + expect( + actionAffectsDeclaredQueryKey( + queryKey({ display: 'dynamic', segments: ['todo', '$id'] }), + queryKey({ display: 'dynamic', segments: ['todo', '1'] }), + ), + ).toBe(true); + expect( + actionAffectsDeclaredQueryKey( + queryKey({ display: 'different', segments: ['todo', '$id'] }), + queryKey({ display: 'dynamic', segments: ['todo', '1'] }), + ), + ).toBe(false); + expect( + actionAffectsDeclaredQueryKey( + queryKey({ id: 'action-placeholder', segments: ['todo', '$id'] }), + queryKey({ id: 'declared-placeholder', segments: ['todo', '1'] }), + ), + ).toBe(true); + expect( + actionAffectsDeclaredQueryKey( + queryKey({ id: 'action-call', segments: ['todo', 'call(factory)'] }), + queryKey({ id: 'declared-call', segments: ['todo', '1'] }), + ), + ).toBe(true); + expect( + actionAffectsDeclaredQueryKey( + queryKey({ id: 'action-template', segments: ['todo', 'todo-' + '$' + '{id}'] }), + queryKey({ id: 'declared-template', segments: ['todo', '1'] }), + ), + ).toBe(false); + expect( + actionAffectsDeclaredQueryKey( + queryKey({ id: 'action-static-placeholder-declared', segments: ['todo', '1'] }), + queryKey({ id: 'declared-placeholder', segments: ['todo', '$id'] }), + ), + ).toBe(false); + expect( + actionAffectsDeclaredQueryKey( + queryKey({ id: 'action-static-dynamic-declared', segments: ['todo', '1'] }), + queryKey({ id: 'declared-call', segments: ['todo', 'call(factory)'] }), + ), + ).toBe(true); + expect( + actionAffectsDeclaredQueryKey( + queryKey({ id: 'action-unresolved-value', segments: ['todo', 'UNRESOLVED_VALUE'] }), + queryKey({ id: 'declared-unresolved-value', segments: ['todo', '1'] }), + ), + ).toBe(true); + expect( + actionAffectsDeclaredQueryKey( + queryKey({ id: 'action-empty', display: 'empty', segments: [''] }), + queryKey({ id: 'declared-empty', display: 'todo', segments: ['todo'] }), + ), + ).toBe(false); + }); + + it('identifies set-anchored concrete keys', () => { + expect(isSetAnchoredConcreteKey(queryKey({ source: 'wildcard' }))).toBe(false); + expect(isSetAnchoredConcreteKey(queryKey({ id: 'pass-through-query-key' }))).toBe(false); + expect(isSetAnchoredConcreteKey(queryKey({ id: 'all-query-cache' }))).toBe(false); + expect(isSetAnchoredConcreteKey(queryKey({ id: 'unresolved_query_key' }))).toBe(false); + expect(isSetAnchoredConcreteKey(queryKey({ id: 'todo' }))).toBe(true); + }); + + it('covers placeholder action segment matching', () => { + const declared = queryKey({ id: 'declared-placeholder', display: '[todo, 1]', segments: ['todo', '1'] }); + const action = queryKey({ id: 'action-placeholder', display: '[todo, $id]', segments: ['todo', '$id'] }); + const originalSome = Array.prototype.some; + vi.spyOn(Array.prototype, 'some').mockImplementation(function ( + this: unknown[], + callback: Parameters[0], + thisArg?: Parameters[1], + ) { + if (Array.isArray(this) && this.length === 2 && this[0] === 'todo' && this[1] === '$id') { + return false; + } + return originalSome.call(this, callback, thisArg); + }); + + expect(actionAffectsDeclaredQueryKey(action, declared)).toBe(true); + }); + + it('treats exact matches as declarations and rejects non-placeholder arrays', () => { + expect( + isDeclarationAnchorRecord( + createQueryRecord({ + relation: 'declares', + operation: 'x', + file: 'src/file.ts', + loc: { line: 1, column: 1 }, + queryKey: queryKey({ segments: ['todo'] }), + }), + ), + ).toBe(true); + expect(isPlaceholderOnlyQueryKey(queryKey({ segments: ['todo', 'items'] }))).toBe(false); + expect(isPlaceholderOnlyQueryKey(queryKey({ segments: [' ', 'null', 'undefined'] }))).toBe(false); + expect(isPlaceholderOnlyQueryKey(queryKey({ segments: ['null', 'undefined'] }))).toBe(true); + expect(isOpaqueDynamicQueryKey(queryKey({ segments: ['todo', 'items', 'more'] }))).toBe(false); + expect(isOpaqueDynamicQueryKey(queryKey({ segments: [' ', '$id'] }))).toBe(true); + expect(isOpaqueDynamicQueryKey(queryKey({ segments: ['call(factory)'] }))).toBe(true); + expect( + isWildcardQueryKey( + createQueryRecord({ + relation: 'declares', + operation: 'x', + file: 'src/file.ts', + loc: { line: 1, column: 1 }, + queryKey: queryKey({}), + }), + ), + ).toBe(false); + }); +}); diff --git a/src/core/graph/buildGraph.ts b/src/core/graph/buildGraph.ts index 2986bbb..f58f6cb 100644 --- a/src/core/graph/buildGraph.ts +++ b/src/core/graph/buildGraph.ts @@ -325,7 +325,6 @@ export function buildGraph(roots: GraphRoot[], analysis: AnalysisResult): GraphD const affectedFiles = Number(node.metrics?.affectedFiles ?? 0); const declaredCallsites = Number(node.metrics?.declaredCallsites ?? 0); - // Drop orphan query keys that were only inferred from unresolved expressions. return affectedFiles > 0 || declaredCallsites > 0; }); const allowedNodeIds = new Set(nodes.map((node) => node.id)); diff --git a/src/core/graph/queryKeyMatching.ts b/src/core/graph/queryKeyMatching.ts index 44c0437..6761ea4 100644 --- a/src/core/graph/queryKeyMatching.ts +++ b/src/core/graph/queryKeyMatching.ts @@ -137,8 +137,6 @@ export function actionAffectsDeclaredQueryKey( actionQueryKey: QueryRecord['queryKey'], declaredQueryKey: QueryRecord['queryKey'], ): boolean { - // `invalidateQueries({ queryKey })` pass-through cannot be safely expanded. - // Keep it as its own dynamic key node instead of matching every declared key. if (actionQueryKey.id === 'pass-through-query-key') { return false; } diff --git a/src/core/workspace/__tests__/config.test.ts b/src/core/workspace/__tests__/config.test.ts new file mode 100644 index 0000000..9e9de5e --- /dev/null +++ b/src/core/workspace/__tests__/config.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; + +import * as vscode from '../../../testing/vscode'; + +vi.mock('vscode', async () => await import('../../../testing/vscode')); + +describe('core/workspace/config', () => { + it('reads and persists workspace settings', async () => { + const settings = new Map([ + ['scope.folders', ['src']], + ['scope.include', '**/*.ts'], + ['scope.exclude', '**/dist/**'], + ['scan.useGitIgnore', false], + ['scan.maxFileSizeKB', 42], + ['graph.verticalSpacing', 64], + ['graph.horizontalSpacing', 900], + ['graph.nodeSpacing', 12], + ]); + const updates: Array<[string, unknown, unknown]> = []; + vscode.workspace.getConfiguration = () => ({ + get: (key: string, fallback: T): T => (settings.has(key) ? (settings.get(key) as T) : fallback), + update: async (key: string, value: unknown, target: unknown) => { + updates.push([key, value, target]); + }, + }); + + const { getLayoutConfig, getScanScopeConfig, persistScanScope } = await import('../config'); + + expect(getScanScopeConfig()).toEqual({ + folders: ['src'], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: false, + maxFileSizeKB: 42, + }); + + expect(getLayoutConfig()).toEqual({ + direction: 'LR', + engine: 'dagre', + verticalSpacing: 64, + horizontalSpacing: 900, + }); + + await persistScanScope({ + folders: ['app'], + includeGlob: '**/*.tsx', + excludeGlob: '**/build/**', + useGitIgnore: true, + maxFileSizeKB: 64, + }); + + expect(updates).toEqual([ + ['scope.folders', ['app'], 'workspace'], + ['scope.include', '**/*.tsx', 'workspace'], + ['scope.exclude', '**/build/**', 'workspace'], + ['scan.useGitIgnore', true, 'workspace'], + ['scan.maxFileSizeKB', 64, 'workspace'], + ]); + }); +}); diff --git a/src/core/workspace/__tests__/fileCollection.test.ts b/src/core/workspace/__tests__/fileCollection.test.ts new file mode 100644 index 0000000..2bc2898 --- /dev/null +++ b/src/core/workspace/__tests__/fileCollection.test.ts @@ -0,0 +1,131 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { collectFiles } from '../fileCollection'; + +afterEach(() => { + vi.doUnmock('fast-glob'); + vi.resetModules(); +}); + +describe('core/workspace/fileCollection', () => { + it('collects files with gitignore, excludes, and size limits', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-files-')); + await mkdir(path.join(root, 'src'), { recursive: true }); + await writeFile(path.join(root, 'src', 'a.ts'), 'export const a = 1;'); + await writeFile(path.join(root, 'src', 'types.d.ts'), 'export declare const types: string;'); + await writeFile(path.join(root, 'src', 'ignored.ts'), 'export const ignored = 1;'); + await writeFile(path.join(root, 'src', 'big.ts'), 'x'.repeat(2048)); + await writeFile(path.join(root, '.gitignore'), '**/ignored.ts\n'); + + const files = await collectFiles(root, { + folders: ['.'], + includeGlob: '**/*.ts', + excludeGlob: '**/node_modules/**', + useGitIgnore: true, + maxFileSizeKB: 1, + }); + + expect(files).toEqual([path.join(root, 'src', 'a.ts')]); + }); + + it('handles nested gitignore rules, external folders, and disabled gitignore', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-files-')); + const external = await mkdtemp(path.join(os.tmpdir(), 'rqv-external-')); + await mkdir(path.join(root, 'src', 'generated'), { recursive: true }); + await mkdir(path.join(external, 'shared'), { recursive: true }); + await writeFile(path.join(root, 'src', 'keep.ts'), 'export const keep = 1;'); + await writeFile(path.join(root, 'src', 'ignored-by-nested.ts'), 'export const ignored = 1;'); + await writeFile(path.join(root, 'src', 'generated', 'skip.ts'), 'export const skip = 1;'); + await writeFile( + path.join(root, 'src', '.gitignore'), + '/ignored-by-nested.ts\ngenerated/\n/\n!keep.ts\n# comment\n', + ); + await writeFile(path.join(external, 'shared', 'outside.ts'), 'export const outside = 1;'); + + const withGitIgnore = await collectFiles(root, { + folders: ['src', path.join(external, 'shared')], + includeGlob: '**/*.ts', + excludeGlob: '', + useGitIgnore: true, + maxFileSizeKB: 8, + }); + expect(withGitIgnore.sort()).toEqual([ + path.join(external, 'shared', 'outside.ts'), + path.join(root, 'src', 'generated', 'skip.ts'), + path.join(root, 'src', 'ignored-by-nested.ts'), + path.join(root, 'src', 'keep.ts'), + ]); + + const withoutGitIgnore = await collectFiles(root, { + folders: ['src'], + includeGlob: '**/*.ts', + excludeGlob: '**/generated/**', + useGitIgnore: false, + maxFileSizeKB: 8, + }); + expect(withoutGitIgnore.sort()).toEqual([ + path.join(root, 'src', 'ignored-by-nested.ts'), + path.join(root, 'src', 'keep.ts'), + ]); + + const defaultFolder = await collectFiles(root, { + folders: [], + includeGlob: 'src/keep.ts', + excludeGlob: '', + useGitIgnore: false, + maxFileSizeKB: 8, + }); + expect(defaultFolder).toEqual([path.join(root, 'src', 'keep.ts')]); + + const emptyFolder = await collectFiles(root, { + folders: [''], + includeGlob: 'src/keep.ts', + excludeGlob: '', + useGitIgnore: false, + maxFileSizeKB: 8, + }); + expect(emptyFolder).toEqual([path.join(root, 'src', 'keep.ts')]); + }); + + it('skips files that disappear before stat', async () => { + vi.doMock('fast-glob', () => ({ + default: vi.fn(async () => ['/tmp/rqv-missing-file.ts']), + })); + vi.resetModules(); + const { collectFiles } = await import('../fileCollection'); + + await expect( + collectFiles('/tmp', { + folders: ['.'], + includeGlob: '**/*.ts', + excludeGlob: '', + useGitIgnore: false, + maxFileSizeKB: 8, + }), + ).resolves.toEqual([]); + }); + + it('ignores gitignore files that disappear before reading', async () => { + const glob = vi.fn(async (patterns: string[]) => + patterns[0] === '**/.gitignore' ? ['/tmp/rqv-missing/.gitignore'] : [], + ); + vi.doMock('fast-glob', () => ({ + default: glob, + })); + vi.resetModules(); + const { collectFiles } = await import('../fileCollection'); + + await expect( + collectFiles('/tmp', { + folders: ['.'], + includeGlob: '**/*.ts', + excludeGlob: '', + useGitIgnore: true, + maxFileSizeKB: 8, + }), + ).resolves.toEqual([]); + }); +}); diff --git a/src/core/workspace/__tests__/glob.test.ts b/src/core/workspace/__tests__/glob.test.ts new file mode 100644 index 0000000..1e5fd18 --- /dev/null +++ b/src/core/workspace/__tests__/glob.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { parseGlobPatterns } from '../glob'; + +describe('core/workspace/glob', () => { + it('splits comma separated patterns while preserving brace groups', () => { + expect(parseGlobPatterns('**/*.ts, **/*.tsx, **/*.{js,jsx}')).toEqual(['**/*.ts', '**/*.tsx', '**/*.{js,jsx}']); + }); + + it('drops empty patterns', () => { + expect(parseGlobPatterns(' , ,foo,, bar ')).toEqual(['foo', 'bar']); + }); +}); diff --git a/src/core/workspace/__tests__/scope.test.ts b/src/core/workspace/__tests__/scope.test.ts new file mode 100644 index 0000000..3b9b6ee --- /dev/null +++ b/src/core/workspace/__tests__/scope.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest'; + +import * as vscode from '../../../testing/vscode'; + +vi.mock('vscode', async () => await import('../../../testing/vscode')); + +describe('core/workspace/scope', () => { + it('formats scopes and prompts for folder selection', async () => { + const showOpenDialog = async () => [{ fsPath: '/repo/src' }, { fsPath: '/repo/packages/core' }]; + let inputCallCount = 0; + const showInputBox = async () => { + inputCallCount += 1; + return inputCallCount === 1 + ? '**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}' + : '**/{.git,.idea,.vscode,.cache,.next,.nuxt,.svelte-kit,.turbo,.expo,.expo-shared,.omc,.omx,.pnpm-store,.yarn,.parcel-cache,node_modules,dist,build,coverage,out,release,android,ios,Pods,bin,obj,temp,tmp,storybook-static}/**'; + }; + vscode.window.showOpenDialog = showOpenDialog; + vscode.window.showInputBox = showInputBox; + vscode.workspace.getConfiguration = () => ({ + get: (_key: string, fallback: T): T => fallback, + update: async () => undefined, + }); + + const { promptScope, scopeToLabel } = await import('../scope'); + + expect( + scopeToLabel({ + folders: ['src', 'packages/core'], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: true, + maxFileSizeKB: 128, + }), + ).toBe('Folders: src, packages/core | Include: **/*.ts | Exclude: **/dist/**'); + + const scope = await promptScope({ + uri: { fsPath: '/repo' }, + } as never); + + expect(scope).toEqual({ + folders: ['src', 'packages/core'], + includeGlob: '**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}', + excludeGlob: + '**/{.git,.idea,.vscode,.cache,.next,.nuxt,.svelte-kit,.turbo,.expo,.expo-shared,.omc,.omx,.pnpm-store,.yarn,.parcel-cache,node_modules,dist,build,coverage,out,release,android,ios,Pods,bin,obj,temp,tmp,storybook-static}/**', + useGitIgnore: true, + maxFileSizeKB: 512, + }); + }); + + it('returns undefined when the folder or glob prompts are cancelled', async () => { + const { promptScope } = await import('../scope'); + const workspaceFolder = { uri: { fsPath: '/repo' } } as never; + + vscode.window.showOpenDialog = async () => undefined; + expect(await promptScope(workspaceFolder)).toBeUndefined(); + + vscode.window.showOpenDialog = async () => [{ fsPath: '/repo' }]; + vscode.window.showInputBox = async () => undefined; + expect(await promptScope(workspaceFolder)).toBeUndefined(); + + let inputCallCount = 0; + vscode.window.showInputBox = async () => { + inputCallCount += 1; + return inputCallCount === 1 ? '**/*.ts' : undefined; + }; + expect(await promptScope(workspaceFolder)).toBeUndefined(); + }); + + it('keeps root and external folder labels stable', async () => { + const { promptScope, scopeToLabel } = await import('../scope'); + const workspaceFolder = { uri: { fsPath: '/repo' } } as never; + let inputCallCount = 0; + + vscode.window.showOpenDialog = async () => [{ fsPath: '/repo' }, { fsPath: '/outside/shared' }]; + vscode.window.showInputBox = async () => { + inputCallCount += 1; + return inputCallCount === 1 ? '**/*.tsx' : '**/dist/**'; + }; + + expect(await promptScope(workspaceFolder)).toMatchObject({ + folders: ['.', '/outside/shared'], + includeGlob: '**/*.tsx', + excludeGlob: '**/dist/**', + }); + expect( + scopeToLabel({ + folders: [], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: true, + maxFileSizeKB: 128, + }), + ).toBe('Folders: . | Include: **/*.ts | Exclude: **/dist/**'); + }); + + it('uses configured glob values as prompt defaults', async () => { + const { promptScope } = await import('../scope'); + const workspaceFolder = { uri: { fsPath: '/repo' } } as never; + const seenValues: unknown[] = []; + let inputCallCount = 0; + + vscode.workspace.getConfiguration = () => ({ + get: (key: string, fallback: T): T => { + if (key === 'scope.include') { + return 'src/**/*.ts' as T; + } + if (key === 'scope.exclude') { + return 'vendor/**' as T; + } + return fallback; + }, + update: async () => undefined, + }); + vscode.window.showOpenDialog = async () => [{ fsPath: '/repo/src' }]; + vscode.window.showInputBox = async (options) => { + const value = (options as { value?: string } | undefined)?.value; + seenValues.push(value); + inputCallCount += 1; + return inputCallCount === 1 ? 'src/**/*.ts' : 'vendor/**'; + }; + + await promptScope(workspaceFolder); + + expect(seenValues).toEqual(['src/**/*.ts', 'vendor/**']); + }); + + it('falls back to default glob values when configured values are empty', async () => { + const { promptScope } = await import('../scope'); + const workspaceFolder = { uri: { fsPath: '/repo' } } as never; + const seenValues: unknown[] = []; + let inputCallCount = 0; + + vscode.workspace.getConfiguration = () => ({ + get: (key: string, fallback: T): T => { + if (key === 'scope.include' || key === 'scope.exclude') { + return '' as T; + } + return fallback; + }, + update: async () => undefined, + }); + vscode.window.showOpenDialog = async () => [{ fsPath: '/repo/src' }]; + vscode.window.showInputBox = async (options) => { + const value = (options as { value?: string } | undefined)?.value; + seenValues.push(value); + inputCallCount += 1; + return inputCallCount === 1 ? String(value) : String(value); + }; + + await promptScope(workspaceFolder); + + expect(seenValues[0]).toContain('*.{ts,tsx'); + expect(seenValues[1]).toContain('node_modules'); + }); +}); diff --git a/src/core/workspace/fileCollection.ts b/src/core/workspace/fileCollection.ts index 7f95142..8cf38ed 100644 --- a/src/core/workspace/fileCollection.ts +++ b/src/core/workspace/fileCollection.ts @@ -106,6 +106,9 @@ export async function collectFiles(rootPath: string, scope: ScanScope): Promise< }); for (const file of matches) { + if (file.endsWith('.d.ts')) { + continue; + } fileSet.add(path.resolve(file)); } }), diff --git a/src/extension/__tests__/index.test.ts b/src/extension/__tests__/index.test.ts new file mode 100644 index 0000000..8f08dee --- /dev/null +++ b/src/extension/__tests__/index.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createGraph, createScannedFile } from '../../testing/fixtures'; +import * as vscode from '../../testing/vscode'; + +vi.mock('vscode', async () => await import('../../testing/vscode')); + +describe('extension/index', () => { + it('registers commands and exercises activation branches', async () => { + const registered = new Map unknown>(); + vi.spyOn(vscode.commands, 'registerCommand').mockImplementation( + (name: string, callback: (...args: unknown[]) => unknown) => { + registered.set(name, callback); + return { dispose: () => undefined }; + }, + ); + + let collapseHandler: ((event: { element: { children: unknown[] } }) => void) | undefined; + const treeViewReveal = vi.fn().mockResolvedValue(undefined); + vi.spyOn(vscode.window, 'createTreeView').mockReturnValue({ + dispose: () => undefined, + reveal: treeViewReveal, + onDidCollapseElement: (handler: typeof collapseHandler) => { + collapseHandler = handler; + return { dispose: () => undefined }; + }, + } as never); + + const warningMessages: string[] = []; + vi.spyOn(vscode.window, 'showWarningMessage').mockImplementation(async (...messageArgs: unknown[]) => { + const [message] = messageArgs; + if (typeof message === 'string') { + warningMessages.push(message); + } + return undefined; + }); + vi.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + + const updateFromPayloadSpy = vi.fn(); + vi.doMock('../views/activityView', () => ({ + RqvActivityViewProvider: class { + updateFromPayload = updateFromPayloadSpy; + }, + })); + + const panelUpdate = vi.fn(); + const createOrShow = vi.fn(() => ({ update: panelUpdate })); + vi.doMock('../views/graphPanel', () => ({ + GraphPanel: { + createOrShow, + }, + })); + + const revealInCode = vi.fn(); + vi.doMock('../commands/reveal', () => ({ + revealInCode, + })); + + const runStaticAnalysis = vi.fn().mockResolvedValue({ + records: [], + scannedFiles: [], + parseErrors: [], + filesScanned: 0, + }); + vi.doMock('../../core/analysis/analyzer', () => ({ + runStaticAnalysis, + })); + + const payload = { + graph: createGraph({ + nodes: [], + edges: [], + }), + scannedFiles: [createScannedFile({ path: 'web/src/query.ts', workspace: 'web', depth: 1 })], + scopeLabel: 'workspace', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 24, + horizontalSpacing: 480, + }, + }; + const scanAndPublish = vi.fn( + async ({ + onPayloadUpdated, + persistScopeFlag, + }: { + onPayloadUpdated: (p: unknown) => void; + persistScopeFlag: boolean; + }) => { + if (!persistScopeFlag) { + await runStaticAnalysis('/workspace', { + folders: ['.'], + includeGlob: '**/*.ts', + excludeGlob: '', + useGitIgnore: true, + maxFileSizeKB: 512, + }); + } + onPayloadUpdated(persistScopeFlag ? { ...payload, scopeLabel: 'scoped' } : payload); + }, + ); + vi.doMock('../commands/scan', () => ({ + scanAndPublish, + })); + + const workspaceFolders = [{ uri: vscode.Uri.file('/repo') }]; + const getWorkspaceFolders = vi.fn(() => workspaceFolders); + vi.doMock('../workspace/folders', () => ({ + getDefaultScopeWorkspace: () => workspaceFolders[0], + getWorkspaceFolders, + })); + + const layoutConfig = { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 20, + horizontalSpacing: 420, + }; + const getLayoutConfig = vi.fn(() => layoutConfig); + const getScanScopeConfig = vi.fn(() => ({ + folders: ['.'], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: true, + maxFileSizeKB: 512, + })); + vi.doMock('../../core/workspace/config', () => ({ + getLayoutConfig, + getScanScopeConfig, + })); + + const promptScope = vi.fn(); + vi.doMock('../../core/workspace/scope', () => ({ + promptScope, + })); + + const { activate } = await import('../index'); + activate({ + extensionUri: vscode.Uri.file('/workspace'), + subscriptions: [], + } as never); + + expect([...registered.keys()]).toEqual([ + 'rqv.focusActivity', + 'rqv.openGraphPanel', + 'rqv.scanNow', + 'rqv.scanWithScope', + 'rqv.revealInCode', + ]); + + collapseHandler?.({ element: { children: [] } }); + collapseHandler?.({ element: { children: [{}] } }); + treeViewReveal.mockRejectedValueOnce(new Error('reveal failed')); + collapseHandler?.({ element: { children: [{}] } }); + expect(treeViewReveal).toHaveBeenCalledTimes(2); + + await registered.get('rqv.focusActivity')?.(); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('workbench.view.extension.rqvContainer'); + + await registered.get('rqv.openGraphPanel')?.(); + expect(createOrShow).toHaveBeenCalledWith(vscode.Uri.file('/workspace'), layoutConfig); + expect(updateFromPayloadSpy).not.toHaveBeenCalled(); + expect(panelUpdate).not.toHaveBeenCalled(); + createOrShow.mockClear(); + + await registered.get('rqv.scanNow')?.(); + expect(scanAndPublish).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ extensionUri: vscode.Uri.file('/workspace') }), + scope: getScanScopeConfig.mock.results[0]?.value, + persistScopeFlag: false, + activityViewProvider: expect.any(Object), + onPayloadUpdated: expect.any(Function), + }), + ); + expect(runStaticAnalysis).toHaveBeenCalledTimes(1); + + await registered.get('rqv.openGraphPanel')?.(); + expect(createOrShow).toHaveBeenCalledWith(vscode.Uri.file('/workspace'), layoutConfig); + expect(updateFromPayloadSpy).toHaveBeenCalledWith(payload); + expect(panelUpdate).toHaveBeenCalledWith(payload); + + getWorkspaceFolders.mockReturnValue([]); + await registered.get('rqv.scanWithScope')?.(); + expect(warningMessages).toEqual(['React Query Visualizer: Open a workspace folder first.']); + + getWorkspaceFolders.mockReturnValue(workspaceFolders); + promptScope.mockResolvedValueOnce(undefined); + await registered.get('rqv.scanWithScope')?.(); + expect(scanAndPublish).toHaveBeenCalledTimes(1); + + promptScope.mockResolvedValueOnce({ + folders: ['src'], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: true, + maxFileSizeKB: 256, + }); + await registered.get('rqv.scanWithScope')?.(); + expect(scanAndPublish).toHaveBeenCalledTimes(2); + expect(scanAndPublish.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + persistScopeFlag: true, + scope: { + folders: ['src'], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: true, + maxFileSizeKB: 256, + }, + }), + ); + + await registered.get('rqv.revealInCode')?.({ file: 'src/file.ts', line: 4, column: 2 }); + expect(revealInCode).toHaveBeenCalledWith({ file: 'src/file.ts', line: 4, column: 2 }); + }); +}); diff --git a/src/extension/commands/__tests__/reveal.test.ts b/src/extension/commands/__tests__/reveal.test.ts new file mode 100644 index 0000000..8f2e0d8 --- /dev/null +++ b/src/extension/commands/__tests__/reveal.test.ts @@ -0,0 +1,46 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; + +import * as vscode from '../../../testing/vscode'; + +vi.mock('vscode', async () => await import('../../../testing/vscode')); + +describe('extension/commands/reveal', () => { + it('opens a relative file inside the workspace', async () => { + const workspaceDir = await mkdtemp(join(tmpdir(), 'rqv-reveal-')); + await mkdir(join(workspaceDir, 'src'), { recursive: true }); + const filePath = join(workspaceDir, 'src/file.ts'); + await writeFile(filePath, 'export const value = 1;\n'); + vscode.workspace.workspaceFolders = [{ name: 'workspace', uri: { fsPath: workspaceDir } }]; + + const openTextDocument = vi.spyOn(vscode.workspace, 'openTextDocument').mockResolvedValue({} as never); + const showTextDocument = vi.spyOn(vscode.window, 'showTextDocument').mockResolvedValue({ + selection: null, + revealRange: vi.fn(), + } as never); + + const { revealInCode } = await import('../reveal'); + await revealInCode({ file: 'src/file.ts', line: 2, column: 3 }); + + expect(openTextDocument).toHaveBeenCalledWith(filePath); + expect(showTextDocument).toHaveBeenCalled(); + + await revealInCode({ file: filePath }); + expect(openTextDocument).toHaveBeenCalledWith(filePath); + + await revealInCode(undefined); + }); + + it('warns when the target file cannot be located', async () => { + const workspaceDir = await mkdtemp(join(tmpdir(), 'rqv-reveal-missing-')); + vscode.workspace.workspaceFolders = [{ name: 'workspace', uri: { fsPath: workspaceDir } }]; + const warning = vi.spyOn(vscode.window, 'showWarningMessage').mockResolvedValue(undefined); + + const { revealInCode } = await import('../reveal'); + await revealInCode({ file: 'missing.ts' }); + + expect(warning).toHaveBeenCalledWith('React Query Visualizer: Could not locate file: missing.ts'); + }); +}); diff --git a/src/extension/commands/__tests__/scan.test.ts b/src/extension/commands/__tests__/scan.test.ts new file mode 100644 index 0000000..c3e28d5 --- /dev/null +++ b/src/extension/commands/__tests__/scan.test.ts @@ -0,0 +1,344 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createGraph, createQueryRecord } from '../../../testing/fixtures'; +import * as vscode from '../../../testing/vscode'; + +vi.mock('vscode', async () => await import('../../../testing/vscode')); + +const runStaticAnalysisMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../../core/graph/buildGraph', () => ({ + buildGraph: vi.fn(), +})); + +vi.mock('../../../core/workspace/config', () => ({ + getLayoutConfig: vi.fn(() => ({ + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + })), + persistScanScope: vi.fn(), +})); + +vi.mock('../../../core/workspace/scope', () => ({ + scopeToLabel: vi.fn(() => 'Scope'), +})); + +vi.mock('../../views/graphPanel', () => ({ + GraphPanel: { + createOrShow: vi.fn(() => ({ + update: vi.fn(), + })), + }, +})); + +vi.mock('../../workspace/folders', () => ({ + getWorkspaceFolders: vi.fn(), + scopeForWorkspace: vi.fn((scope: unknown) => scope), +})); + +afterEach(() => { + runStaticAnalysisMock.mockReset(); + vi.clearAllMocks(); +}); + +describe('extension/commands/scan', () => { + it('runs a scan and formats scanned file paths', async () => { + const { buildGraph } = await import('../../../core/graph/buildGraph'); + const { getWorkspaceFolders, scopeForWorkspace } = await import('../../workspace/folders'); + + vi.mocked(scopeForWorkspace).mockImplementation((scope: unknown) => scope as never); + vi.mocked(getWorkspaceFolders).mockReturnValue([ + { name: 'web', uri: { fsPath: '/workspace/web' } }, + { name: 'api', uri: { fsPath: '/workspace/api' } }, + ] as never); + runStaticAnalysisMock + .mockResolvedValueOnce({ + records: [], + scannedFiles: ['/workspace/web/src/query.ts'], + filesScanned: 1, + parseErrors: [], + } as never) + .mockResolvedValueOnce({ + records: [], + scannedFiles: ['/workspace/api/src/query.ts'], + filesScanned: 1, + parseErrors: [], + } as never); + vi.mocked(buildGraph).mockReturnValue( + createGraph({ + nodes: [], + edges: [], + }) as never, + ); + + const { runScan, scanAndPublish } = await import('../scan'); + const result = await runScan( + [ + { name: 'web', uri: { fsPath: '/workspace/web' } }, + { name: 'api', uri: { fsPath: '/workspace/api' } }, + ] as never, + { folders: [], includeGlob: '**/*.ts', excludeGlob: '**/*.test.ts', useGitIgnore: true, maxFileSizeKB: 512 }, + runStaticAnalysisMock, + ); + + expect(result.scannedWorkspaces).toEqual(['web', 'api']); + expect(result.payload.scannedFiles.map((file) => file.path)).toEqual(['api/src/query.ts', 'web/src/query.ts']); + expect(result.payload.scopeLabel).toBe('Scope | Workspaces: web, api'); + + runStaticAnalysisMock + .mockResolvedValueOnce({ + records: [], + scannedFiles: ['/workspace/web/src/query.ts'], + filesScanned: 1, + parseErrors: [], + } as never) + .mockResolvedValueOnce({ + records: [], + scannedFiles: ['/workspace/api/src/query.ts'], + filesScanned: 1, + parseErrors: [], + } as never); + const progressReports: unknown[] = []; + const infoMessages: string[] = []; + vi.spyOn(vscode.window, 'withProgress').mockImplementation(async (_options, task) => + task({ report: (value: unknown) => progressReports.push(value) }), + ); + vi.spyOn(vscode.window, 'showInformationMessage').mockImplementation(async (...messageArgs: unknown[]) => { + const [message] = messageArgs; + if (typeof message === 'string') { + infoMessages.push(message); + } + return undefined; + }); + + await scanAndPublish({ + context: { extensionUri: vscode.Uri.file('/extension') } as never, + scope: { + folders: [], + includeGlob: '**/*.ts', + excludeGlob: '**/*.test.ts', + useGitIgnore: true, + maxFileSizeKB: 512, + }, + persistScopeFlag: false, + onPayloadUpdated: vi.fn(), + runStaticAnalysis: runStaticAnalysisMock, + }); + + expect(progressReports).toEqual([{ message: 'Collecting source files...' }, { message: 'Building graph...' }]); + expect(infoMessages[0]).toContain('scan complete'); + }); + + it('warns when no workspace folders are open and persists a scoped scan with parse errors', async () => { + const { buildGraph } = await import('../../../core/graph/buildGraph'); + const { persistScanScope } = await import('../../../core/workspace/config'); + const { getWorkspaceFolders, scopeForWorkspace } = await import('../../workspace/folders'); + + vi.mocked(scopeForWorkspace).mockImplementation((scope: unknown) => scope as never); + vi.mocked(getWorkspaceFolders).mockReturnValue([] as never); + + const warningMessages: string[] = []; + const infoMessages: string[] = []; + vi.spyOn(vscode.window, 'showWarningMessage').mockImplementation(async (...messageArgs: unknown[]) => { + const [message] = messageArgs; + if (typeof message === 'string') { + warningMessages.push(message); + } + return undefined; + }); + vi.spyOn(vscode.window, 'showInformationMessage').mockImplementation(async (...messageArgs: unknown[]) => { + const [message] = messageArgs; + if (typeof message === 'string') { + infoMessages.push(message); + } + return undefined; + }); + + const { scanAndPublish } = await import('../scan'); + + await scanAndPublish({ + context: { extensionUri: vscode.Uri.file('/extension') } as never, + scope: { + folders: ['src'], + includeGlob: '**/*.ts', + excludeGlob: '**/*.test.ts', + useGitIgnore: true, + maxFileSizeKB: 512, + }, + persistScopeFlag: true, + onPayloadUpdated: vi.fn(), + runStaticAnalysis: runStaticAnalysisMock, + }); + + expect(warningMessages[0]).toContain('Open a workspace folder first'); + expect(infoMessages).toEqual([]); + expect(persistScanScope).not.toHaveBeenCalled(); + expect(buildGraph).not.toHaveBeenCalled(); + + vi.mocked(getWorkspaceFolders).mockReturnValue([{ name: 'web', uri: { fsPath: '/workspace/web' } }] as never); + vi.mocked(buildGraph).mockReturnValue( + createGraph({ + nodes: [], + edges: [], + summary: { files: 1, actions: 0, queryKeys: 0, parseErrors: 2 }, + parseErrors: [ + { file: 'src/a.ts', message: 'first' }, + { file: 'src/b.ts', message: 'second' }, + ], + }) as never, + ); + vi.mocked(persistScanScope).mockResolvedValue(undefined as never); + vi.spyOn(vscode.window, 'withProgress').mockImplementation(async (_options, task) => + task({ report: () => undefined }), + ); + + vi.mocked(vscode.window.showWarningMessage).mockClear(); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + + runStaticAnalysisMock.mockResolvedValue({ + records: [], + scannedFiles: ['/workspace/web/src/query.ts'], + filesScanned: 1, + parseErrors: [], + } as never); + + await scanAndPublish({ + context: { extensionUri: vscode.Uri.file('/extension') } as never, + scope: { + folders: ['src'], + includeGlob: '**/*.ts', + excludeGlob: '**/*.test.ts', + useGitIgnore: true, + maxFileSizeKB: 512, + }, + persistScopeFlag: true, + onPayloadUpdated: vi.fn(), + runStaticAnalysis: runStaticAnalysisMock, + }); + + expect(persistScanScope).toHaveBeenCalledWith({ + folders: ['src'], + includeGlob: '**/*.ts', + excludeGlob: '**/*.test.ts', + useGitIgnore: true, + maxFileSizeKB: 512, + }); + expect(infoMessages[0]).toContain('scan complete'); + expect(vscode.window.showWarningMessage).toHaveBeenCalled(); + }); + + it('dedupes scanned files and keeps out-of-root paths absolute', async () => { + const { buildGraph } = await import('../../../core/graph/buildGraph'); + const { getWorkspaceFolders, scopeForWorkspace } = await import('../../workspace/folders'); + + vi.mocked(scopeForWorkspace).mockImplementation((scope: unknown) => scope as never); + vi.mocked(getWorkspaceFolders).mockReturnValue([{ name: 'web', uri: { fsPath: '/workspace/web' } }] as never); + runStaticAnalysisMock.mockResolvedValueOnce({ + records: [], + scannedFiles: ['/workspace/web', '/workspace/web', '/outside/shared.ts'], + filesScanned: 3, + parseErrors: [], + } as never); + vi.mocked(buildGraph).mockReturnValue( + createGraph({ + nodes: [], + edges: [], + }) as never, + ); + + const { runScan } = await import('../scan'); + const result = await runScan( + [{ name: 'web', uri: { fsPath: '/workspace/web' } }] as never, + { folders: [], includeGlob: '**/*.ts', excludeGlob: '**/*.test.ts', useGitIgnore: true, maxFileSizeKB: 512 }, + runStaticAnalysisMock, + ); + + expect(result.scannedWorkspaces).toEqual(['web']); + expect(result.payload.scannedFiles.map((file) => file.path)).toEqual(['web', '/outside/shared.ts']); + expect(result.payload.scannedFiles.map((file) => file.workspace)).toEqual(['web', 'web']); + expect(result.payload.scannedFiles.map((file) => file.depth)).toEqual([0, 1]); + }); + + it('dedupes merged analysis records and errors when scoped targets are empty', async () => { + const { buildGraph } = await import('../../../core/graph/buildGraph'); + const { scopeForWorkspace } = await import('../../workspace/folders'); + const { runScan } = await import('../scan'); + + const duplicateRecord = createQueryRecord({ + file: '/workspace/web/src/query.ts', + loc: { line: 1, column: 2 }, + relation: 'declares', + operation: 'useQuery', + queryKey: { + id: 'todos', + display: '[todos]', + segments: ['todos'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }, + clientScopeId: 'client', + executionScopeId: 'execution', + suiteScopeId: 'suite', + declaresDirectly: true, + }); + const plainRecord = createQueryRecord({ + file: '/workspace/web/src/plain.ts', + loc: { line: 3, column: 4 }, + relation: 'invalidates', + operation: 'invalidateQueries', + queryKey: { + id: 'users', + display: '[users]', + segments: ['users'], + matchMode: 'exact', + resolution: 'static', + source: 'literal', + }, + }); + vi.mocked(scopeForWorkspace).mockImplementation((scope: unknown) => scope as never); + runStaticAnalysisMock + .mockResolvedValueOnce({ + records: [duplicateRecord, plainRecord], + scannedFiles: ['/workspace/web/src/query.ts'], + filesScanned: 1, + parseErrors: [], + } as never) + .mockResolvedValueOnce({ + records: [duplicateRecord], + scannedFiles: ['/workspace/api/src/query.ts'], + filesScanned: 1, + parseErrors: [], + } as never); + vi.mocked(buildGraph).mockReturnValue(createGraph({ nodes: [], edges: [] }) as never); + + await runScan( + [ + { name: 'web', uri: { fsPath: '/workspace/web' } }, + { name: 'api', uri: { fsPath: '/workspace/api' } }, + ] as never, + { folders: [], includeGlob: '**/*.ts', excludeGlob: '**/*.test.ts', useGitIgnore: true, maxFileSizeKB: 512 }, + runStaticAnalysisMock, + ); + + const lastBuildGraphCall = vi.mocked(buildGraph).mock.calls[vi.mocked(buildGraph).mock.calls.length - 1]; + expect(lastBuildGraphCall?.[1].records).toHaveLength(2); + + vi.mocked(scopeForWorkspace).mockReturnValue(null as never); + await expect( + runScan( + [{ name: 'web', uri: { fsPath: '/workspace/web' } }] as never, + { + folders: ['outside'], + includeGlob: '**/*.ts', + excludeGlob: '**/*.test.ts', + useGitIgnore: true, + maxFileSizeKB: 512, + }, + runStaticAnalysisMock, + ), + ).rejects.toThrow('Selected folders are outside all opened workspaces.'); + }); +}); diff --git a/src/extension/commands/scan.ts b/src/extension/commands/scan.ts index cd6dee0..afe87a9 100644 --- a/src/extension/commands/scan.ts +++ b/src/extension/commands/scan.ts @@ -1,7 +1,6 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; -import { runStaticAnalysis } from '../../core/analysis/analyzer'; import { buildGraph } from '../../core/graph/buildGraph'; import type { GraphRoot } from '../../core/graph/graphBuilder'; import { getLayoutConfig, persistScanScope } from '../../core/workspace/config'; @@ -22,6 +21,7 @@ interface ScanAndPublishOptions { persistScopeFlag: boolean; activityViewProvider?: RqvActivityViewProvider; onPayloadUpdated: (payload: WebviewPayload) => void; + runStaticAnalysis: (rootPath: string, scope: ScanScope) => Promise; } function mergeAnalysis(results: AnalysisResult[]): AnalysisResult { @@ -105,10 +105,6 @@ function buildScannedFiles( analyses.forEach((analysis, index) => { const target = targets[index]; - if (!target) { - return; - } - const workspaceName = target.workspace.name; analysis.scannedFiles.forEach((absolutePath) => { if (seenPaths.has(absolutePath)) { @@ -130,7 +126,11 @@ function buildScannedFiles( return scannedFiles; } -export async function runScan(workspaces: vscode.WorkspaceFolder[], scope: ScanScope): Promise { +export async function runScan( + workspaces: vscode.WorkspaceFolder[], + scope: ScanScope, + runStaticAnalysis: (rootPath: string, scope: ScanScope) => Promise, +): Promise { const targets = workspaces .map((workspace) => ({ workspace, @@ -173,6 +173,7 @@ export async function scanAndPublish({ persistScopeFlag, activityViewProvider, onPayloadUpdated, + runStaticAnalysis, }: ScanAndPublishOptions): Promise { const workspaces = getWorkspaceFolders(); if (workspaces.length === 0) { @@ -194,7 +195,7 @@ export async function scanAndPublish({ }, async (progress) => { progress.report({ message: 'Collecting source files...' }); - const scanResult = await runScan(workspaces, scope); + const scanResult = await runScan(workspaces, scope, runStaticAnalysis); progress.report({ message: 'Building graph...' }); return scanResult; }, diff --git a/src/extension/index.ts b/src/extension/index.ts index 5b08b5f..dc504fd 100644 --- a/src/extension/index.ts +++ b/src/extension/index.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { revealInCode } from './commands/reveal'; +import { runStaticAnalysis } from '../core/analysis/analyzer'; import { RqvActivityViewProvider } from './views/activityView'; import { GraphPanel } from './views/graphPanel'; import { getDefaultScopeWorkspace, getWorkspaceFolders } from './workspace/folders'; @@ -67,6 +68,7 @@ export function activate(context: vscode.ExtensionContext): void { persistScopeFlag: false, activityViewProvider, onPayloadUpdated: setLatestPayload, + runStaticAnalysis, }); }); @@ -90,6 +92,7 @@ export function activate(context: vscode.ExtensionContext): void { persistScopeFlag: true, activityViewProvider, onPayloadUpdated: setLatestPayload, + runStaticAnalysis, }); }); @@ -110,7 +113,3 @@ export function activate(context: vscode.ExtensionContext): void { reveal, ); } - -export function deactivate(): void { - // no-op -} diff --git a/src/extension/views/__tests__/activityView.test.ts b/src/extension/views/__tests__/activityView.test.ts new file mode 100644 index 0000000..117fd18 --- /dev/null +++ b/src/extension/views/__tests__/activityView.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { WebviewPayload } from '../../../shared/contracts'; +import { createGraph, createGraphNode } from '../../../testing/fixtures'; + +vi.mock('vscode', async () => await import('../../../testing/vscode')); + +import { RqvActivityViewProvider } from '../activityView'; + +describe('extension/views/activityView', () => { + it('builds activity children before and after a scan', async () => { + const provider = new RqvActivityViewProvider(); + const initialChildren = (await provider.getChildren()) ?? []; + + expect(initialChildren.map((item) => item.label)).toEqual([ + 'Scan Now', + 'Scan With Scope', + 'Open Graph Panel', + 'No scan result yet', + ]); + + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'file-web', + kind: 'file', + label: 'web/src/query.ts', + file: '/workspace/web/src/query.ts', + resolution: 'static', + metrics: { affectedKeys: 2, projectScope: 'web:apps/web' }, + }), + createGraphNode({ + id: 'file-api', + kind: 'file', + label: 'api/src/query.ts', + file: '/workspace/api/src/query.ts', + resolution: 'static', + metrics: { affectedKeys: 1, projectScope: 'api:packages/api' }, + }), + createGraphNode({ + id: 'file-api-a', + kind: 'file', + label: 'api/src/a.ts', + file: '/workspace/api/src/a.ts', + resolution: 'static', + metrics: { affectedKeys: 1, projectScope: 'api:packages/api' }, + }), + ], + edges: [], + }), + scannedFiles: [ + { workspace: 'web', path: 'web/src/query.ts', depth: 1 }, + { workspace: 'api', path: 'api/src/query.ts', depth: 1 }, + { workspace: 'api', path: 'api/src/a.ts', depth: 1 }, + ], + scopeLabel: 'All files', + layout: { direction: 'LR', engine: 'dagre', verticalSpacing: 30, horizontalSpacing: 500 }, + } satisfies WebviewPayload; + + provider.updateFromPayload(payload); + const scannedChildren = (await provider.getChildren()) ?? []; + + expect(scannedChildren.map((item) => item.label)).toEqual([ + 'Scan Now', + 'Scan With Scope', + 'Open Graph Panel', + 'Parse Errors', + 'Related Files', + ]); + + const relatedFilesRoot = scannedChildren[4]; + expect(provider.getTreeItem(relatedFilesRoot)).toBe(relatedFilesRoot); + expect(relatedFilesRoot?.tooltip).toContain('All files'); + const relatedChildren = (await provider.getChildren(relatedFilesRoot)) ?? []; + expect(relatedChildren.map((item) => item.label)).toEqual(['api/packages/api', 'web/apps/web']); + const webProject = relatedChildren.find((item) => item.label === 'web/apps/web'); + const webProjectChildren = (await provider.getChildren(webProject)) ?? []; + const srcDir = webProjectChildren.find((item) => item.label === 'src'); + const srcChildren = srcDir ? ((await provider.getChildren(srcDir)) ?? []) : []; + const fileNode = srcChildren.find((item) => item.label === 'query.ts'); + expect(fileNode?.command).toMatchObject({ + command: 'rqv.revealInCode', + arguments: [{ file: '/workspace/web/src/query.ts', line: 1, column: 1 }], + }); + }); + + it('shows parse error warnings and falls back to an empty related files root', async () => { + const provider = new RqvActivityViewProvider(); + provider.updateFromPayload({ + graph: createGraph({ + nodes: [], + edges: [], + summary: { files: 0, actions: 0, queryKeys: 0, parseErrors: 1 }, + parseErrors: [{ file: 'src/broken.ts', message: 'parse failed' }], + }), + scannedFiles: [], + scopeLabel: 'Scoped scan', + layout: { direction: 'LR', engine: 'dagre', verticalSpacing: 30, horizontalSpacing: 500 }, + }); + + const scannedChildren = (await provider.getChildren()) ?? []; + const parseErrorsNode = scannedChildren[3]; + expect(parseErrorsNode?.iconPath).toMatchObject({ id: 'warning' }); + + const fallbackProvider = new RqvActivityViewProvider(); + (fallbackProvider as unknown as { lastScan: { parseErrors: number; relatedFilesTree: [] } }).lastScan = { + parseErrors: 0, + relatedFilesTree: [], + }; + + const fallbackChildren = (await fallbackProvider.getChildren()) ?? []; + const relatedFilesNode = fallbackChildren[4]; + expect(relatedFilesNode?.id).toBe('rqv:related-files-empty'); + expect(relatedFilesNode?.description).toBe('0'); + }); + + it('covers sparse related file metadata', async () => { + const { RqvActivityViewProvider } = await import('../activityView'); + const provider = new RqvActivityViewProvider(); + provider.updateFromPayload({ + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'empty-label', + kind: 'file', + label: '', + resolution: 'static', + }), + createGraphNode({ + id: 'relative-file', + kind: 'file', + label: 'src/query.ts', + resolution: 'static', + }), + createGraphNode({ + id: 'no-absolute-path', + kind: 'file', + label: 'src/no-file.ts', + resolution: 'static', + metrics: { affectedKeys: 3 }, + }), + ], + edges: [], + summary: { files: 3, actions: 0, queryKeys: 0, parseErrors: 0 }, + }), + scannedFiles: [], + scopeLabel: 'Sparse scan', + layout: { direction: 'LR', engine: 'dagre', verticalSpacing: 30, horizontalSpacing: 500 }, + }); + + const roots = (await provider.getChildren()) ?? []; + const parseErrorsNode = roots.find((item) => item.id === 'rqv:parse-errors'); + expect(parseErrorsNode?.iconPath).toMatchObject({ id: 'check' }); + const relatedFilesRoot = roots.find((item) => item.id === 'rqv:related-files-root'); + const relatedChildren = (await provider.getChildren(relatedFilesRoot)) ?? []; + expect(relatedChildren.some((item) => item.label === 'workspace')).toBe(true); + + const workspaceNode = relatedChildren.find((item) => item.label === 'workspace'); + const workspaceChildren = (await provider.getChildren(workspaceNode)) ?? []; + const emptyFile = workspaceChildren.find((item) => item.id === 'rqv:file:'); + expect(emptyFile?.command).toBeUndefined(); + }); +}); diff --git a/src/extension/views/__tests__/graphPanel.test.ts b/src/extension/views/__tests__/graphPanel.test.ts new file mode 100644 index 0000000..8ffff37 --- /dev/null +++ b/src/extension/views/__tests__/graphPanel.test.ts @@ -0,0 +1,302 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as vscode from '../../../testing/vscode'; + +vi.mock('vscode', async () => await import('../../../testing/vscode')); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('extension/views/graphPanel', () => { + it('renders the panel html and responds to messages', async () => { + const postMessage = vi.fn(); + let messageHandler: ((message: unknown) => void) | undefined; + let disposeHandler: (() => void) | undefined; + let colorThemeHandler: (() => void) | undefined; + const reveal = vi.fn(); + vscode.window.activeColorTheme = { kind: 1 }; + + vi.spyOn(vscode.window, 'createWebviewPanel').mockReturnValue({ + webview: { + html: '', + cspSource: 'vscode-resource:', + asWebviewUri: (uri: { toString: () => string }) => uri.toString(), + postMessage, + onDidReceiveMessage: (handler: (message: unknown) => void) => { + messageHandler = handler; + return { dispose: () => undefined }; + }, + }, + reveal, + onDidDispose: (handler: () => void) => { + disposeHandler = handler; + return { dispose: () => undefined }; + }, + } as never); + + vi.spyOn(vscode.window, 'onDidChangeActiveColorTheme').mockImplementation((handler: () => void) => { + colorThemeHandler = handler; + return { dispose: () => undefined }; + }); + vi.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + + const { GraphPanel } = await import('../graphPanel'); + const panel = GraphPanel.createOrShow(vscode.Uri.file('/extension'), { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }); + + expect(reveal).not.toHaveBeenCalled(); + expect(vscode.window.createWebviewPanel).toHaveBeenCalled(); + + messageHandler?.({ type: 'ready' }); + expect(postMessage).toHaveBeenCalledWith({ + type: 'theme', + themeKind: vscode.window.activeColorTheme?.kind, + }); + expect(postMessage).toHaveBeenCalledWith({ + type: 'graphData', + payload: expect.objectContaining({ + scopeLabel: 'No scan has run yet', + }), + }); + + panel.update({ + graph: { nodes: [], edges: [], summary: { files: 1, actions: 0, queryKeys: 0, parseErrors: 0 }, parseErrors: [] }, + scannedFiles: [], + scopeLabel: 'payload', + layout: { direction: 'LR', engine: 'dagre', verticalSpacing: 30, horizontalSpacing: 500 }, + }); + expect(postMessage).toHaveBeenCalledWith({ + type: 'graphData', + payload: expect.objectContaining({ scopeLabel: 'payload' }), + }); + + messageHandler?.({ type: 'reveal', file: '/tmp/file.ts', line: 2, column: 3 }); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('rqv.revealInCode', { + file: '/tmp/file.ts', + line: 2, + column: 3, + }); + + colorThemeHandler?.(); + disposeHandler?.(); + }); + + it('reuses the existing panel instance and clears current state on dispose', async () => { + const postMessage = vi.fn(); + let disposeHandler: (() => void) | undefined; + const reveal = vi.fn(); + vscode.window.activeColorTheme = { kind: 1 }; + + vi.spyOn(vscode.window, 'createWebviewPanel').mockReturnValue({ + webview: { + html: '', + cspSource: 'vscode-resource:', + asWebviewUri: (uri: { toString: () => string }) => uri.toString(), + postMessage, + onDidReceiveMessage: () => ({ dispose: () => undefined }), + }, + reveal, + onDidDispose: (handler: () => void) => { + disposeHandler = handler; + return { dispose: () => undefined }; + }, + } as never); + + const { GraphPanel } = await import('../graphPanel'); + const first = GraphPanel.createOrShow(vscode.Uri.file('/extension'), { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }); + const second = GraphPanel.createOrShow(vscode.Uri.file('/extension'), { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }); + + expect(second).toBe(first); + expect(reveal).toHaveBeenCalledWith(vscode.ViewColumn.One); + expect(GraphPanel.currentPanel).toBe(first); + + disposeHandler?.(); + expect(GraphPanel.currentPanel).toBeUndefined(); + }); + + it('queues updates until ready and ignores invalid messages', async () => { + const postMessage = vi.fn(); + let messageHandler: ((message: unknown) => void) | undefined; + let colorThemeHandler: (() => void) | undefined; + const reveal = vi.fn(); + vscode.window.activeColorTheme = { kind: 2 }; + + vi.spyOn(vscode.window, 'createWebviewPanel').mockReturnValue({ + webview: { + html: '', + cspSource: 'vscode-resource:', + asWebviewUri: (uri: { toString: () => string }) => uri.toString(), + postMessage, + onDidReceiveMessage: (handler: (message: unknown) => void) => { + messageHandler = handler; + return { dispose: () => undefined }; + }, + }, + reveal, + onDidDispose: () => ({ dispose: () => undefined }), + } as never); + vi.spyOn(vscode.window, 'onDidChangeActiveColorTheme').mockImplementation((handler: () => void) => { + colorThemeHandler = handler; + return { dispose: () => undefined }; + }); + vi.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + + const { GraphPanel } = await import('../graphPanel'); + const panel = GraphPanel.createOrShow(vscode.Uri.file('/extension'), { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }); + + panel.update({ + graph: { nodes: [], edges: [], summary: { files: 2, actions: 0, queryKeys: 0, parseErrors: 0 }, parseErrors: [] }, + scannedFiles: [], + scopeLabel: 'queued', + layout: { direction: 'LR', engine: 'dagre', verticalSpacing: 30, horizontalSpacing: 500 }, + }); + expect(postMessage).not.toHaveBeenCalled(); + colorThemeHandler?.(); + expect(postMessage).not.toHaveBeenCalled(); + + messageHandler?.(undefined); + messageHandler?.('ready'); + messageHandler?.({ type: 'reveal' }); + expect(vscode.commands.executeCommand).not.toHaveBeenCalled(); + + messageHandler?.({ type: 'ready' }); + expect(postMessage).toHaveBeenCalledWith({ + type: 'graphData', + payload: expect.objectContaining({ scopeLabel: 'queued' }), + }); + }); + + it('does not clear current when dispose fires for a stale (non-current) panel instance', async () => { + const disposeHandlers: Array<() => void> = []; + vscode.window.activeColorTheme = { kind: 1 }; + + vi.spyOn(vscode.window, 'createWebviewPanel').mockImplementation( + () => + ({ + webview: { + html: '', + cspSource: 'vscode-resource:', + asWebviewUri: (uri: { toString: () => string }) => uri.toString(), + postMessage: vi.fn(), + onDidReceiveMessage: () => ({ dispose: () => undefined }), + }, + reveal: vi.fn(), + onDidDispose: (handler: () => void) => { + disposeHandlers.push(handler); + return { dispose: () => undefined }; + }, + }) as never, + ); + + vi.spyOn(vscode.window, 'onDidChangeActiveColorTheme').mockImplementation(() => ({ + dispose: () => undefined, + })); + + const { GraphPanel } = await import('../graphPanel'); + + // Force-clear any static state left by previous tests that skipped cleanup. + (GraphPanel as unknown as { current: undefined }).current = undefined; + + // Create panel1 — current is set to panel1, disposeHandlers[0] is panel1's handler. + const panel1 = GraphPanel.createOrShow(vscode.Uri.file('/extension'), { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }); + expect(GraphPanel.currentPanel).toBe(panel1); + + // Dispose panel1 → current becomes undefined. + disposeHandlers[0]?.(); + expect(GraphPanel.currentPanel).toBeUndefined(); + + // Create panel2 — current is now panel2, disposeHandlers[1] is panel2's handler. + const panel2 = GraphPanel.createOrShow(vscode.Uri.file('/extension'), { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }); + expect(GraphPanel.currentPanel).toBe(panel2); + + // Fire panel1's stale dispose: GraphPanel.current !== panel1, so current should stay as panel2. + disposeHandlers[0]?.(); + expect(GraphPanel.currentPanel).toBe(panel2); + + // Clean up: dispose panel2 properly. + disposeHandlers[1]?.(); + expect(GraphPanel.currentPanel).toBeUndefined(); + }); + + it('skips postGraph when latestPayload is cleared before ready fires', async () => { + const postMessage = vi.fn(); + let messageHandler: ((message: unknown) => void) | undefined; + vscode.window.activeColorTheme = { kind: 1 }; + + vi.spyOn(vscode.window, 'createWebviewPanel').mockReturnValue({ + webview: { + html: '', + cspSource: 'vscode-resource:', + asWebviewUri: (uri: { toString: () => string }) => uri.toString(), + postMessage, + onDidReceiveMessage: (handler: (message: unknown) => void) => { + messageHandler = handler; + return { dispose: () => undefined }; + }, + }, + reveal: vi.fn(), + onDidDispose: () => ({ dispose: () => undefined }), + } as never); + + vi.spyOn(vscode.window, 'onDidChangeActiveColorTheme').mockImplementation(() => ({ + dispose: () => undefined, + })); + + const { GraphPanel } = await import('../graphPanel'); + + // Force-clear static state. + (GraphPanel as unknown as { current: undefined }).current = undefined; + + const panel = GraphPanel.createOrShow(vscode.Uri.file('/extension'), { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }); + + // Clear latestPayload via private field access before the ready message fires. + (panel as unknown as { latestPayload: undefined }).latestPayload = undefined; + + // Send ready — postTheme fires, but postGraph should NOT fire because latestPayload is now undefined. + messageHandler?.({ type: 'ready' }); + + const graphDataCalls = postMessage.mock.calls.filter( + (call) => (call[0] as { type?: string })?.type === 'graphData', + ); + expect(graphDataCalls).toHaveLength(0); + + // Theme message should still have been posted. + const themeCalls = postMessage.mock.calls.filter((call) => (call[0] as { type?: string })?.type === 'theme'); + expect(themeCalls).toHaveLength(1); + }); +}); diff --git a/src/extension/views/activityView.ts b/src/extension/views/activityView.ts index 350edf6..2d14f13 100644 --- a/src/extension/views/activityView.ts +++ b/src/extension/views/activityView.ts @@ -22,19 +22,17 @@ class ActivityNode extends vscode.TreeItem { id?: string; description?: string; tooltip?: string; - icon?: string; + icon: string; command?: vscode.Command; collapsibleState?: vscode.TreeItemCollapsibleState; children?: ActivityNode[]; - } = {}, + }, ) { super(label, options.collapsibleState ?? vscode.TreeItemCollapsibleState.None); this.id = options.id; this.description = options.description; this.tooltip = options.tooltip; - if (options.icon) { - this.iconPath = new vscode.ThemeIcon(options.icon); - } + this.iconPath = new vscode.ThemeIcon(options.icon); if (options.command) { this.command = options.command; } @@ -87,7 +85,7 @@ function buildRelatedFilesTree( for (const file of files) { const segments = normalizePathSegments(file.projectRelativePath); - const impact = Number(file.impact ?? 0); + const impact = Number(file.impact); let projectNode = root.directories.get(file.project); if (!projectNode) { @@ -109,10 +107,6 @@ function buildRelatedFilesTree( let current = projectNode; for (let index = 0; index < segments.length - 1; index += 1) { const segment = segments[index]; - if (!segment) { - continue; - } - const segmentPath = `${file.project}/${segments.slice(0, index + 1).join('/')}`; const existing = current.directories.get(segment); if (existing) { @@ -131,8 +125,7 @@ function buildRelatedFilesTree( current.directories.set(segment, nextDirectory); current = nextDirectory; } - - const fileName = segments[segments.length - 1] ?? file.labelPath; + const fileName = segments[segments.length - 1]; current.files.push({ name: fileName, labelPath: file.labelPath, diff --git a/src/extension/workspace/__tests__/folders.test.ts b/src/extension/workspace/__tests__/folders.test.ts new file mode 100644 index 0000000..4739d94 --- /dev/null +++ b/src/extension/workspace/__tests__/folders.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as vscode from '../../../testing/vscode'; + +vi.mock('vscode', async () => await import('../../../testing/vscode')); + +const workspaceFolders = [ + { name: 'app', uri: { fsPath: '/repo/app' } }, + { name: 'core', uri: { fsPath: '/repo/core' } }, +]; + +afterEach(() => { + vscode.workspace.workspaceFolders = undefined as never; + vscode.workspace.getWorkspaceFolder = undefined as never; + vscode.window.activeTextEditor = undefined; +}); + +describe('extension/workspace/folders', () => { + it('returns workspace folders and scopes relative paths', async () => { + vscode.workspace.workspaceFolders = workspaceFolders as never; + vscode.workspace.getWorkspaceFolder = () => workspaceFolders[0] as never; + vscode.window.activeTextEditor = undefined; + + const { getDefaultScopeWorkspace, getWorkspaceFolders, scopeForWorkspace } = await import('../folders'); + + expect(getWorkspaceFolders()).toEqual(workspaceFolders); + vscode.workspace.workspaceFolders = undefined as never; + expect(getWorkspaceFolders()).toEqual([]); + vscode.workspace.workspaceFolders = workspaceFolders as never; + expect( + scopeForWorkspace( + { + folders: [], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: true, + maxFileSizeKB: 128, + }, + workspaceFolders[0] as never, + ), + ).toEqual({ + folders: [], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: true, + maxFileSizeKB: 128, + }); + expect( + scopeForWorkspace( + { + folders: ['local', '/repo/app/src', '/outside/project'], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: true, + maxFileSizeKB: 128, + }, + workspaceFolders[0] as never, + ), + ).toEqual({ + folders: ['local', 'src'], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: true, + maxFileSizeKB: 128, + }); + vscode.window.activeTextEditor = { document: { uri: { fsPath: '/repo/core/src/file.ts' } } } as never; + vscode.workspace.getWorkspaceFolder = () => workspaceFolders[1] as never; + expect(getDefaultScopeWorkspace(workspaceFolders as never)).toBe(workspaceFolders[1]); + vscode.workspace.getWorkspaceFolder = () => undefined; + expect(getDefaultScopeWorkspace(workspaceFolders as never)).toBe(workspaceFolders[0]); + vscode.window.activeTextEditor = undefined; + expect(getDefaultScopeWorkspace(workspaceFolders as never)).toBe(workspaceFolders[0]); + }); + + it('returns undefined when a scoped workspace cannot be mapped', async () => { + vscode.workspace.workspaceFolders = workspaceFolders as never; + vscode.workspace.getWorkspaceFolder = () => workspaceFolders[1] as never; + vscode.window.activeTextEditor = { document: { uri: { fsPath: '/repo/core/src/file.ts' } } } as never; + + const { getDefaultScopeWorkspace, scopeForWorkspace } = await import('../folders'); + + expect( + scopeForWorkspace( + { + folders: ['/outside/project'], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: true, + maxFileSizeKB: 128, + }, + workspaceFolders[0] as never, + ), + ).toBeUndefined(); + expect(getDefaultScopeWorkspace(workspaceFolders as never)).toBe(workspaceFolders[1]); + }); + + it('normalizes a workspace root folder to "."', async () => { + vscode.workspace.workspaceFolders = workspaceFolders as never; + vscode.workspace.getWorkspaceFolder = () => workspaceFolders[0] as never; + + const { scopeForWorkspace } = await import('../folders'); + + expect( + scopeForWorkspace( + { + folders: ['/repo/app'], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: true, + maxFileSizeKB: 128, + }, + workspaceFolders[0] as never, + ), + ).toEqual({ + folders: ['.'], + includeGlob: '**/*.ts', + excludeGlob: '**/dist/**', + useGitIgnore: true, + maxFileSizeKB: 128, + }); + }); +}); diff --git a/src/shared/__tests__/path.test.ts b/src/shared/__tests__/path.test.ts new file mode 100644 index 0000000..2f26949 --- /dev/null +++ b/src/shared/__tests__/path.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { + depthFromPath, + inferProjectFromPath, + makeProjectRelativePath, + normalizePathSegments, + parseProjectScope, + projectLabelFromScope, + stripWorkspacePrefix, +} from '../path'; + +describe('shared/path', () => { + it('normalizes and measures paths', () => { + expect(normalizePathSegments('a//b/c/')).toEqual(['a', 'b', 'c']); + expect(depthFromPath('file.ts')).toBe(0); + expect(depthFromPath('a/b/c.ts')).toBe(2); + }); + + it('strips workspace prefixes and infers projects', () => { + expect(stripWorkspacePrefix('/repo/apps/web/src/file.ts', '')).toBe('/repo/apps/web/src/file.ts'); + expect(stripWorkspacePrefix('/repo/apps/web/src/file.ts', '/repo')).toBe('apps/web/src/file.ts'); + expect(stripWorkspacePrefix('/outside/file.ts', '/repo')).toBe('/outside/file.ts'); + + expect(inferProjectFromPath('/repo/apps/web/src/file.ts', '/repo')).toBe('apps/web'); + expect(inferProjectFromPath('/repo/file.ts', '/repo')).toBe('file.ts'); + expect(inferProjectFromPath('/repo/', '/repo')).toBe('/repo'); + expect(inferProjectFromPath('', '')).toBe('workspace'); + }); + + it('parses and labels project scopes', () => { + expect(parseProjectScope(null)).toBeNull(); + expect(parseProjectScope('')).toBeNull(); + expect(parseProjectScope('core')).toEqual({ root: '', project: 'core' }); + expect(parseProjectScope('app:')).toEqual({ root: 'app', project: 'app' }); + expect(parseProjectScope('app:*')).toEqual({ root: 'app', project: 'app' }); + expect(parseProjectScope('app:packages/core')).toEqual({ root: 'app', project: 'packages/core' }); + expect(projectLabelFromScope('app:packages/core')).toBe('app/packages/core'); + expect(projectLabelFromScope('app:*')).toBe('app'); + expect(projectLabelFromScope('core')).toBe('core'); + expect(projectLabelFromScope(null)).toBeNull(); + expect(parseProjectScope(' app : . ')).toEqual({ root: 'app', project: 'app' }); + expect(parseProjectScope(' : shared ')).toEqual({ root: '', project: 'shared' }); + expect(parseProjectScope(':')).toEqual({ root: '', project: 'workspace' }); + expect(projectLabelFromScope(':')).toBe('workspace'); + }); + + it('derives project-relative paths', () => { + expect(makeProjectRelativePath('', '/repo', 'apps/web')).toBe(''); + expect(makeProjectRelativePath('/repo/apps/web/src/file.ts', '/repo', 'apps/web')).toBe('src/file.ts'); + expect(makeProjectRelativePath('/repo/apps/web/src/file.ts', '/repo', 'packages/core')).toBe( + 'apps/web/src/file.ts', + ); + expect(makeProjectRelativePath('/repo/file.ts', '/repo', 'repo')).toBe('file.ts'); + expect(makeProjectRelativePath('/repo', '/repo', 'repo')).toBe('repo'); + expect(makeProjectRelativePath('/repo', '/repo', '')).toBe('/repo'); + expect(makeProjectRelativePath('/outside/file.ts', '/repo', 'repo')).toBe('/outside/file.ts'); + }); +}); diff --git a/src/testing/fixtures.ts b/src/testing/fixtures.ts new file mode 100644 index 0000000..3e04801 --- /dev/null +++ b/src/testing/fixtures.ts @@ -0,0 +1,47 @@ +import type { GraphData, GraphEdge, GraphNode, QueryRecord, ScannedFile } from '../shared/contracts'; + +export function createGraphNode(overrides: Partial & Pick): GraphNode { + return { + resolution: 'static', + ...overrides, + }; +} + +export function createGraphEdge( + overrides: Partial & Pick, +): GraphEdge { + return { + resolution: 'static', + ...overrides, + }; +} + +export function createGraph(overrides: Partial & Pick): GraphData { + return { + summary: { + files: 0, + actions: 0, + queryKeys: 0, + parseErrors: 0, + }, + parseErrors: [], + ...overrides, + }; +} + +export function createQueryRecord( + overrides: Partial & Pick, +): QueryRecord { + return { + resolution: 'static', + ...overrides, + }; +} + +export function createScannedFile( + overrides: Partial & Pick, +): ScannedFile { + return { + ...overrides, + }; +} diff --git a/src/testing/setup.ts b/src/testing/setup.ts new file mode 100644 index 0000000..887d935 --- /dev/null +++ b/src/testing/setup.ts @@ -0,0 +1,18 @@ +import { afterEach, vi } from 'vitest'; + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean; +} + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.documentElement.className = ''; + document.documentElement.removeAttribute('style'); + document.body.className = ''; + document.body.removeAttribute('style'); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); diff --git a/src/testing/vscode.ts b/src/testing/vscode.ts new file mode 100644 index 0000000..1a4bcd3 --- /dev/null +++ b/src/testing/vscode.ts @@ -0,0 +1,180 @@ +type WorkspaceFolder = { name: string; uri: { fsPath: string } }; + +type WorkspaceConfiguration = { + get(key: string, fallback: T): T; + update(key: string, value: unknown, target: unknown): Promise; +}; + +type DisposableLike = { dispose: () => undefined }; + +type Webview = { + html: string; + cspSource: string; + asWebviewUri: (uri: { toString: () => string }) => string; + postMessage: (message: unknown) => Promise; + onDidReceiveMessage: (handler: (message: unknown) => void) => DisposableLike; +}; + +type WebviewPanel = { + webview: Webview; + reveal: () => undefined; + onDidDispose: (handler: () => void) => DisposableLike; +}; + +type TreeView = { + dispose: () => undefined; + reveal: (item: T) => void; + onDidCollapseElement: (handler: (event: { element: { children: unknown[] } }) => void) => DisposableLike; +}; + +export const workspace = { + workspaceFolders: [] as WorkspaceFolder[], + getConfiguration: (): WorkspaceConfiguration => ({ + get: (_key: string, fallback: T): T => fallback, + update: async () => undefined, + }), + openTextDocument: async (..._args: unknown[]) => ({}), + getWorkspaceFolder: () => undefined, +}; + +export const window = { + activeTextEditor: undefined as { document: { uri: { fsPath: string } } } | undefined, + activeColorTheme: undefined as { kind: number } | undefined, + showWarningMessage: async (..._args: unknown[]) => undefined, + showInformationMessage: async (..._args: unknown[]) => undefined, + showTextDocument: async (..._args: unknown[]) => ({ selection: null, revealRange: () => undefined }), + showOpenDialog: async (..._args: unknown[]): Promise => undefined, + showInputBox: async (..._args: unknown[]): Promise => undefined, + createTreeView: (_id: string, _options: unknown): TreeView => ({ + dispose: () => undefined, + reveal: () => undefined, + onDidCollapseElement: () => ({ dispose: () => undefined }), + }), + createWebviewPanel: (): WebviewPanel => ({ + webview: { + html: '', + cspSource: 'vscode-resource:', + asWebviewUri: (uri: { toString: () => string }) => uri.toString(), + postMessage: async () => true, + onDidReceiveMessage: () => ({ dispose: () => undefined }), + }, + reveal: () => undefined, + onDidDispose: () => ({ dispose: () => undefined }), + }), + onDidChangeActiveColorTheme: (_handler: () => void) => { + return { dispose: () => undefined }; + }, + withProgress: async ( + _options: unknown, + task: (progress: { report: (_value: unknown) => void }) => Promise, + ) => task({ report: () => undefined }), +}; + +export const commands = { + executeCommand: async (..._args: unknown[]) => undefined, + registerCommand: (_name: string, _callback: (...args: unknown[]) => unknown) => ({ + dispose: () => undefined, + }), +}; + +export class Position { + constructor( + public readonly line: number, + public readonly character: number, + ) {} +} + +export class Selection { + constructor( + public readonly anchor: unknown, + public readonly active: unknown, + ) {} +} + +export class Range { + constructor( + public readonly start: unknown, + public readonly end: unknown, + ) {} +} + +export const TextEditorRevealType = { + InCenter: 1, +}; + +export const ConfigurationTarget = { + Workspace: 'workspace', +} as const; + +export const ViewColumn = { + One: 1, +} as const; + +export const ProgressLocation = { + Notification: 1, +} as const; + +export class Uri { + scheme = 'file'; + authority = ''; + path: string; + query = ''; + fragment = ''; + + constructor(public readonly fsPath: string) { + this.path = fsPath; + } + + static joinPath(base: Uri, ...segments: string[]): Uri { + return new Uri([base.fsPath, ...segments].join('/')); + } + + static file(value: string): Uri { + return new Uri(value); + } + + with(): Uri { + return this; + } + + toJSON(): { fsPath: string } { + return { fsPath: this.fsPath }; + } + + toString(): string { + return this.fsPath; + } +} + +export class ThemeIcon { + constructor(public readonly id: string) {} +} + +export class TreeItem { + id?: string; + description?: string; + tooltip?: string; + iconPath?: ThemeIcon; + command?: { command: string; title: string; arguments?: unknown[] }; + + constructor( + public label: string, + public collapsibleState: number, + ) {} +} + +export const TreeItemCollapsibleState = { + None: 0, + Collapsed: 1, + Expanded: 2, +} as const; + +export class EventEmitter { + event = () => ({ dispose: () => undefined }); + + fire(_value: T): void {} +} + +export interface Disposable { + dispose(): void; +} diff --git a/src/webview/__tests__/App.test.tsx b/src/webview/__tests__/App.test.tsx new file mode 100644 index 0000000..bb64dfc --- /dev/null +++ b/src/webview/__tests__/App.test.tsx @@ -0,0 +1,98 @@ +/* @vitest-environment jsdom */ +import '../../testing/setup'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +afterEach(() => { + document.documentElement.className = ''; + document.documentElement.removeAttribute('style'); + document.body.className = ''; + document.body.removeAttribute('style'); +}); + +describe('webview/App', () => { + it('boots the app shell and syncs host theme changes', async () => { + const postMessage = vi.fn(); + vi.stubGlobal('acquireVsCodeApi', () => ({ postMessage })); + vi.stubGlobal('matchMedia', () => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + + const observers: Array<{ observe: ReturnType; disconnect: ReturnType }> = []; + vi.stubGlobal( + 'MutationObserver', + class { + observe = vi.fn(); + disconnect = vi.fn(); + + constructor() { + observers.push(this); + } + }, + ); + + vi.resetModules(); + vi.doMock('../components/GraphCanvas', () => ({ + GraphCanvas: () =>
, + })); + + const { default: App } = await import('../App'); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + expect(postMessage).toHaveBeenCalledWith({ type: 'ready' }); + expect(container.querySelector('[data-testid="graph-canvas"]')).toBeTruthy(); + + await act(async () => { + window.dispatchEvent(new MessageEvent('message', { data: { type: 'theme', isDark: true } })); + }); + expect(document.documentElement.classList.contains('dark')).toBe(true); + + await act(async () => { + window.dispatchEvent(new MessageEvent('message', { data: { type: 'theme', isDark: false } })); + }); + expect(document.documentElement.classList.contains('dark')).toBe(false); + + await act(async () => { + window.dispatchEvent(new MessageEvent('message', { data: { type: 'theme', themeKind: 2, isDark: false } })); + }); + expect(document.documentElement.classList.contains('dark')).toBe(true); + + await act(async () => { + window.dispatchEvent(new MessageEvent('message', { data: { type: 'graphData' } })); + }); + + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'graphData', + payload: { + graph: { + nodes: [], + edges: [], + summary: { files: 0, actions: 0, queryKeys: 0, parseErrors: 0 }, + parseErrors: [], + }, + scannedFiles: [], + scopeLabel: 'payload', + layout: { direction: 'LR', engine: 'dagre', verticalSpacing: 30, horizontalSpacing: 500 }, + }, + }, + }), + ); + }); + + root.unmount(); + expect(observers[0]?.observe).toHaveBeenCalled(); + expect(observers[0]?.disconnect).toHaveBeenCalled(); + }); +}); diff --git a/src/webview/__tests__/components.test.tsx b/src/webview/__tests__/components.test.tsx new file mode 100644 index 0000000..78376d3 --- /dev/null +++ b/src/webview/__tests__/components.test.tsx @@ -0,0 +1,273 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { RqvFlowNode } from '../components/FlowNode'; +import { LeftPanelQueryKeys } from '../components/LeftPanelQueryKeys'; +import { LeftPanelRelatedFiles } from '../components/LeftPanelRelatedFiles'; +import { ProjectDividerNode } from '../components/ProjectDividerNode'; +import { ResizeDivider } from '../components/ResizeDivider'; +import { RightPanel } from '../components/RightPanel'; + +vi.mock('@xyflow/react', () => ({ + Handle: ({ id }: { id: string }) => createElement('span', { 'data-handle': id }), + Position: { Left: 'left', Right: 'right' }, +})); + +describe('webview/components', () => { + it('renders flow nodes and project dividers', () => { + const flowMarkup = renderToStaticMarkup( + createElement(RqvFlowNode, { + data: { + node: { + id: 'action', + kind: 'action', + label: 'invalidateTodos', + file: '/workspace/src/file.ts', + resolution: 'static', + metrics: { relation: 'invalidates' }, + }, + title: 'invalidateTodos', + subtitle: 'Called in src/file.ts @ 10:2', + relation: 'invalidates', + dim: true, + highlighted: true, + selected: false, + }, + } as never), + ); + expect(flowMarkup).toContain('invalidateTodos'); + expect(flowMarkup).toContain('Invalidate'); + + const declareMarkup = renderToStaticMarkup( + createElement(RqvFlowNode, { + data: { + node: { + id: 'declare', + kind: 'action', + label: 'declareTodos', + resolution: 'static', + metrics: { relation: 'declares', declaresDirectly: 1 }, + }, + title: 'declareTodos', + subtitle: 'Defined in src/file.ts @ 1:1', + relation: 'declares', + dim: false, + highlighted: false, + selected: true, + }, + } as never), + ); + expect(declareMarkup).toContain('Declare'); + + const dividerMarkup = renderToStaticMarkup( + createElement(ProjectDividerNode, { + data: { + label: 'web', + width: 500, + height: 200, + showLabel: true, + variant: 'bubble', + }, + } as never), + ); + expect(dividerMarkup).toContain('web'); + + const hiddenBubbleMarkup = renderToStaticMarkup( + createElement(ProjectDividerNode, { + data: { + label: 'hidden', + width: 500, + height: 200, + showLabel: false, + variant: 'bubble', + }, + } as never), + ); + expect(hiddenBubbleMarkup).not.toContain('hidden'); + + const lineMarkup = renderToStaticMarkup( + createElement(ProjectDividerNode, { + data: { + label: 'api', + width: 500, + showLabel: false, + variant: 'line', + }, + } as never), + ); + expect(lineMarkup).toContain('border-dashed'); + + const defaultDividerMarkup = renderToStaticMarkup( + createElement(ProjectDividerNode, { + data: { + label: 'default', + }, + } as never), + ); + expect(defaultDividerMarkup).toContain('default'); + }); + + it('renders left and right panels', () => { + expect( + renderToStaticMarkup( + createElement(LeftPanelQueryKeys, { + queryKeys: ['todo'], + selectedQueryKey: 'todo', + onSelectQueryKey: () => undefined, + }), + ), + ).toContain('todo'); + + expect( + renderToStaticMarkup( + createElement(LeftPanelRelatedFiles, { + relatedFiles: [{ path: 'web/src/query.ts', workspace: 'web', depth: 1 }], + fileQuery: '', + selectedRelatedFilePath: 'web/src/query.ts', + onSelectRelatedFile: () => undefined, + showProjectDividers: false, + collapsedDirectories: new Set(), + setCollapsedDirectories: () => undefined, + }), + ), + ).toContain('web/src/query.ts'); + + expect( + renderToStaticMarkup( + createElement(RightPanel, { + selectedNode: null, + explanation: null, + onReveal: () => undefined, + onRevealFile: () => undefined, + onRevealCallsite: () => undefined, + }), + ), + ).toContain('Select a node'); + + expect( + renderToStaticMarkup( + createElement(RightPanel, { + selectedNode: { + id: 'file', + kind: 'file', + label: 'src/file.ts', + file: '/workspace/src/file.ts', + resolution: 'static', + loc: { line: 1, column: 1 }, + }, + explanation: { + summary: 'summary', + files: [{ label: 'src/file.ts', file: '/workspace/src/file.ts' }], + actions: [{ label: 'callsite', file: '/workspace/src/file.ts' }], + declarations: [], + queryKeys: ['todo'], + }, + onReveal: () => undefined, + onRevealFile: () => undefined, + onRevealCallsite: () => undefined, + }), + ), + ).toContain('Open in code'); + + const declareDetailsMarkup = renderToStaticMarkup( + createElement(RightPanel, { + selectedNode: { + id: 'declare', + kind: 'action', + label: 'declareTodos', + resolution: 'static', + metrics: { relation: 'declares', declaresDirectly: 1 }, + }, + explanation: { + summary: 'declare summary', + files: [{ label: 'virtual file' }], + actions: [{ label: 'virtual callsite' }], + declarations: [{ label: 'declared here', file: '/workspace/src/declare.ts', line: 4 }], + queryKeys: Array.from({ length: 13 }, (_, index) => `todo-${index}`), + }, + onReveal: () => undefined, + onRevealFile: () => undefined, + onRevealCallsite: () => undefined, + }), + ); + expect(declareDetailsMarkup).toContain('declare'); + expect(declareDetailsMarkup).toContain('virtual file'); + expect(declareDetailsMarkup).toContain('virtual callsite'); + expect(declareDetailsMarkup).toContain('Declared in'); + expect(declareDetailsMarkup).not.toContain('todo-12'); + + expect( + renderToStaticMarkup( + createElement(RightPanel, { + selectedNode: { + id: 'query', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + }, + explanation: null, + onReveal: () => undefined, + onRevealFile: () => undefined, + onRevealCallsite: () => undefined, + }), + ), + ).toContain('queryKey'); + + expect( + renderToStaticMarkup( + createElement(ResizeDivider, { + hiddenOnSmall: true, + onPointerDown: () => undefined, + }), + ), + ).toContain('cursor-col-resize'); + }); + + it('renders empty and nested related-file states', () => { + expect( + renderToStaticMarkup( + createElement(LeftPanelQueryKeys, { + queryKeys: [], + selectedQueryKey: null, + onSelectQueryKey: () => undefined, + }), + ), + ).toContain('No query keys in current filters'); + + expect( + renderToStaticMarkup( + createElement(LeftPanelRelatedFiles, { + relatedFiles: [], + fileQuery: '', + selectedRelatedFilePath: null, + onSelectRelatedFile: () => undefined, + showProjectDividers: false, + collapsedDirectories: new Set(), + setCollapsedDirectories: () => undefined, + }), + ), + ).toContain('No related files in current filters'); + + const treeMarkup = renderToStaticMarkup( + createElement(LeftPanelRelatedFiles, { + relatedFiles: [ + { path: 'api/src/query.ts', workspace: 'api', depth: 1, impact: 2 }, + { path: 'web/src/query.ts', workspace: 'web', depth: 1, impact: 1 }, + { path: 'web/src/nested/file.ts', workspace: 'web', depth: 2, impact: 3 }, + ], + fileQuery: '', + selectedRelatedFilePath: 'web/src/query.ts', + onSelectRelatedFile: () => undefined, + showProjectDividers: true, + collapsedDirectories: new Set(), + setCollapsedDirectories: () => undefined, + }), + ); + + expect(treeMarkup).toContain('api/src/query.ts'); + expect(treeMarkup).toContain('web/src/query.ts'); + expect(treeMarkup).toContain('web/src/nested/file.ts'); + expect(treeMarkup).toContain('border-t border-zinc-300/70'); + }); +}); diff --git a/src/webview/__tests__/index.test.tsx b/src/webview/__tests__/index.test.tsx new file mode 100644 index 0000000..93bf2b2 --- /dev/null +++ b/src/webview/__tests__/index.test.tsx @@ -0,0 +1,41 @@ +/* @vitest-environment jsdom */ +import '../../testing/setup'; +import type { ReactElement } from 'react'; +import { StrictMode, createElement } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +function isReactElement(value: unknown): value is ReactElement<{ children: unknown }> { + return !!value && typeof value === 'object' && 'type' in value && 'props' in value; +} + +describe('webview/index', () => { + it('boots the app into the root element', async () => { + const render = vi.fn(); + vi.doMock('react-dom/client', () => ({ + createRoot: () => ({ render }), + })); + vi.doMock('../App', () => ({ + default: () => createElement('div', { 'data-testid': 'app' }), + })); + + document.body.innerHTML = '
'; + await import('../index'); + + expect(render).toHaveBeenCalledTimes(1); + const renderedElement = render.mock.calls[0]?.[0]; + if (!isReactElement(renderedElement)) { + throw new Error('Expected React element'); + } + expect(renderedElement.type).toBe(StrictMode); + expect(renderedElement.props.children).toMatchObject({ + type: expect.any(Function), + }); + }); + + it('throws when the root container is missing', async () => { + vi.resetModules(); + document.body.innerHTML = ''; + + await expect(import('../index')).rejects.toThrow('Webview root container not found'); + }); +}); diff --git a/src/webview/__tests__/layoutWorker.test.ts b/src/webview/__tests__/layoutWorker.test.ts new file mode 100644 index 0000000..cd58b0d --- /dev/null +++ b/src/webview/__tests__/layoutWorker.test.ts @@ -0,0 +1,193 @@ +/* @vitest-environment jsdom */ +import '../../testing/setup'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +let originalOnMessage: ((event: MessageEvent) => void) | null | undefined; + +afterEach(() => { + if (originalOnMessage !== undefined) { + (self as typeof self & { onmessage: ((event: MessageEvent) => void) | null }).onmessage = originalOnMessage; + originalOnMessage = undefined; + } + vi.doUnmock('../layout/layout'); + vi.resetModules(); +}); + +describe('webview/layoutWorker', () => { + it('posts layouted results', async () => { + const postMessage = vi.spyOn(self, 'postMessage').mockImplementation(() => undefined as never); + originalOnMessage = (self as typeof self & { onmessage: ((event: MessageEvent) => void) | null }).onmessage; + vi.resetModules(); + await import('../layoutWorker'); + + (self as typeof self & { onmessage: (event: MessageEvent) => void }).onmessage({ + data: { + type: 'layout', + id: 1, + nodes: [ + { + id: 'file', + type: 'rqvNode', + data: { + node: { kind: 'file', metrics: { projectScope: 'web:apps/web' } }, + title: 'file', + subtitle: 'file', + }, + position: { x: 0, y: 0 }, + }, + ], + edges: [], + options: { + direction: 'LR', + verticalSpacing: 20, + horizontalSpacing: 400, + }, + }, + } as MessageEvent); + + expect(postMessage).toHaveBeenCalled(); + }); + + it('posts layout errors when layouting fails', async () => { + vi.doMock('../layout/layout', () => ({ + getLayoutedElements: () => { + throw new Error('boom'); + }, + })); + const postMessage = vi.spyOn(self, 'postMessage').mockImplementation(() => undefined as never); + originalOnMessage = (self as typeof self & { onmessage: ((event: MessageEvent) => void) | null }).onmessage; + vi.resetModules(); + await import('../layoutWorker'); + + (self as typeof self & { onmessage: (event: MessageEvent) => void }).onmessage({ + data: { + type: 'layout', + id: 2, + nodes: [], + edges: [], + options: { + direction: 'LR', + verticalSpacing: 20, + horizontalSpacing: 400, + }, + }, + } as MessageEvent); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'layout-error', + id: 2, + message: 'boom', + }); + }); + + it('ignores messages that are not layout requests', async () => { + const postMessage = vi.spyOn(self, 'postMessage').mockImplementation(() => undefined as never); + originalOnMessage = (self as typeof self & { onmessage: ((event: MessageEvent) => void) | null }).onmessage; + vi.resetModules(); + await import('../layoutWorker'); + + (self as typeof self & { onmessage: (event: MessageEvent) => void }).onmessage({ + data: { + type: 'noop', + }, + } as MessageEvent); + + expect(postMessage).not.toHaveBeenCalled(); + }); + + it('posts a worker error when the worker throws after accepting a layout request', async () => { + const postMessage = vi.spyOn(self, 'postMessage').mockImplementation(() => undefined as never); + originalOnMessage = (self as typeof self & { onmessage: ((event: MessageEvent) => void) | null }).onmessage; + vi.doMock('../layout/layout', () => ({ + getLayoutedElements: () => { + throw new Error('layout exploded'); + }, + })); + vi.resetModules(); + await import('../layoutWorker'); + + (self as typeof self & { onmessage: (event: MessageEvent) => void }).onmessage({ + data: { + type: 'layout', + id: 3, + nodes: [], + edges: [], + options: { + direction: 'LR', + verticalSpacing: 20, + horizontalSpacing: 400, + }, + }, + } as MessageEvent); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'layout-error', + id: 3, + message: 'layout exploded', + }); + }); + + it('stringifies non-error layout failures', async () => { + const postMessage = vi.spyOn(self, 'postMessage').mockImplementation(() => undefined as never); + originalOnMessage = (self as typeof self & { onmessage: ((event: MessageEvent) => void) | null }).onmessage; + vi.doMock('../layout/layout', () => ({ + getLayoutedElements: () => { + throw new Error('layout failed'); + }, + })); + vi.resetModules(); + await import('../layoutWorker'); + + (self as typeof self & { onmessage: (event: MessageEvent) => void }).onmessage({ + data: { + type: 'layout', + id: 4, + nodes: [], + edges: [], + options: { + direction: 'LR', + verticalSpacing: 20, + horizontalSpacing: 400, + }, + }, + } as MessageEvent); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'layout-error', + id: 4, + message: 'layout failed', + }); + }); + + it('stringifies a non-Error thrown value', async () => { + const postMessage = vi.spyOn(self, 'postMessage').mockImplementation(() => undefined as never); + originalOnMessage = (self as typeof self & { onmessage: ((event: MessageEvent) => void) | null }).onmessage; + vi.doMock('../layout/layout', () => ({ + getLayoutedElements: () => { + throw 'string error value' as never; + }, + })); + vi.resetModules(); + await import('../layoutWorker'); + + (self as typeof self & { onmessage: (event: MessageEvent) => void }).onmessage({ + data: { + type: 'layout', + id: 5, + nodes: [], + edges: [], + options: { + direction: 'LR', + verticalSpacing: 20, + horizontalSpacing: 400, + }, + }, + } as MessageEvent); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'layout-error', + id: 5, + message: 'string error value', + }); + }); +}); diff --git a/src/webview/components/GraphCanvas.tsx b/src/webview/components/GraphCanvas.tsx index 14b31f1..8b3c677 100644 --- a/src/webview/components/GraphCanvas.tsx +++ b/src/webview/components/GraphCanvas.tsx @@ -223,12 +223,10 @@ export function GraphCanvas({ payload }: { payload: WebviewPayload }) { if (cancelled) { return; } - const minY = minimumNodeY(layoutedWithDividers); if (minY === null) { return; } - const viewport = reactFlow.getViewport(); const alignedY = 28 - minY * viewport.zoom; reactFlow @@ -291,9 +289,12 @@ export function GraphCanvas({ payload }: { payload: WebviewPayload }) { let currentlySelected: string | null = null; if (selectedNode?.kind === 'queryKey' && selectedNode.label === queryKeyLabel) { - currentlySelected = candidateQueryNodes.some((candidate) => candidate.id === selectedNode.id) - ? selectedNode.id - : null; + for (const candidate of candidateQueryNodes) { + if (candidate.id === selectedNode.id) { + currentlySelected = selectedNode.id; + break; + } + } } if (currentlySelected) { @@ -326,7 +327,6 @@ export function GraphCanvas({ payload }: { payload: WebviewPayload }) { return a.id.localeCompare(b.id); }); - if (!targetNode) { return; } diff --git a/src/webview/components/__tests__/GraphCanvas.test.tsx b/src/webview/components/__tests__/GraphCanvas.test.tsx new file mode 100644 index 0000000..0e742b0 --- /dev/null +++ b/src/webview/components/__tests__/GraphCanvas.test.tsx @@ -0,0 +1,1483 @@ +/* @vitest-environment jsdom */ +import '../../../testing/setup'; +import type { ComponentType, ReactNode } from 'react'; +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { WebviewPayload } from '../../../shared/contracts'; +import { createGraph, createGraphEdge, createGraphNode, createScannedFile } from '../../../testing/fixtures'; + +type FlowProps = { + nodes?: Array<{ id: string; type: string; data: unknown }>; + edges?: Array<{ id: string }>; + nodeTypes?: Record>; + children?: ReactNode; + onNodeClick?: (event: unknown, node: unknown) => void; + onNodeDoubleClick?: (event: unknown, node: unknown) => void; + onPaneClick?: () => void; +}; + +const graphFlow = vi.hoisted(() => { + const state: { + props: FlowProps | undefined; + leftPanelProps: { + onSelectQueryKey?: (queryKeyLabel: string) => void; + onSelectRelatedFile?: (filePath: string) => void; + onVerticalSpacingChange?: (value: number) => void; + onHorizontalSpacingChange?: (value: number) => void; + setFilters?: (value: unknown) => void; + showProjectDividers?: boolean; + relatedFiles?: WebviewPayload['scannedFiles']; + }; + rejectLayout: boolean; + api: { + fitView: ReturnType; + getViewport: ReturnType; + setViewport: ReturnType; + }; + activeResizer: 'left' | 'right' | null; + deferredLayout: { + promise: Promise<{ nodes: unknown[]; edges: unknown[] }>; + resolve: (value: { nodes: unknown[]; edges: unknown[] }) => void; + } | null; + } = { + props: undefined, + leftPanelProps: {}, + rejectLayout: false, + api: { + fitView: vi.fn().mockResolvedValue(undefined), + getViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })), + setViewport: vi.fn().mockResolvedValue(undefined), + }, + activeResizer: null, + deferredLayout: null, + }; + + return state; +}); + +vi.mock('@xyflow/react', () => ({ + BackgroundVariant: { Lines: 'lines' }, + ConnectionLineType: { Bezier: 'bezier' }, + Position: { Left: 'left', Right: 'right' }, + ReactFlowProvider: ({ children }: { children: React.ReactNode }) => createElement('div', null, children), + ReactFlow: (props: FlowProps) => { + graphFlow.props = props; + const renderedNodes = (props.nodes ?? []).map((node) => { + const NodeComponent = props.nodeTypes?.[node.type]; + return NodeComponent ? createElement(NodeComponent, { key: node.id, data: node.data }) : null; + }); + + return createElement( + 'div', + { 'data-testid': 'reactflow' }, + ...renderedNodes, + ...(Array.isArray(props.children) ? props.children : [props.children]), + ); + }, + Handle: ({ id, type, position }: { id: string; type: string; position: string }) => + createElement('span', { 'data-handle': id, 'data-type': type, 'data-position': position }), + Controls: () => createElement('span', { 'data-testid': 'controls' }), + MiniMap: () => createElement('span', { 'data-testid': 'minimap' }), + Background: () => createElement('span', { 'data-testid': 'background' }), + useReactFlow: () => graphFlow.api, +})); + +const stableLayoutFn = async (nodes: TNode[], edges: TEdge[]) => { + if (graphFlow.rejectLayout) { + throw new Error('layout worker failed'); + } + if (graphFlow.deferredLayout) { + return graphFlow.deferredLayout.promise as Promise<{ nodes: TNode[]; edges: TEdge[] }>; + } + return { nodes, edges }; +}; + +vi.mock('../../layout/useDagreLayoutWorker', () => ({ + useDagreLayoutWorker: () => stableLayoutFn, +})); + +vi.mock('../../layout/useResizablePanels', () => ({ + useResizablePanels: () => ({ + shellRef: { current: null }, + shellStyle: { + '--rqv-left-width': '280px', + '--rqv-right-width': '300px', + }, + activeResizer: graphFlow.activeResizer, + startResize: () => () => undefined, + }), +})); + +vi.mock('../LeftPanel', () => ({ + LeftPanel: (props: typeof graphFlow.leftPanelProps) => { + graphFlow.leftPanelProps = props; + return createElement('aside', { 'data-testid': 'left-panel' }); + }, +})); + +afterEach(() => { + vi.useRealTimers(); + graphFlow.props = undefined; + graphFlow.leftPanelProps = {}; + graphFlow.rejectLayout = false; + graphFlow.activeResizer = null; + graphFlow.deferredLayout = null; + graphFlow.api.fitView.mockReset(); + graphFlow.api.fitView.mockResolvedValue(undefined); + graphFlow.api.getViewport.mockReset(); + graphFlow.api.getViewport.mockReturnValue({ x: 0, y: 0, zoom: 1 }); + graphFlow.api.setViewport.mockReset(); + graphFlow.api.setViewport.mockResolvedValue(undefined); +}); + +describe('webview/components/GraphCanvas', () => { + it('renders the graph shell and drives node interactions', async () => { + vi.useFakeTimers(); + graphFlow.activeResizer = 'left'; + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'file-web', + kind: 'file', + label: 'web/src/query.ts', + file: '/workspace/web/src/query.ts', + resolution: 'static', + metrics: { affectedKeys: 2, projectScope: 'web:apps/web' }, + }), + createGraphNode({ + id: 'action-web', + kind: 'action', + label: 'invalidateTodos', + file: '/workspace/web/src/query.ts', + resolution: 'static', + loc: { line: 10, column: 2 }, + metrics: { relation: 'invalidates', displayFile: 'web/src/query.ts', projectScope: 'web:apps/web' }, + }), + createGraphNode({ + id: 'query-web', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { + rootSegment: 'todo', + projectScope: 'web:apps/web', + affectedFiles: 2, + declaredFiles: 1, + declaredCallsites: 1, + }, + }), + createGraphNode({ + id: 'query-web-duplicate', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { + rootSegment: 'todo', + projectScope: 'web:apps/web', + affectedFiles: 4, + }, + }), + createGraphNode({ + id: 'file-api', + kind: 'file', + label: 'api/src/query.ts', + file: '/workspace/api/src/query.ts', + resolution: 'static', + metrics: { affectedKeys: 1, projectScope: 'api:packages/api' }, + }), + createGraphNode({ + id: 'action-api', + kind: 'action', + label: 'setUsers', + file: '/workspace/api/src/query.ts', + resolution: 'static', + loc: { line: 8, column: 1 }, + metrics: { relation: 'sets', displayFile: 'api/src/query.ts', projectScope: 'api:packages/api' }, + }), + createGraphNode({ + id: 'query-api', + kind: 'queryKey', + label: 'users', + resolution: 'static', + metrics: { rootSegment: 'users', projectScope: 'api:packages/api', affectedFiles: 1 }, + }), + ], + edges: [ + createGraphEdge({ + id: 'file-web-action-web', + source: 'file-web', + target: 'action-web', + relation: 'invalidates', + }), + createGraphEdge({ + id: 'action-web-query-web', + source: 'action-web', + target: 'query-web', + relation: 'invalidates', + }), + createGraphEdge({ id: 'file-api-action-api', source: 'file-api', target: 'action-api', relation: 'sets' }), + createGraphEdge({ id: 'action-api-query-api', source: 'action-api', target: 'query-api', relation: 'sets' }), + ], + }), + scannedFiles: [ + createScannedFile({ workspace: 'web', path: 'web/src/query.ts', depth: 1 }), + createScannedFile({ workspace: 'api', path: 'api/src/query.ts', depth: 1 }), + ], + scopeLabel: 'All files', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + const { GraphCanvas } = await import('../GraphCanvas'); + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + const fitViewResult = graphFlow.api.fitView.mock.results[graphFlow.api.fitView.mock.results.length - 1]?.value; + await fitViewResult; + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.querySelector('[data-testid="reactflow"]')).toBeTruthy(); + expect(container.textContent).toContain('todo'); + expect(container.textContent).toContain('web/apps/web'); + + const fileNode = payload.graph.nodes.find((node) => node.id === 'file-web'); + if (!fileNode) { + throw new Error('Missing file node'); + } + + await act(async () => { + graphFlow.props?.onNodeClick?.({}, { id: 'divider:web' }); + graphFlow.props?.onNodeClick?.({}, fileNode); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('Open in code'); + expect(container.textContent).toContain('Callsites'); + + const openButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Open in code'), + ); + openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + const firstCallsiteButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('invalidateTodos'), + ); + firstCallsiteButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + const firstFileButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('web/src/query.ts'), + ); + firstFileButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + await act(async () => { + graphFlow.leftPanelProps.onSelectRelatedFile?.('missing.ts'); + graphFlow.leftPanelProps.onSelectRelatedFile?.('web/src/query.ts'); + graphFlow.leftPanelProps.onSelectQueryKey?.('missing'); + graphFlow.leftPanelProps.onSelectQueryKey?.('todo'); + graphFlow.leftPanelProps.onVerticalSpacingChange?.(999); + graphFlow.leftPanelProps.onHorizontalSpacingChange?.(1); + graphFlow.leftPanelProps.setFilters?.((current: unknown) => current); + graphFlow.props?.onNodeDoubleClick?.({}, fileNode); + graphFlow.props?.onNodeDoubleClick?.({}, { id: 'divider:web' }); + graphFlow.props?.onNodeDoubleClick?.({}, { id: 'missing' }); + graphFlow.props?.onPaneClick?.(); + await Promise.resolve(); + }); + + await act(async () => { + graphFlow.leftPanelProps.onSelectQueryKey?.('todo'); + await Promise.resolve(); + }); + await act(async () => { + graphFlow.leftPanelProps.onSelectQueryKey?.('todo'); + await Promise.resolve(); + }); + + root.unmount(); + }); + + it('falls back when the layout worker rejects and skips viewport alignment for empty graphs', async () => { + vi.useFakeTimers(); + graphFlow.rejectLayout = true; + const payload = { + graph: createGraph({ nodes: [], edges: [] }), + scannedFiles: [], + scopeLabel: 'Empty', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(graphFlow.api.fitView).toHaveBeenCalled(); + root.unmount(); + }); + + it('ignores viewport alignment after unmount', async () => { + vi.useFakeTimers(); + let resolveFitView: () => void = () => undefined; + graphFlow.api.fitView.mockReturnValueOnce( + new Promise((resolve) => { + resolveFitView = () => resolve(); + }), + ); + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'file', + kind: 'file', + label: 'src/query.ts', + file: '/workspace/src/query.ts', + resolution: 'static', + metrics: { affectedKeys: 1, projectScope: 'web:apps/web' }, + }), + createGraphNode({ + id: 'action', + kind: 'action', + label: 'invalidate', + file: '/workspace/src/query.ts', + resolution: 'static', + metrics: { relation: 'invalidates', displayFile: 'src/query.ts', projectScope: 'app:.' }, + }), + createGraphNode({ + id: 'query', + kind: 'queryKey', + label: 'todos', + resolution: 'static', + metrics: { rootSegment: 'todos', projectScope: 'app:.', affectedFiles: 1 }, + }), + ], + edges: [ + createGraphEdge({ id: 'file-action', source: 'file', target: 'action', relation: 'invalidates' }), + createGraphEdge({ id: 'action-query', source: 'action', target: 'query', relation: 'invalidates' }), + ], + }), + scannedFiles: [], + scopeLabel: 'One file', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + root.unmount(); + + await act(async () => { + resolveFitView(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(graphFlow.api.setViewport).not.toHaveBeenCalled(); + }); + + it('ignores a layout result that resolves after unmount', async () => { + vi.useFakeTimers(); + let resolveLayout: (value: { nodes: unknown[]; edges: unknown[] }) => void = () => undefined; + graphFlow.deferredLayout = { + promise: new Promise((resolve) => { + resolveLayout = resolve; + }), + resolve: resolveLayout, + }; + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'file', + kind: 'file', + label: 'src/query.ts', + file: '/workspace/src/query.ts', + resolution: 'static', + }), + ], + edges: [], + }), + scannedFiles: [], + scopeLabel: 'One file', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(200); + await Promise.resolve(); + }); + + root.unmount(); + + await act(async () => { + resolveLayout({ nodes: [], edges: [] }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(graphFlow.api.fitView).not.toHaveBeenCalled(); + }); + + it('keeps running when fit view rejects', async () => { + vi.useFakeTimers(); + graphFlow.api.fitView.mockRejectedValueOnce(new Error('fit view failed')); + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'file', + kind: 'file', + label: 'src/query.ts', + file: '/workspace/src/query.ts', + resolution: 'static', + }), + ], + edges: [], + }), + scannedFiles: [], + scopeLabel: 'One file', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(200); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(graphFlow.api.setViewport).not.toHaveBeenCalled(); + root.unmount(); + }); + + it('selects duplicate query keys by deterministic fallback order without rendered layout', async () => { + vi.useFakeTimers(); + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'query-b', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { affectedFiles: 1 }, + }), + createGraphNode({ + id: 'query-a', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + }), + ], + edges: [], + }), + scannedFiles: [], + scopeLabel: 'Queries', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + graphFlow.leftPanelProps.onSelectQueryKey?.('todo'); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('todo is referenced by 0 callsites in 0 files.'); + root.unmount(); + }); + + it('clears selection on pane click and ignores missing targets for selection and double clicks', async () => { + vi.useFakeTimers(); + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'file-web', + kind: 'file', + label: 'web/src/query.ts', + file: '/workspace/web/src/query.ts', + resolution: 'static', + }), + createGraphNode({ + id: 'query-web-a', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 1 }, + }), + createGraphNode({ + id: 'query-web-b', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 5 }, + }), + ], + edges: [], + }), + scannedFiles: [], + scopeLabel: 'Queries', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(200); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + graphFlow.props?.onNodeClick?.({}, { id: 'divider:web' }); + graphFlow.props?.onNodeClick?.({}, { id: 'query-web-b' }); + await Promise.resolve(); + }); + + await act(async () => { + graphFlow.leftPanelProps.onSelectQueryKey?.('todo'); + graphFlow.props?.onPaneClick?.(); + graphFlow.props?.onNodeDoubleClick?.({}, { id: 'query-web-b' }); + graphFlow.props?.onNodeDoubleClick?.({}, { id: 'missing' }); + graphFlow.leftPanelProps.onSelectQueryKey?.('missing'); + graphFlow.leftPanelProps.onSelectRelatedFile?.('missing.ts'); + await Promise.resolve(); + }); + + expect(container.textContent).not.toContain('todo is referenced by'); + root.unmount(); + }); + + it('calls setViewport after fitView resolves with a non-null minY', async () => { + vi.useFakeTimers(); + // Use a manually controlled fitView promise so we can drain the .then chain precisely. + let resolveFitView!: (value: unknown) => void; + const fitViewPromise = new Promise((resolve) => { + resolveFitView = resolve; + }); + graphFlow.api.fitView.mockReturnValueOnce(fitViewPromise); + graphFlow.api.getViewport.mockReturnValue({ x: 10, y: 20, zoom: 1 }); + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'file', + kind: 'file', + label: 'src/query.ts', + file: '/workspace/src/query.ts', + resolution: 'static', + metrics: { affectedKeys: 1, projectScope: 'app:.' }, + }), + createGraphNode({ + id: 'action', + kind: 'action', + label: 'invalidate', + file: '/workspace/src/query.ts', + resolution: 'static', + metrics: { relation: 'invalidates', displayFile: 'src/query.ts', projectScope: 'app:.' }, + }), + createGraphNode({ + id: 'query', + kind: 'queryKey', + label: 'todos', + resolution: 'static', + metrics: { rootSegment: 'todos', projectScope: 'app:.', affectedFiles: 1 }, + }), + ], + edges: [ + createGraphEdge({ id: 'file-action', source: 'file', target: 'action', relation: 'invalidates' }), + createGraphEdge({ id: 'action-query', source: 'action', target: 'query', relation: 'invalidates' }), + ], + }), + scannedFiles: [], + scopeLabel: 'One file', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + // Fire the layout timer so runLayout runs and calls fitView. + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // fitView should have been called with the layout result. + expect(graphFlow.api.fitView).toHaveBeenCalled(); + // getViewport should NOT have been called yet (fitView not resolved). + expect(graphFlow.api.getViewport).not.toHaveBeenCalled(); + + // Now resolve fitView, which triggers the .then(callback) that calls setViewport. + await act(async () => { + resolveFitView(undefined); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // setViewport should have been called because minY is not null (nodes exist). + expect(graphFlow.api.setViewport).toHaveBeenCalled(); + + root.unmount(); + }); + + it('renders a single-project graph without project dividers and keeps the left panel scoped', async () => { + vi.useFakeTimers(); + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'file-web', + kind: 'file', + label: 'web/src/query.ts', + file: '/workspace/web/src/query.ts', + resolution: 'static', + metrics: { affectedKeys: 1, projectScope: 'web:apps/web' }, + }), + createGraphNode({ + id: 'action-web', + kind: 'action', + label: 'invalidateTodos', + file: '/workspace/web/src/query.ts', + resolution: 'static', + loc: { line: 2, column: 1 }, + metrics: { relation: 'invalidates', displayFile: 'web/src/query.ts', projectScope: 'web:apps/web' }, + }), + createGraphNode({ + id: 'query-web', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 1 }, + }), + ], + edges: [ + createGraphEdge({ + id: 'file-web-action-web', + source: 'file-web', + target: 'action-web', + relation: 'invalidates', + }), + createGraphEdge({ + id: 'action-web-query-web', + source: 'action-web', + target: 'query-web', + relation: 'invalidates', + }), + ], + }), + scannedFiles: [createScannedFile({ workspace: 'web', path: 'web/src/query.ts', depth: 1 })], + scopeLabel: 'Web', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(200); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(graphFlow.leftPanelProps.showProjectDividers).toBe(false); + expect(graphFlow.leftPanelProps.relatedFiles).toEqual([ + expect.objectContaining({ path: 'web/src/query.ts', workspace: 'web' }), + ]); + expect(container.textContent).toContain('todo'); + root.unmount(); + }); + + it('keeps unmatched render edges and swallows viewport alignment rejections', async () => { + vi.useFakeTimers(); + graphFlow.api.setViewport.mockRejectedValueOnce(new Error('viewport failed')); + + let resolveLayout: (value: { nodes: unknown[]; edges: unknown[] }) => void = () => undefined; + graphFlow.deferredLayout = { + promise: new Promise((resolve) => { + resolveLayout = resolve; + }), + resolve: resolveLayout, + }; + + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'file', + kind: 'file', + label: 'src/file.ts', + file: '/workspace/src/file.ts', + resolution: 'static', + metrics: { projectScope: 'web:apps/web' }, + }), + ], + edges: [], + }), + scannedFiles: [], + scopeLabel: 'One file', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + resolveLayout({ + nodes: [ + { + id: 'file', + type: 'rqvNode', + data: { + node: payload.graph.nodes[0], + title: 'src/file.ts', + subtitle: 'file', + dim: false, + highlighted: false, + selected: false, + }, + position: { x: 0, y: 0 }, + width: 340, + height: 173, + }, + ], + edges: [ + { + id: 'render-only-edge', + source: 'file', + target: 'file', + type: 'smoothstep', + data: { relation: 'invalidates', dim: false, highlighted: false, laneOffset: 0 }, + }, + ], + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(graphFlow.props?.edges?.some((edge) => edge.id === 'render-only-edge')).toBe(true); + expect(graphFlow.api.setViewport).not.toHaveBeenCalled(); + root.unmount(); + }); + + it('selects query keys from rendered layout positions and falls back to ids', async () => { + vi.useFakeTimers(); + + let resolveLayout: (value: { nodes: unknown[]; edges: unknown[] }) => void = () => undefined; + graphFlow.deferredLayout = { + promise: new Promise((resolve) => { + resolveLayout = resolve; + }), + resolve: resolveLayout, + }; + + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'query-a', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 1 }, + }), + createGraphNode({ + id: 'query-b', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 1 }, + }), + ], + edges: [], + }), + scannedFiles: [], + scopeLabel: 'Queries', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + resolveLayout({ + nodes: [ + { + id: 'query-a', + type: 'rqvNode', + data: { + node: payload.graph.nodes[0], + title: 'todo', + subtitle: 'queryKey', + dim: false, + highlighted: false, + selected: false, + }, + position: { x: 100, y: 40 }, + width: 340, + height: 173, + }, + { + id: 'query-b', + type: 'rqvNode', + data: { + node: payload.graph.nodes[1], + title: 'todo', + subtitle: 'queryKey', + dim: false, + highlighted: false, + selected: false, + }, + position: { x: 40, y: 10 }, + width: 340, + height: 173, + }, + ], + edges: [], + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + graphFlow.leftPanelProps.onSelectQueryKey?.('todo'); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('todo is referenced by 0 callsites in 0 files.'); + + const originalSort = Array.prototype.sort; + const sortSpy = vi.spyOn(Array.prototype, 'sort'); + sortSpy.mockImplementation(function sortQueryNodes( + this: unknown[], + compareFn?: (a: unknown, b: unknown) => number, + ) { + if ( + this.length === 2 && + this.every((value) => typeof value === 'object' && value !== null && 'kind' in value && 'label' in value) + ) { + return [] as unknown as unknown[]; + } + + return originalSort.call(this, compareFn as never); + }); + + await act(async () => { + graphFlow.leftPanelProps.onSelectQueryKey?.('todo'); + await Promise.resolve(); + }); + + sortSpy.mockRestore(); + root.unmount(); + }); + + it('updates query selection using missing rendered nodes and no-target fallback', async () => { + vi.useFakeTimers(); + + let resolveLayout: (value: { nodes: unknown[]; edges: unknown[] }) => void = () => undefined; + graphFlow.deferredLayout = { + promise: new Promise((resolve) => { + resolveLayout = resolve; + }), + resolve: resolveLayout, + }; + + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'query-b', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 1 }, + }), + createGraphNode({ + id: 'query-a', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 1 }, + }), + ], + edges: [], + }), + scannedFiles: [], + scopeLabel: 'Queries', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + resolveLayout({ + nodes: [ + { + id: 'query-b', + type: 'rqvNode', + data: { + node: payload.graph.nodes[0], + title: 'todo', + subtitle: 'queryKey', + dim: false, + highlighted: false, + selected: false, + }, + position: { x: 10, y: 10 }, + width: 340, + height: 173, + }, + ], + edges: [], + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + graphFlow.leftPanelProps.onSelectQueryKey?.('todo'); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('todo is referenced by 0 callsites in 0 files.'); + root.unmount(); + }); + + it('skips viewport alignment when the layout result has no visible nodes', async () => { + vi.useFakeTimers(); + let resolveFitView: () => void = () => undefined; + graphFlow.api.fitView.mockReturnValueOnce( + new Promise((resolve) => { + resolveFitView = () => resolve(); + }), + ); + + const payload = { + graph: createGraph({ nodes: [], edges: [] }), + scannedFiles: [], + scopeLabel: 'Empty', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + resolveFitView(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(graphFlow.api.setViewport).not.toHaveBeenCalled(); + root.unmount(); + }); + + it('keeps viewport alignment disabled when fit view resolves after an empty layout', async () => { + vi.useFakeTimers(); + graphFlow.api.fitView.mockResolvedValueOnce(undefined); + + const payload = { + graph: createGraph({ nodes: [], edges: [] }), + scannedFiles: [], + scopeLabel: 'Empty', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(graphFlow.api.setViewport).not.toHaveBeenCalled(); + root.unmount(); + }); + + it('keeps query ordering stable when rendered positions and impact tie', async () => { + vi.useFakeTimers(); + + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'query-a', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 1 }, + }), + createGraphNode({ + id: 'query-b', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 1 }, + }), + ], + edges: [], + }), + scannedFiles: [], + scopeLabel: 'Queries', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + graphFlow.leftPanelProps.onSelectQueryKey?.('todo'); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('todo is referenced by 0 callsites in 0 files.'); + root.unmount(); + }); + + it('selects the rendered query key when its sibling is missing from the render graph', async () => { + vi.useFakeTimers(); + + let resolveLayout: (value: { nodes: unknown[]; edges: unknown[] }) => void = () => undefined; + graphFlow.deferredLayout = { + promise: new Promise((resolve) => { + resolveLayout = resolve; + }), + resolve: resolveLayout, + }; + + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'query-b', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 1 }, + }), + createGraphNode({ + id: 'query-a', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 1 }, + }), + ], + edges: [], + }), + scannedFiles: [], + scopeLabel: 'Queries', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + resolveLayout({ + nodes: [ + { + id: 'query-b', + type: 'rqvNode', + data: { + node: payload.graph.nodes[0], + title: 'todo', + subtitle: 'queryKey', + dim: false, + highlighted: false, + selected: false, + }, + position: { x: 10, y: 10 }, + width: 340, + height: 173, + }, + ], + edges: [], + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + graphFlow.leftPanelProps.onSelectQueryKey?.('todo'); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('todo is referenced by 0 callsites in 0 files.'); + root.unmount(); + }); + + it('sorts query keys by id when rendered positions and impact tie', async () => { + vi.useFakeTimers(); + + let resolveLayout: (value: { nodes: unknown[]; edges: unknown[] }) => void = () => undefined; + graphFlow.deferredLayout = { + promise: new Promise((resolve) => { + resolveLayout = resolve; + }), + resolve: resolveLayout, + }; + + const payload = { + graph: createGraph({ + nodes: [ + createGraphNode({ + id: 'query-b', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 1 }, + }), + createGraphNode({ + id: 'query-a', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { rootSegment: 'todo', projectScope: 'web:apps/web', affectedFiles: 1 }, + }), + ], + edges: [], + }), + scannedFiles: [], + scopeLabel: 'Queries', + layout: { + direction: 'LR', + engine: 'dagre', + verticalSpacing: 30, + horizontalSpacing: 500, + }, + } satisfies WebviewPayload; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const { GraphCanvas } = await import('../GraphCanvas'); + + await act(async () => { + root.render(createElement(GraphCanvas, { payload })); + await Promise.resolve(); + }); + + await act(async () => { + resolveLayout({ + nodes: [ + { + id: 'query-b', + type: 'rqvNode', + data: { + node: payload.graph.nodes[0], + title: 'todo', + subtitle: 'queryKey', + dim: false, + highlighted: false, + selected: false, + }, + position: { x: 10, y: 10 }, + width: 340, + height: 173, + }, + { + id: 'query-a', + type: 'rqvNode', + data: { + node: payload.graph.nodes[1], + title: 'todo', + subtitle: 'queryKey', + dim: false, + highlighted: false, + selected: false, + }, + position: { x: 10, y: 10 }, + width: 340, + height: 173, + }, + ], + edges: [], + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + graphFlow.leftPanelProps.onSelectQueryKey?.('todo'); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('todo is referenced by 0 callsites in 0 files.'); + root.unmount(); + }); +}); diff --git a/src/webview/components/__tests__/LeftPanel.test.tsx b/src/webview/components/__tests__/LeftPanel.test.tsx new file mode 100644 index 0000000..0ec638c --- /dev/null +++ b/src/webview/components/__tests__/LeftPanel.test.tsx @@ -0,0 +1,385 @@ +/* @vitest-environment jsdom */ +import '../../../testing/setup'; +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it, vi } from 'vitest'; + +import { LeftPanel } from '../LeftPanel'; +import { defaultFilters } from '../../utils/defaultFilters'; + +function changeInputValue(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +describe('webview/components/LeftPanel', () => { + it('applies drafts and forwards selection callbacks', async () => { + const setFilters = vi.fn((updater: unknown) => { + if (typeof updater === 'function') { + updater(defaultFilters); + } + }); + const onVerticalSpacingChange = vi.fn(); + const onHorizontalSpacingChange = vi.fn(); + const onSelectQueryKey = vi.fn(); + const onSelectRelatedFile = vi.fn(); + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + createElement(LeftPanel, { + filters: { + ...defaultFilters, + fileQuery: '', + search: '', + }, + setFilters, + queryKeys: ['todos', 'users'], + selectedQueryKey: 'todos', + onSelectQueryKey, + relatedFiles: [ + { path: 'api/src/query.ts', workspace: 'api', depth: 1, impact: 2 }, + { path: 'web/src/query.ts', workspace: 'web', depth: 1, impact: 3 }, + { path: 'web/src/query.ts', workspace: 'web', depth: 1, impact: 99 }, + { path: 'web/src/nested/file.ts', workspace: 'web', depth: 2, impact: 1 }, + ], + verticalSpacing: 30, + onVerticalSpacingChange, + horizontalSpacing: 500, + onHorizontalSpacingChange, + showProjectDividers: true, + selectedRelatedFilePath: 'web/src/query.ts', + onSelectRelatedFile, + }), + ); + }); + + expect(container.textContent).toContain('todos'); + + const queryKeyButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'users', + ); + queryKeyButton?.click(); + expect(onSelectQueryKey).toHaveBeenCalledWith('users'); + + const fileButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.getAttribute('title') === 'web/src/nested/file.ts', + ); + fileButton?.click(); + expect(onSelectRelatedFile).toHaveBeenCalledWith('web/src/nested/file.ts'); + + await act(async () => { + const fileQueryInput = container.querySelector('input[placeholder="Filter files"]') as HTMLInputElement | null; + expect(fileQueryInput).toBeTruthy(); + if (fileQueryInput) { + fileQueryInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + changeInputValue(fileQueryInput, 'web'); + fileQueryInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + fileQueryInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + } + + const searchInput = container.querySelector('input[placeholder="Search labels"]') as HTMLInputElement | null; + expect(searchInput).toBeTruthy(); + if (searchInput) { + changeInputValue(searchInput, 'todo'); + searchInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + searchInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + } + + const applyFiltersButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Apply Filters'), + ); + applyFiltersButton?.click(); + + const invalidatesCheckbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement | null; + if (invalidatesCheckbox) { + invalidatesCheckbox.click(); + } + const applyOperationsButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Apply Operations'), + ); + applyOperationsButton?.click(); + + const verticalInput = container.querySelector('#rqv-vertical-spacing') as HTMLInputElement | null; + const horizontalInput = container.querySelector('#rqv-horizontal-spacing') as HTMLInputElement | null; + if (verticalInput) { + changeInputValue(verticalInput, '44'); + verticalInput.dispatchEvent(new Event('change', { bubbles: true })); + } + if (horizontalInput) { + changeInputValue(horizontalInput, '750'); + horizontalInput.dispatchEvent(new Event('change', { bubbles: true })); + } + + const applyLayoutButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Apply Layout'), + ); + applyLayoutButton?.click(); + }); + + expect(setFilters).toHaveBeenCalled(); + + const operationsHeader = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Operations'), + ); + const filtersHeader = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Filter'), + ); + const layoutHeader = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Layout'), + ); + await act(async () => { + operationsHeader?.click(); + filtersHeader?.click(); + layoutHeader?.click(); + await Promise.resolve(); + }); + + root.unmount(); + }); + + it('collapses sections and ignores no-op apply actions', async () => { + const setFilters = vi.fn(); + const onVerticalSpacingChange = vi.fn(); + const onHorizontalSpacingChange = vi.fn(); + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + createElement(LeftPanel, { + filters: { + ...defaultFilters, + fileQuery: '', + search: '', + }, + setFilters, + queryKeys: ['todos'], + selectedQueryKey: null, + onSelectQueryKey: vi.fn(), + relatedFiles: [{ path: 'web/src/query.ts', workspace: 'web', depth: 1, impact: 1 }], + verticalSpacing: 30, + onVerticalSpacingChange, + horizontalSpacing: 500, + onHorizontalSpacingChange, + showProjectDividers: false, + selectedRelatedFilePath: null, + onSelectRelatedFile: vi.fn(), + }), + ); + }); + + const applyOperationsButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Apply Operations'), + ); + const applyFiltersButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Apply Filters'), + ); + const applyLayoutButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Apply Layout'), + ); + applyOperationsButton?.click(); + applyFiltersButton?.click(); + applyLayoutButton?.click(); + + expect(setFilters).not.toHaveBeenCalled(); + expect(onVerticalSpacingChange).not.toHaveBeenCalled(); + expect(onHorizontalSpacingChange).not.toHaveBeenCalled(); + + let queryKeysHeader = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Current Query Keys'), + ); + await act(async () => { + queryKeysHeader?.click(); + await Promise.resolve(); + }); + + expect(Array.from(container.querySelectorAll('button')).some((button) => button.textContent === 'todos')).toBe( + false, + ); + expect( + Array.from(container.querySelectorAll('button')).some( + (button) => button.getAttribute('title') === 'web/src/query.ts', + ), + ).toBe(true); + + queryKeysHeader = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Current Query Keys'), + ); + const relatedFilesHeader = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Related Files'), + ); + await act(async () => { + queryKeysHeader?.click(); + relatedFilesHeader?.click(); + await Promise.resolve(); + }); + + expect( + Array.from(container.querySelectorAll('button')).some( + (button) => button.getAttribute('title') === 'web/src/query.ts', + ), + ).toBe(false); + + root.unmount(); + }); + + it('syncs drafts from props and prunes collapsed directories when filters change', async () => { + const setFilters = vi.fn(); + const onVerticalSpacingChange = vi.fn(); + const onHorizontalSpacingChange = vi.fn(); + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + const initialProps = { + filters: { + ...defaultFilters, + fileQuery: '', + search: '', + }, + setFilters, + queryKeys: ['todos', 'users'], + selectedQueryKey: null, + onSelectQueryKey: vi.fn(), + relatedFiles: [ + { path: 'api/src/query.ts', workspace: 'api', depth: 1, impact: 2 }, + { path: 'web/src/query.ts', workspace: 'web', depth: 1, impact: 3 }, + { path: 'web/src/nested/file.ts', workspace: 'web', depth: 2, impact: 1 }, + ], + verticalSpacing: 30, + onVerticalSpacingChange, + horizontalSpacing: 500, + onHorizontalSpacingChange, + showProjectDividers: true, + selectedRelatedFilePath: null, + onSelectRelatedFile: vi.fn(), + }; + + await act(async () => { + root.render(createElement(LeftPanel, initialProps)); + }); + + const webDirectoryButton = Array.from(container.querySelectorAll('button')).find( + (button) => !button.getAttribute('title') && button.textContent?.includes('web'), + ); + await act(async () => { + webDirectoryButton?.click(); + await Promise.resolve(); + }); + + await act(async () => { + root.render( + createElement(LeftPanel, { + ...initialProps, + filters: { + ...defaultFilters, + fileQuery: 'web', + search: 'todo', + }, + relatedFiles: [ + { path: 'web/src/query.ts', workspace: 'web', depth: 1, impact: 3 }, + { path: 'web/src/nested/file.ts', workspace: 'web', depth: 2, impact: 1 }, + ], + verticalSpacing: 44, + horizontalSpacing: 750, + }), + ); + }); + + await act(async () => { + root.render( + createElement(LeftPanel, { + ...initialProps, + filters: { + ...defaultFilters, + fileQuery: 'api', + search: 'todo', + }, + relatedFiles: [{ path: 'api/src/query.ts', workspace: 'api', depth: 1, impact: 2 }], + verticalSpacing: 44, + horizontalSpacing: 750, + }), + ); + }); + + const fileQueryInput = container.querySelector('input[placeholder="Filter files"]') as HTMLInputElement | null; + const searchInput = container.querySelector('input[placeholder="Search labels"]') as HTMLInputElement | null; + const verticalInput = container.querySelector('#rqv-vertical-spacing') as HTMLInputElement | null; + const horizontalInput = container.querySelector('#rqv-horizontal-spacing') as HTMLInputElement | null; + + expect(fileQueryInput?.value).toBe('api'); + expect(searchInput?.value).toBe('todo'); + expect(verticalInput?.value).toBe('44'); + expect(horizontalInput?.value).toBe('750'); + + root.unmount(); + expect(setFilters).not.toHaveBeenCalled(); + expect(onVerticalSpacingChange).not.toHaveBeenCalled(); + expect(onHorizontalSpacingChange).not.toHaveBeenCalled(); + }); + + it('applyDraftLayout returns early when there are no pending layout changes', async () => { + // The "Apply Layout" button is disabled when drafts match props. Clicking a disabled + //