diff --git a/package-lock.json b/package-lock.json index 5a4b59e..61ee80a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,13 @@ "version": "0.1.2", "license": "MIT", "dependencies": { - "openai": "^4.73.0", + "openai": "^4.104.0", "web-tree-sitter": "^0.26.7" }, "devDependencies": { "@types/node": "^20.0.0", "@types/vscode": "^1.85.0", - "esbuild": "^0.24.0", + "esbuild": "^0.28.0", "typescript": "^5.8.0" }, "engines": { @@ -23,9 +23,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -40,9 +40,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -57,9 +57,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -74,9 +74,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -91,9 +91,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -108,9 +108,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -125,9 +125,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -142,9 +142,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -159,9 +159,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -176,9 +176,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -193,9 +193,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -210,9 +210,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -227,9 +227,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -244,9 +244,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -261,9 +261,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -278,9 +278,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -295,9 +295,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -312,9 +312,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -329,9 +329,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -346,9 +346,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -363,9 +363,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -379,10 +379,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -397,9 +414,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -414,9 +431,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -431,9 +448,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -597,9 +614,9 @@ } }, "node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -610,31 +627,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/event-target-shim": { diff --git a/package.json b/package.json index d1f69a5..f4b0250 100644 --- a/package.json +++ b/package.json @@ -196,20 +196,20 @@ "build:webview": "cd webview && npm run build", "build:dashboard": "cd dashboard && npm run build && rm -rf ../dashboard-dist && cp -r dist ../dashboard-dist", "test": "npm run test:scanner", - "test:scanner": "tsc -p tsconfig.scanner-tests.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js", + "test:scanner": "tsc -p tsconfig.scanner-tests.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js", "calibrate-detectors": "tsc -p tsconfig.scanner-tests.json && node dist-test/test/waste-calibration.js", "watch:ext": "node esbuild.mjs --watch", "watch:webview": "cd webview && npm run build -- --watch", "package": "npm run build:webview && node esbuild.mjs --release && npx @vscode/vsce package --no-dependencies --allow-missing-repository" }, "dependencies": { - "openai": "^4.73.0", + "openai": "^4.104.0", "web-tree-sitter": "^0.26.7" }, "devDependencies": { "@types/node": "^20.0.0", "@types/vscode": "^1.85.0", - "esbuild": "^0.24.0", + "esbuild": "^0.28.0", "typescript": "^5.8.0" } } diff --git a/src/cli/scan.ts b/src/cli/scan.ts index e32f4b1..8e6bc93 100644 --- a/src/cli/scan.ts +++ b/src/cli/scan.ts @@ -152,7 +152,7 @@ async function runContextFormat(options: CliOptions, access: Awaited void): void { - try { - fn(); - console.log(`PASS ${name}`); - } catch (error) { - console.error(`FAIL ${name}`); - throw error; - } +const pendingTests: Array<() => Promise> = []; + +function run(name: string, fn: () => void | Promise): void { + pendingTests.push(async () => { + try { + await fn(); + console.log(`PASS ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + throw error; + } + }); } -function withTempWorkspace(files: Record, fn: (workspaceDir: string) => void): void { +async function withTempWorkspace( + files: Record, + fn: (workspaceDir: string) => void | Promise +): Promise { const originalCwd = process.cwd(); const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "compression-test-")); @@ -29,15 +36,15 @@ function withTempWorkspace(files: Record, fn: (workspaceDir: str fs.writeFileSync(absolutePath, content, "utf8"); } process.chdir(workspaceDir); - fn(workspaceDir); + await fn(workspaceDir); } finally { process.chdir(originalCwd); fs.rmSync(workspaceDir, { recursive: true, force: true }); } } -run("compressClusters returns compact summaries, normalized findings, and bounded snippets", () => { - withTempWorkspace( +run("compressClusters returns compact summaries, normalized findings, and bounded snippets", async () => { + await withTempWorkspace( { "src/chat/loop.ts": [ "export async function loop(items) {", @@ -63,7 +70,7 @@ run("compressClusters returns compact summaries, normalized findings, and bounde "}, 1000);", ].join("\n"), }, - () => { + async () => { const snapshot = buildSnapshot({ apiCalls: [ { @@ -136,7 +143,7 @@ run("compressClusters returns compact summaries, normalized findings, and bounde }); const clusters = buildReviewClusters(scoreRepoIntelligence(snapshot)); - const compressed = compressClusters(clusters, snapshot); + const compressed = await compressClusters(clusters, snapshot); assert.ok(compressed.length >= 1); const loopCluster = compressed.find((cluster) => cluster.primarySummary.filePath === "src/chat/loop.ts"); @@ -179,8 +186,8 @@ run("compressClusters returns compact summaries, normalized findings, and bounde ); }); -run("compressClusters dedupes repeated same-file findings and collapses repeated titles in export output", () => { - withTempWorkspace( +run("compressClusters dedupes repeated same-file findings and collapses repeated titles in export output", async () => { + await withTempWorkspace( { "src/chat/cache.ts": [ "export async function loadModel() {", @@ -189,7 +196,7 @@ run("compressClusters dedupes repeated same-file findings and collapses repeated "}", ].join("\n"), }, - () => { + async () => { const snapshot = buildSnapshot({ apiCalls: [ { @@ -243,7 +250,7 @@ run("compressClusters dedupes repeated same-file findings and collapses repeated ], }); - const compressed = compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); + const compressed = await compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); const cluster = compressed.find((entry) => entry.primarySummary.filePath === "src/chat/cache.ts"); assert.ok(cluster); assert.equal(cluster?.findings.filter((finding) => finding.title === "Missing caching").length, 1); @@ -252,8 +259,8 @@ run("compressClusters dedupes repeated same-file findings and collapses repeated ); }); -run("compressClusters uses softer evidence language for weak test-derived files", () => { - withTempWorkspace( +run("compressClusters uses softer evidence language for weak test-derived files", async () => { + await withTempWorkspace( { "src/test/providers.test.ts": [ "for (const provider of ALL_PROVIDERS) {", @@ -261,7 +268,7 @@ run("compressClusters uses softer evidence language for weak test-derived files" "}", ].join("\n"), }, - () => { + async () => { const snapshot = buildSnapshot({ apiCalls: [ { @@ -276,7 +283,7 @@ run("compressClusters uses softer evidence language for weak test-derived files" findings: [], }); - const compressed = compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); + const compressed = await compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); const testCluster = compressed.find((cluster) => cluster.primarySummary.filePath === "src/test/providers.test.ts"); assert.ok(testCluster); assert.ok(testCluster?.primarySummary.description.startsWith("This test file")); @@ -289,8 +296,8 @@ run("compressClusters uses softer evidence language for weak test-derived files" ); }); -run("compressClusters uses neutral snippet labels for test helper cache-like code", () => { - withTempWorkspace( +run("compressClusters uses neutral snippet labels for test helper cache-like code", async () => { + await withTempWorkspace( { "src/test/providers.test.ts": [ "function findProvider(id) {", @@ -298,7 +305,7 @@ run("compressClusters uses neutral snippet labels for test helper cache-like cod "}", ].join("\n"), }, - () => { + async () => { const snapshot = buildSnapshot({ apiCalls: [ { @@ -324,7 +331,7 @@ run("compressClusters uses neutral snippet labels for test helper cache-like cod ], }); - const compressed = compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); + const compressed = await compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); const testCluster = compressed.find((cluster) => cluster.primarySummary.filePath === "src/test/providers.test.ts"); assert.ok(testCluster); assert.ok(testCluster?.snippets.some((snippet) => snippet.label === "Relevant test helper context")); @@ -333,8 +340,8 @@ run("compressClusters uses neutral snippet labels for test helper cache-like cod ); }); -run("compressClusters handles files with only findings, null providers, and missing snippet files", () => { - withTempWorkspace( +run("compressClusters handles files with only findings, null providers, and missing snippet files", async () => { + await withTempWorkspace( { "src/shared/a.ts": [ "export async function a() {", @@ -352,7 +359,7 @@ run("compressClusters handles files with only findings, null providers, and miss "}", ].join("\n"), }, - () => { + async () => { const snapshot = buildSnapshot({ apiCalls: [ { @@ -404,7 +411,7 @@ run("compressClusters handles files with only findings, null providers, and miss ], }); - const compressed = compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); + const compressed = await compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); assert.ok(compressed.length >= 1); const sharedCluster = compressed.find((cluster) => cluster.primarySummary.filePath.startsWith("src/shared/")); @@ -425,8 +432,8 @@ run("compressClusters handles files with only findings, null providers, and miss ); }); -run("compressClusters uses snapshot.repoRoot instead of process.cwd() for snippet reads", () => { - withTempWorkspace( +run("compressClusters uses snapshot.repoRoot instead of process.cwd() for snippet reads", async () => { + await withTempWorkspace( { "src/chat/loop.ts": [ "export async function loop(items) {", @@ -436,7 +443,7 @@ run("compressClusters uses snapshot.repoRoot instead of process.cwd() for snippe "}", ].join("\n"), }, - (workspaceDir) => { + async (workspaceDir) => { const snapshot = buildSnapshot({ repoRoot: workspaceDir, apiCalls: [ @@ -456,7 +463,7 @@ run("compressClusters uses snapshot.repoRoot instead of process.cwd() for snippe process.chdir(os.tmpdir()); try { - const compressed = compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); + const compressed = await compressClusters(buildReviewClusters(scoreRepoIntelligence(snapshot)), snapshot); const loopCluster = compressed.find((cluster) => cluster.primarySummary.filePath === "src/chat/loop.ts"); assert.ok(loopCluster); assert.ok((loopCluster?.snippets.length ?? 0) >= 1); @@ -466,3 +473,12 @@ run("compressClusters uses snapshot.repoRoot instead of process.cwd() for snippe } ); }); + +(async () => { + for (const test of pendingTests) { + await test(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/intelligence/__tests__/export.test.ts b/src/intelligence/__tests__/export.test.ts index c3ee28b..f6893f0 100644 --- a/src/intelligence/__tests__/export.test.ts +++ b/src/intelligence/__tests__/export.test.ts @@ -10,17 +10,24 @@ import { buildExportContext, formatAsJSON, formatAsMarkdown } from "../export"; import { scoreRepoIntelligence } from "../scorer"; import type { CompressedCluster, ExportedContext } from "../types"; -function run(name: string, fn: () => void): void { - try { - fn(); - console.log(`PASS ${name}`); - } catch (error) { - console.error(`FAIL ${name}`); - throw error; - } +const pendingTests: Array<() => Promise> = []; + +function run(name: string, fn: () => void | Promise): void { + pendingTests.push(async () => { + try { + await fn(); + console.log(`PASS ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + throw error; + } + }); } -function withTempWorkspace(files: Record, fn: (workspaceDir: string) => void): void { +async function withTempWorkspace( + files: Record, + fn: (workspaceDir: string) => void | Promise +): Promise { const originalCwd = process.cwd(); const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "export-test-")); @@ -31,15 +38,15 @@ function withTempWorkspace(files: Record, fn: (workspaceDir: str fs.writeFileSync(absolutePath, content, "utf8"); } process.chdir(workspaceDir); - fn(workspaceDir); + await fn(workspaceDir); } finally { process.chdir(originalCwd); fs.rmSync(workspaceDir, { recursive: true, force: true }); } } -run("buildExportContext assembles meta, top files, key risks, and passes clusters through unchanged", () => { - withTempWorkspace( +run("buildExportContext assembles meta, top files, key risks, and passes clusters through unchanged", async () => { + await withTempWorkspace( { "src/chat/loop.ts": [ "export async function loop(items) {", @@ -59,7 +66,7 @@ run("buildExportContext assembles meta, top files, key risks, and passes cluster "}, 1000);", ].join("\n"), }, - (workspaceDir) => { + async (workspaceDir) => { const snapshot = buildSnapshot({ apiCalls: [ { @@ -112,7 +119,7 @@ run("buildExportContext assembles meta, top files, key risks, and passes cluster }); const scored = scoreRepoIntelligence(snapshot); - const clusters = compressClusters(buildReviewClusters(scored), snapshot); + const clusters = await compressClusters(buildReviewClusters(scored), snapshot); const context = buildExportContext(clusters, snapshot, scored, { generatorVersion: "0.1.0" }); assert.equal(context.meta.projectName, path.basename(workspaceDir)); @@ -200,7 +207,9 @@ run("formatAsMarkdown and formatAsJSON render stable onboarding output", () => { }, ], keyRisks: ["Unbounded loop API calls", "Rate-limit risk"], + costLeaks: [], }, + providerSummary: [], clusters, }; @@ -598,7 +607,9 @@ run("formatAsMarkdown clarifies cluster-vs-primary providers and softens heurist }, ], keyRisks: ["Potential missing caching on hot path"], + costLeaks: [], }, + providerSummary: [], clusters: [ { id: "cluster:src/chat/providers/xai.ts", @@ -838,3 +849,12 @@ run("buildExportContext prefers non-generated non-tooling top files when runtime assert.ok(!context.summary.topFiles.some((file) => file.filePath === "dashboard-dist/assets/index-abc123.js")); assert.ok(!context.summary.topFiles.some((file) => file.filePath === "src/scanner/patterns/provider-gemini.ts")); }); + +(async () => { + for (const test of pendingTests) { + await test(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/scanner/source-span.ts b/src/scanner/source-span.ts new file mode 100644 index 0000000..0e7b5de --- /dev/null +++ b/src/scanner/source-span.ts @@ -0,0 +1,39 @@ +/** + * Span describing where a detection lives in source. + * + * - Lines are 1-based to match VSCode's display convention. + * - Columns are 0-based to match tree-sitter's `startPosition.column` + * and VSCode's `Position` constructor. + */ +export interface SourceSpan { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; +} + +/** Build a zero-width span at a single point (used as a safe fallback). */ +export function pointSpan(line: number, column = 0): SourceSpan { + return { startLine: line, startColumn: column, endLine: line, endColumn: column }; +} + +/** + * Compute the end position of a regex match given (a) the start line/column + * inside the source and (b) the matched text. Walks the matched text counting + * newlines so multi-line matches report their true extent. + */ +export function spanFromMatch( + startLine: number, + startColumn: number, + matchText: string +): SourceSpan { + let endLine = startLine; + let endColumn = startColumn + matchText.length; + const newlineCount = (matchText.match(/\n/g) ?? []).length; + if (newlineCount > 0) { + endLine = startLine + newlineCount; + const lastNewline = matchText.lastIndexOf("\n"); + endColumn = matchText.length - lastNewline - 1; + } + return { startLine, startColumn, endLine, endColumn }; +} diff --git a/src/test/__mocks__/vscode.ts b/src/test/__mocks__/vscode.ts new file mode 100644 index 0000000..e8be2ab --- /dev/null +++ b/src/test/__mocks__/vscode.ts @@ -0,0 +1,104 @@ +// Minimal `vscode` shim for unit-test environments where the real Extension +// Host API is not available. Only stubs the surface that src/extension.ts and +// its eager imports touch at module-load time and during a no-op +// activate()/deactivate() sequence. + +class Disposable { + static from(..._disposables: { dispose: () => void }[]): Disposable { + return new Disposable(); + } + dispose(): void { /* noop */ } +} + +class EventEmitter { + event = (_listener: (_e: T) => void) => new Disposable(); + fire(_data: T): void { /* noop */ } + dispose(): void { /* noop */ } +} + +class Uri { + static file(p: string) { return new Uri(p); } + static joinPath(_base: Uri, ..._segments: string[]) { return new Uri("joined"); } + constructor(public readonly fsPath: string = "") {} + toString() { return this.fsPath; } +} + +const noop = () => undefined; +const asyncNoop = async () => undefined; + +const window = { + createOutputChannel: (_name: string) => ({ + appendLine: noop, + append: noop, + clear: noop, + show: noop, + dispose: noop, + hide: noop, + replace: noop, + name: _name, + }), + createStatusBarItem: () => ({ + text: "", + tooltip: "", + command: undefined, + color: undefined, + show: noop, + hide: noop, + dispose: noop, + }), + showInformationMessage: asyncNoop, + showErrorMessage: asyncNoop, + showWarningMessage: asyncNoop, + registerWebviewViewProvider: () => new Disposable(), + activeTextEditor: undefined, +}; + +const workspace = { + workspaceFolders: [] as { uri: Uri; name: string; index: number }[], + getConfiguration: () => ({ + get: (_key: string, defaultValue?: T): T | undefined => defaultValue, + update: asyncNoop, + has: () => false, + inspect: () => undefined, + }), + onDidChangeConfiguration: () => new Disposable(), + onDidChangeWorkspaceFolders: () => new Disposable(), + fs: { + readFile: asyncNoop, + writeFile: asyncNoop, + stat: asyncNoop, + }, + findFiles: async () => [] as Uri[], +}; + +const commands = { + registerCommand: () => new Disposable(), + executeCommand: asyncNoop, +}; + +const env = { + clipboard: { + writeText: asyncNoop, + readText: async () => "", + }, + openExternal: asyncNoop, +}; + +const StatusBarAlignment = { Left: 1, Right: 2 } as const; +const ViewColumn = { Active: -1, Beside: -2, One: 1, Two: 2, Three: 3 } as const; +const ConfigurationTarget = { Global: 1, Workspace: 2, WorkspaceFolder: 3 } as const; +const ThemeColor = class { constructor(public readonly id: string) {} }; + +export { + Disposable, + EventEmitter, + Uri, + window, + workspace, + commands, + env, + StatusBarAlignment, + ViewColumn, + ConfigurationTarget, + ThemeColor, +}; diff --git a/src/test/extension-activation.test.ts b/src/test/extension-activation.test.ts new file mode 100644 index 0000000..4ee5281 --- /dev/null +++ b/src/test/extension-activation.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import Module from "node:module"; + +// Activation test for src/extension.ts. +// +// Strategy: extension.ts imports `vscode` at module top level, which is only +// available inside the real Extension Host. We monkey-patch Node's require +// resolution to return our local stub for any `require("vscode")`, then load +// the BUILT artifact (dist/extension.js) rather than compiling extension.ts +// under the scanner tsconfig — that would drag the entire webview-provider +// transitive closure into the test compile graph for no benefit. + +const distExtensionPath = path.resolve(__dirname, "..", "..", "dist", "extension.js"); +const mockVscodePath = path.resolve(__dirname, "__mocks__", "vscode.js"); + +function installVscodeMock(): void { + if (!fs.existsSync(mockVscodePath)) { + throw new Error(`vscode mock not compiled at ${mockVscodePath} — run "npx tsc -p tsconfig.scanner-tests.json" first`); + } + const originalRequire = Module.prototype.require as unknown as (id: string) => unknown; + function patched(this: NodeJS.Module, id: string) { + if (id === "vscode") return originalRequire.call(this, mockVscodePath); + return originalRequire.call(this, id); + } + (Module.prototype as { require: unknown }).require = patched; +} + +async function runTests() { + if (!fs.existsSync(distExtensionPath)) { + throw new Error(`dist/extension.js not found at ${distExtensionPath} — run "npm run build:ext" before tests`); + } + + installVscodeMock(); + + // Use dynamic require so tsc does not try to follow the import path into + // the extension's `vscode` import chain. The vscode mock is now wired. + const ext = require(distExtensionPath) as { activate?: unknown; deactivate?: unknown }; + + assert.equal(typeof ext.activate, "function", "activate must be exported"); + assert.equal(typeof ext.deactivate, "function", "deactivate must be exported"); + + // deactivate() must be idempotent — calling it before activate() ever fires, + // and calling it twice, must not throw. This catches accidental state + // assumptions in deactivate() that would otherwise blow up on edge-case + // shutdown sequences (e.g., extension host kills before activate completes). + (ext.deactivate as () => void)(); + (ext.deactivate as () => void)(); + + console.log("PASS extension-activation"); +} + +runTests().catch((e) => { console.error(e); process.exit(1); }); diff --git a/src/webview-provider.ts b/src/webview-provider.ts index b1b7e72..3e4b896 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -5,89 +5,34 @@ import * as path from "path"; import { scanWorkspace, detectLocalWastePatterns, - readWorkspaceFileExcerpt, countScopedWorkspaceFiles, - getWorkspaceScanFiles, } from "./scanner/workspace-scanner"; -import { createProject, findProjectByName, submitScan, getAllEndpoints, getAllSuggestions, validateProjectId } from "./api-client"; -import { buildSystemPrompt } from "./chat/prompts"; +import { findProjectByName, createProject, validateProjectId } from "./api-client"; import { - buildProviderOptions, - executeChat, - findModelMetadata, getDefaultChatSelection, - getProviderAdapter, - ChatAdapterError, type ChatProviderId, - type NormalizedChatMessage, - type NormalizedChatRequest, } from "./chat"; -import type { WebviewMessage, HostMessage, KeyServiceId, KeyStatusSummary, ProjectIdStatusSummary } from "./messages"; +import type { WebviewMessage, HostMessage, KeyServiceId, ProjectIdStatusSummary } from "./messages"; import type { ApiCallInput, EndpointRecord, Suggestion, ScanSummary } from "./analysis/types"; -import { runSimulation, StaticDataSource } from "./simulator"; import type { SimulatorInput } from "./simulator/types"; -import { classifyEndpointScope, detectEndpointProvider } from "./scanner/endpoint-classification"; -import { classifyPricing, calculateSavings } from "./scan-results"; import { buildSnapshot } from "./intelligence/builder"; import { scoreSnapshot } from "./intelligence/scorer"; import { buildReviewClusters } from "./intelligence/clusters"; import { compressClusters } from "./intelligence/compression"; import { buildExportContext, formatAsMarkdown } from "./intelligence/export"; -import { estimateLocalMonthlyCost } from "./intelligence/cost-utils"; import { buildKeyFingerprint, - buildKeyStatusSummary, getKeyService, - listKeyServices, - maskKeyPreview, readStoredSecret, resolveCurrentKeyValue, - validateServiceKey, type PersistedKeyValidationSnapshot, } from "./key-management"; import { resolveWorkspaceFilePathSafely } from "./workspace-file-access"; import { getOutputChannel } from "./output"; - -interface ChatMessage { - role: "user" | "assistant"; - content: string; -} - -interface AiFinding { - type: Suggestion["type"]; - severity: Suggestion["severity"]; - confidence: number; - description: string; - affectedFile: string; - targetLine?: number; - evidence: string[]; -} - -interface AiPromptFile { - path: string; - snippet: string; - startLine: number; - endLine: number; -} - -interface AiReviewInput { - files: AiPromptFile[]; - summary: ScanSummary | null; - endpoints: Array<{ - id: string; - method: string; - url: string; - status: EndpointRecord["status"]; - monthlyCost: number; - files: string[]; - }>; - suggestions: Array<{ - type: Suggestion["type"]; - severity: Suggestion["severity"]; - description: string; - affectedFiles: string[]; - }>; -} +import { ChatHandler } from "./webview/chat-handler"; +import { KeyManagementHandler } from "./webview/key-management-handler"; +import { SimulationHandler } from "./webview/simulation-handler"; +import { ScanPublishingHandler, type ExportDebugPayload } from "./webview/scan-publishing-handler"; async function resolveWorkspaceFileSafely( workspaceFolder: vscode.WorkspaceFolder, @@ -118,475 +63,6 @@ export async function collectLocalScanData( return { apiCalls, findings, totalFilesScanned }; } -function normalizeDescription(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim(); -} - -function trimText(value: string, max: number): string { - return value.length <= max ? value : `${value.slice(0, max)}...`; -} - -function clampConfidence(value: number): number { - if (!Number.isFinite(value)) return 0; - if (value < 0) return 0; - if (value > 1) return 1; - return value; -} - - -function mapStatusToSuggestionType(status: EndpointRecord["status"]): Suggestion["type"] | null { - switch (status) { - case "cacheable": - return "cache"; - case "batchable": - return "batch"; - case "redundant": - return "redundancy"; - case "n_plus_one_risk": - return "n_plus_one"; - case "rate_limit_risk": - return "rate_limit"; - default: - return null; - } -} - -function chooseSeverity(status: EndpointRecord["status"], monthlyCost: number): Suggestion["severity"] { - if (status === "n_plus_one_risk" || status === "redundant") { - return monthlyCost >= 100 ? "high" : "medium"; - } - if (status === "rate_limit_risk") { - return monthlyCost >= 50 ? "high" : "medium"; - } - return monthlyCost >= 100 ? "medium" : "low"; -} - - -function confidenceFromEndpointStatus(endpoint: EndpointRecord): number { - const base = - endpoint.status === "n_plus_one_risk" ? 0.78 : - endpoint.status === "redundant" ? 0.72 : - endpoint.status === "rate_limit_risk" ? 0.7 : - endpoint.status === "cacheable" ? 0.66 : - endpoint.status === "batchable" ? 0.66 : - 0.55; - const perRequestBoost = endpoint.callSites.some((site) => site.frequency === "per-request") ? 0.07 : 0; - return clampConfidence(base + perRequestBoost); -} - -function buildAggressiveDescription(endpoint: EndpointRecord, type: Suggestion["type"]): string { - const firstSite = endpoint.callSites[0]; - const location = firstSite ? ` (${firstSite.file}:${firstSite.line})` : ""; - switch (type) { - case "cache": - return `Potential caching opportunity detected for \`${endpoint.method} ${endpoint.url}\`${location}. This endpoint appears cacheable; consider adding response caching with explicit TTL and cache invalidation rules to reduce repeated requests and cost.`; - case "batch": - return `Potential batching opportunity detected for \`${endpoint.method} ${endpoint.url}\`${location}. This endpoint appears in a pattern that may benefit from request batching or bulk-fetch patterns to reduce request volume.`; - case "redundancy": - return `Potential redundant API usage detected for \`${endpoint.method} ${endpoint.url}\`${location}. Multiple call paths may be invoking equivalent requests; consider deduping in-flight requests and consolidating repeated fetches.`; - case "n_plus_one": - return `Potential N+1 API pattern detected for \`${endpoint.method} ${endpoint.url}\`${location}. Review loop-driven request behavior and replace with prefetch/batch patterns where possible.`; - case "rate_limit": - return `Potential rate-limit risk detected for \`${endpoint.method} ${endpoint.url}\`${location}. Add throttling/backoff and request coalescing to reduce burst frequency and avoid provider limits.`; - default: - return `Potential optimization opportunity detected for \`${endpoint.method} ${endpoint.url}\`${location}.`; - } -} - -function buildAggressiveSuggestions( - endpoints: EndpointRecord[], - suggestions: Suggestion[], - localFindings: Awaited> -): Suggestion[] { - const existing = new Set(); - for (const suggestion of suggestions) { - for (const endpointId of suggestion.affectedEndpoints) { - existing.add(`${endpointId}:${suggestion.type}`); - } - } - - function normalizePath(p: string): string { - return p.replace(/\\/g, "/").replace(/^\.\//, ""); - } - - // Build a set of (type, file) pairs already covered by the waste detector. - // Aggressive suggestions for these will be suppressed so the richer finding wins. - const coveredByWaste = new Set(); - for (const finding of localFindings) { - coveredByWaste.add(`${finding.type}:${normalizePath(finding.affectedFile)}`); - } - - const extras: Suggestion[] = []; - for (const endpoint of endpoints) { - const type = mapStatusToSuggestionType(endpoint.status); - if (!type) continue; - - const dedupeKey = `${endpoint.id}:${type}`; - if (existing.has(dedupeKey)) continue; - - // Skip internal endpoints — they have no cost implications - if (endpoint.scope === "internal") continue; - - // If the waste detector already covers this type for any of this endpoint's files, - // suppress the aggressive suggestion — the waste detector finding has richer evidence. - const suppressedByWaste = endpoint.files.some((f) => - coveredByWaste.has(`${type}:${normalizePath(f)}`) - ); - if (suppressedByWaste) continue; - - extras.push({ - id: `local-${endpoint.id}-${type}`, - projectId: endpoint.projectId, - scanId: endpoint.scanId, - type, - severity: chooseSeverity(endpoint.status, endpoint.monthlyCost), - affectedEndpoints: [endpoint.id], - affectedFiles: endpoint.files, - estimatedMonthlySavings: calculateSavings(type, "medium", endpoint.monthlyCost), - description: buildAggressiveDescription(endpoint, type), - codeFix: "", - source: "local-rule", - confidence: confidenceFromEndpointStatus(endpoint), - evidence: endpoint.callSites.slice(0, 3).map((site) => `Observed callsite: ${site.file}:${site.line}`), - pricingClass: classifyPricing([endpoint.costModel]), - }); - } - - return [...suggestions, ...extras]; -} - -const PROXIMITY_THRESHOLD_LINES = 25; - -/** - * Find the endpoint whose call site is closest to the finding's line number. - * Only considers call sites within PROXIMITY_THRESHOLD_LINES of the finding. - * Falls back to null if no close match is found, allowing callers to use - * file-level cost as a fallback. - * - * TODO: Replace line-proximity threshold with function-scope matching once - * function boundary data is available at this point in the pipeline. Function - * scope is semantically more accurate — a finding and its triggering call site - * always share the same function body regardless of line distance. - */ -function findClosestEndpoint( - finding: { affectedFile: string; line?: number }, - fileEndpoints: EndpointRecord[] -): EndpointRecord | null { - if (!finding.line || fileEndpoints.length === 0) return null; - - let closest: EndpointRecord | null = null; - let closestDistance = Infinity; - - for (const ep of fileEndpoints) { - // Skip route-def endpoints — they have monthlyCost === 0 and would - // produce misleading $0 savings estimates - if (ep.monthlyCost === 0 && ep.callSites.every(s => s.library === "route-def")) continue; - - for (const site of ep.callSites) { - if (site.file !== finding.affectedFile) continue; - const distance = Math.abs(site.line - finding.line); - if (distance < closestDistance) { - closestDistance = distance; - closest = ep; - } - } - } - - return closestDistance <= PROXIMITY_THRESHOLD_LINES ? closest : null; -} - -function mergeLocalWasteFindings( - baseSuggestions: Suggestion[], - localFindings: Awaited>, - endpoints: EndpointRecord[], - projectId: string, - scanId: string -): Suggestion[] { - const existingByDescAndFile = new Set( - baseSuggestions.map((s) => `${s.description}::${s.affectedFiles[0] ?? ""}`) - ); - - const locals: Suggestion[] = []; - for (const finding of localFindings) { - if (finding.confidence < 0.35) continue; - - const key = `${finding.description}::${finding.affectedFile}`; - if (existingByDescAndFile.has(key)) continue; - existingByDescAndFile.add(key); - - const fileEndpoints = endpoints.filter((ep) => ep.files.includes(finding.affectedFile)); - const closestEndpoint = findClosestEndpoint(finding, fileEndpoints); - const directCost = closestEndpoint?.monthlyCost ?? 0; - const fileMonthlyCost = fileEndpoints.reduce((sum, ep) => sum + ep.monthlyCost, 0); - const baselineCost = directCost > 0 - ? directCost - : fileMonthlyCost > 0 - ? fileMonthlyCost - : 0; // unknown — no savings estimate - const estimatedMonthlySavings = calculateSavings(finding.type, finding.severity, baselineCost); - const pricingClass = classifyPricing(fileEndpoints.map((ep) => ep.costModel)); - - locals.push({ - id: finding.id, - projectId, - scanId, - type: finding.type, - severity: finding.severity, - affectedEndpoints: fileEndpoints.map((ep) => ep.id), - affectedFiles: [finding.affectedFile], - targetLine: finding.line, - estimatedMonthlySavings, - description: finding.description, - codeFix: "", - source: "local-rule", - confidence: finding.confidence, - evidence: finding.evidence, - pricingClass, - }); - } - - return [...baseSuggestions, ...locals]; -} - -const GENERIC_DYNAMIC_TOKENS = new Set(["endpoint", "url", "path", "uri", "route"]); -const OUTBOUND_LIBRARIES = new Set([ - "fetch", - "axios", - "got", - "superagent", - "ky", - "requests", - "http", - "HttpClient", - "$http", - "openai", -]); - -function isHighConfidenceEndpointUrl(url: string): boolean { - if (!url) return false; - if (/^https?:\/\//i.test(url)) return true; - if (url.startsWith("/")) return true; - if (/\$\{\s*(endpoint|url|path|uri|route)\s*\}/i.test(url)) return false; - const dynamic = url.match(/^]+)>$/i); - if (!dynamic) return false; - const token = dynamic[1].trim().toLowerCase(); - if (GENERIC_DYNAMIC_TOKENS.has(token)) return false; - // A naked dynamic base URL token is not an endpoint route. - if (/base[_-]?url/.test(token)) return false; - return /base[_-]?url|api|endpoint/i.test(token); -} - -function shouldSubmitRemote(call: ApiCallInput): boolean { - if (!call.library || !OUTBOUND_LIBRARIES.has(call.library)) return false; - return isHighConfidenceEndpointUrl(call.url); -} - -function shouldIncludeSynthetic(call: ApiCallInput): boolean { - if (!isHighConfidenceEndpointUrl(call.url)) return false; - if (call.library === "route-def" || call.library === "api-helper") return call.url.startsWith("/"); - return true; -} - -function normalizePathParams(url: string): string { - return url - .replace(/\$\{\s*[^}]+\s*\}/g, ":param") - .replace(/<[^>]+>/g, ":param") - .replace(/\{[^}]+\}/g, ":param"); -} - -function stripQueryAndHash(url: string): string { - const queryIdx = url.indexOf("?"); - const hashIdx = url.indexOf("#"); - const cutAt = - queryIdx >= 0 && hashIdx >= 0 ? Math.min(queryIdx, hashIdx) : - queryIdx >= 0 ? queryIdx : - hashIdx >= 0 ? hashIdx : - -1; - return cutAt >= 0 ? url.slice(0, cutAt) : url; -} - -function canonicalizeEndpointUrl(url: string): string { - const stripped = stripQueryAndHash(url.trim()); - return normalizePathParams(stripped); -} - -function isDynamicPlaceholderUrl(url: string): boolean { - return /^]+>$/i.test(url.trim()); -} - -function buildEndpointKey(method: string, url: string): string { - return `${method.toUpperCase()} ${canonicalizeEndpointUrl(url)}`; -} - -function pickDisplayUrl(current: string, candidate: string): string { - const currentCanonical = canonicalizeEndpointUrl(current); - const candidateCanonical = canonicalizeEndpointUrl(candidate); - - const score = (value: string): number => { - let s = 0; - if (!isDynamicPlaceholderUrl(value)) s += 3; - if (value === stripQueryAndHash(value)) s += 2; - if (value.includes(":param")) s += 1; - if (value.includes("/")) s += 1; - return s; - }; - - const currentScore = score(currentCanonical); - const candidateScore = score(candidateCanonical); - return candidateScore > currentScore ? candidateCanonical : currentCanonical; -} - -const FREQUENCY_SEVERITY: Record = { - polling: 6, - "unbounded-loop": 5, - parallel: 4, - "bounded-loop": 3, - conditional: 2, - "cache-guarded": 1, - single: 0, -}; - -function pickMostSevereFrequency(a: string | undefined, b: string | undefined): string | undefined { - if (!a && !b) return undefined; - if (!a) return b; - if (!b) return a; - return (FREQUENCY_SEVERITY[a] ?? 0) >= (FREQUENCY_SEVERITY[b] ?? 0) ? a : b; -} - -function mergeRemoteAndLocalEndpoints( - remote: EndpointRecord[], - localCalls: ApiCallInput[], - projectId: string, - scanId: string -): EndpointRecord[] { - const merged = remote.map((endpoint) => ({ - ...endpoint, - scope: endpoint.scope ?? classifyEndpointScope(endpoint.url), - })); - const byMethodUrl = new Map(); - for (const endpoint of merged) { - byMethodUrl.set(buildEndpointKey(endpoint.method, endpoint.url), endpoint); - } - - const syntheticByMethodUrl = new Map(); - for (const call of localCalls) { - if (!shouldIncludeSynthetic(call)) continue; - const key = buildEndpointKey(call.method, call.url); - if (byMethodUrl.has(key)) { - const endpoint = byMethodUrl.get(key)!; - endpoint.url = pickDisplayUrl(endpoint.url, call.url); - if (!endpoint.files.includes(call.file)) { - endpoint.files.push(call.file); - } - const hasSite = endpoint.callSites.some( - (site) => site.file === call.file && site.line === call.line && site.library === call.library - ); - if (!hasSite) { - endpoint.callSites.push({ - file: call.file, - line: call.line, - library: call.library ?? "", - frequency: call.frequency, - frequencyClass: call.frequencyClass, - crossFileOrigin: call.crossFileOrigin ?? null, - }); - } - // Propagate enriched fields to endpoint - if (!endpoint.methodSignature && call.methodSignature) endpoint.methodSignature = call.methodSignature; - if (!endpoint.costModel && call.costModel) endpoint.costModel = call.costModel; - endpoint.frequencyClass = pickMostSevereFrequency(endpoint.frequencyClass, call.frequencyClass); - if (call.batchCapable) endpoint.batchCapable = true; - if (call.cacheCapable) endpoint.cacheCapable = true; - if (call.streaming) endpoint.streaming = true; - if (call.isMiddleware) endpoint.isMiddleware = true; - if (call.crossFileOrigin) { - endpoint.crossFileOrigins = endpoint.crossFileOrigins ?? []; - endpoint.crossFileOrigins.push(call.crossFileOrigin); - } - continue; - } - - if (!syntheticByMethodUrl.has(key)) { - const canonicalUrl = canonicalizeEndpointUrl(call.url); - const provider = call.provider ?? detectEndpointProvider(canonicalUrl); - const callsPerDay = call.frequency === "per-request" ? 100 : call.library === "route-def" ? 0 : 1; - syntheticByMethodUrl.set(key, { - id: `local-${scanId}-${syntheticByMethodUrl.size + 1}`, - projectId, - scanId, - provider, - method: call.method, - url: canonicalUrl, - scope: classifyEndpointScope(canonicalUrl), - files: [call.file], - callSites: [{ - file: call.file, - line: call.line, - library: call.library ?? "", - frequency: call.frequency, - frequencyClass: call.frequencyClass, - crossFileOrigin: call.crossFileOrigin ?? null, - }], - callsPerDay, - monthlyCost: estimateLocalMonthlyCost(provider, callsPerDay, call.methodSignature) ?? 0, - status: - call.frequency === "per-request" - ? "n_plus_one_risk" - : call.library === "route-def" - ? "normal" - : "normal", - methodSignature: call.methodSignature, - costModel: call.costModel, - frequencyClass: call.frequencyClass, - batchCapable: call.batchCapable, - cacheCapable: call.cacheCapable, - streaming: call.streaming, - isMiddleware: call.isMiddleware, - crossFileOrigins: call.crossFileOrigin ? [call.crossFileOrigin] : undefined, - }); - continue; - } - - const synthetic = syntheticByMethodUrl.get(key)!; - synthetic.url = pickDisplayUrl(synthetic.url, call.url); - synthetic.scope = classifyEndpointScope(synthetic.url); - synthetic.provider = call.provider ?? detectEndpointProvider(synthetic.url); - if (!synthetic.files.includes(call.file)) { - synthetic.files.push(call.file); - } - const hasSite = synthetic.callSites.some( - (site) => site.file === call.file && site.line === call.line && site.library === call.library - ); - if (!hasSite) { - synthetic.callSites.push({ - file: call.file, - line: call.line, - library: call.library ?? "", - frequency: call.frequency, - frequencyClass: call.frequencyClass, - crossFileOrigin: call.crossFileOrigin ?? null, - }); - } - if (call.frequency === "per-request") { - synthetic.status = "n_plus_one_risk"; - synthetic.callsPerDay = Math.max(synthetic.callsPerDay, 100); - } - if (!synthetic.methodSignature && call.methodSignature) synthetic.methodSignature = call.methodSignature; - if (!synthetic.costModel && call.costModel) synthetic.costModel = call.costModel; - synthetic.frequencyClass = pickMostSevereFrequency(synthetic.frequencyClass, call.frequencyClass); - if (call.batchCapable) synthetic.batchCapable = true; - if (call.cacheCapable) synthetic.cacheCapable = true; - if (call.streaming) synthetic.streaming = true; - if (call.isMiddleware) synthetic.isMiddleware = true; - if (call.crossFileOrigin) { - synthetic.crossFileOrigins = synthetic.crossFileOrigins ?? []; - synthetic.crossFileOrigins.push(call.crossFileOrigin); - } - } - - return [...merged, ...syntheticByMethodUrl.values()] - .filter((ep) => ep.scope !== "internal"); -} export interface WebviewMessageHandlers { startScan(): Promise; @@ -656,7 +132,6 @@ export async function dispatchWebviewMessage( export class ReCostSidebarProvider implements vscode.WebviewViewProvider { public static readonly viewType = "recost.sidebarView"; - private static readonly KEY_VALIDATION_STATE_STORAGE_KEY = "recost.keyValidationState"; private static readonly MANUAL_PROJECT_ID_STORAGE_KEY = "recost.manualProjectId"; private static readonly MANUAL_PROJECT_ID_VALIDATION_STORAGE_KEY = "recost.manualProjectIdValidation"; @@ -671,13 +146,12 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { private lastApiCalls: ApiCallInput[] = []; private lastFindings: Awaited> = []; - // Simulator state (persisted across sessions) - private savedScenarios: import("./simulator/types").SavedScenario[] = []; - // Chat state - private chatHistory: ChatMessage[] = []; + private readonly chatHandler: ChatHandler; + private readonly keyManagementHandler: KeyManagementHandler; + private readonly simulationHandler: SimulationHandler; + private readonly scanPublishingHandler: ScanPublishingHandler; private readonly outputChannel: vscode.OutputChannel; - private readonly keyValidationState = new Map(); private readonly projectIdCheckingState = new Set(); private async sendProjectIdStatus(): Promise { @@ -703,29 +177,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { return path.join(os.tmpdir(), `recost-extension-scan-results-${workspaceName}.json`); } - private async exportDebugScanResults(payload: { - mode: "local-only" | "remote-enriched"; - scannedFiles: string[]; - local: { - apiCalls: ApiCallInput[]; - localWasteFindings: Awaited>; - submittedRemoteApiCalls: ApiCallInput[]; - }; - remote: null | { - projectId: string; - scanId: string; - endpoints: EndpointRecord[]; - suggestions: Suggestion[]; - summary: ScanSummary; - }; - final: { - projectId: string; - scanId: string; - endpoints: EndpointRecord[]; - suggestions: Suggestion[]; - summary: ScanSummary; - }; - }): Promise { + private async exportDebugScanResults(payload: ExportDebugPayload): Promise { const exportPath = this.getDebugScanExportPath(); const body = { exportedAt: new Date().toISOString(), @@ -738,6 +190,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { } catch (error) { const message = error instanceof Error ? error.message : String(error); this.outputChannel.appendLine(`[debug-export] Failed to write ${exportPath}: ${message}`); + vscode.window.showErrorMessage(`ReCost: failed to export scan results: ${message}`); } } @@ -745,8 +198,61 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { this.context = context; this.outputChannel = vscode.window.createOutputChannel("ReCost AI Review"); this.context.subscriptions.push(this.outputChannel); - this.savedScenarios = (this.context.globalState.get("recost.simulatorScenarios")) ?? []; - this.restoreKeyValidationState(); + this.simulationHandler = new SimulationHandler({ + postMessage: (m) => this.postMessage(m), + context: this.context, + getLastEndpoints: () => this.lastEndpoints, + }); + this.keyManagementHandler = new KeyManagementHandler({ + postMessage: (m) => this.postMessage(m), + context: this.context, + outputChannel: this.outputChannel, + openKeys: (id) => this.openKeys(id), + getManualProjectId: () => this.getManualProjectId(), + clearProjectIdValidationState: () => this.clearProjectIdValidationState(), + sendProjectIdStatus: () => this.sendProjectIdStatus(), + validateManualProjectId: () => this.validateManualProjectId(), + }); + this.chatHandler = new ChatHandler({ + postMessage: (m) => this.postMessage(m), + outputChannel: this.outputChannel, + context: this.context, + getSelectedChatProvider: () => this.getSelectedChatProvider(), + getSelectedChatModel: () => this.getSelectedChatModel(), + getLastEndpoints: () => this.lastEndpoints, + getLastSuggestions: () => this.lastSuggestions, + getLastSummary: () => this.lastSummary, + getProjectId: () => this.projectId, + setLastSuggestions: (suggestions) => { this.lastSuggestions = suggestions; }, + setLastSummary: (summary) => { this.lastSummary = summary; }, + getKeyServiceIdForProvider: (providerId) => this.getKeyServiceIdForProvider(providerId), + getStoredProviderApiKey: (providerId) => this.getStoredProviderApiKey(providerId), + setValidationState: (serviceId, snapshot) => this.setValidationState(serviceId, snapshot), + clearValidationState: (serviceId) => this.clearValidationState(serviceId), + sendKeyStatusUpdate: (serviceId, focusServiceId) => this.sendKeyStatusUpdate(serviceId, focusServiceId), + openKeys: (focusServiceId) => this.openKeys(focusServiceId), + }); + this.scanPublishingHandler = new ScanPublishingHandler({ + postMessage: (m) => this.postMessage(m), + context: this.context, + setLastEndpoints: (endpoints) => { this.lastEndpoints = endpoints; }, + setLastSuggestions: (suggestions) => { this.lastSuggestions = suggestions; }, + setLastSummary: (summary) => { this.lastSummary = summary; }, + setLastApiCalls: (calls) => { this.lastApiCalls = calls; }, + setLastFindings: (findings) => { this.lastFindings = findings; }, + setProjectId: (id) => { this.projectId = id; }, + getProjectId: () => this.projectId, + getManualProjectId: () => this.getManualProjectId(), + getRcApiKey: () => this.getRcApiKey(), + resolveScanProjectTarget: (rcApiKey) => this.resolveScanProjectTarget(rcApiKey), + getWorkspaceName: () => this.getWorkspaceName(), + openKeys: (focusServiceId) => this.openKeys(focusServiceId), + setRecostValidationState: (snapshot) => this.setValidationState("recost", snapshot), + clearRecostValidationState: () => this.clearValidationState("recost"), + sendRecostKeyStatusUpdate: () => this.sendKeyStatusUpdate("recost", "recost"), + resetChatHistory: () => this.chatHandler.resetHistory(), + exportDebugScanResults: (payload) => this.exportDebugScanResults(payload), + }); } resolveWebviewView( @@ -806,22 +312,15 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { } private getKeyServiceIdForProvider(providerId: string): KeyServiceId | undefined { - return listKeyServices().find((service) => service.providerId === providerId)?.serviceId; + return this.keyManagementHandler.getKeyServiceIdForProvider(providerId); } private async getStoredProviderApiKey(providerId: string): Promise { - const serviceId = this.getKeyServiceIdForProvider(providerId); - if (!serviceId) return undefined; - return readStoredSecret(getKeyService(serviceId), this.context.secrets); + return this.keyManagementHandler.getStoredProviderApiKey(providerId); } - private async sendChatConfig(providerId = this.getSelectedChatProvider(), model = this.getSelectedChatModel()) { - this.postMessage({ - type: "chatConfig", - providers: buildProviderOptions(), - selectedProvider: providerId, - selectedModel: model, - }); + private sendChatConfig(providerId?: ChatProviderId, model?: string) { + return this.chatHandler.sendChatConfig(providerId, model); } public postMessage(message: HostMessage) { @@ -994,114 +493,29 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { return { projectId: await this.getOrCreateProject(rcApiKey), source: "auto" }; } - private async buildAllKeyStatuses(): Promise { - const services = listKeyServices(); - return Promise.all( - services.map((service) => - this.buildKeyStatus(service) - ) - ); - } - private async sendAllKeyStatuses(focusServiceId?: KeyServiceId) { - this.postMessage({ type: "allKeyStatuses", statuses: await this.buildAllKeyStatuses(), focusServiceId }); + return this.keyManagementHandler.sendAllKeyStatuses(focusServiceId); } private async sendKeyStatusUpdate(serviceId: KeyServiceId, focusServiceId?: KeyServiceId) { - const service = getKeyService(serviceId); - const status = await this.buildKeyStatus(service); - this.postMessage({ type: "keyStatusUpdated", status, focusServiceId }); + return this.keyManagementHandler.sendKeyStatusUpdate(serviceId, focusServiceId); } private async clearServiceKey(serviceId: KeyServiceId) { - const service = getKeyService(serviceId); - if (service.secretStorageKey) { - await this.context.secrets.delete(service.secretStorageKey); - } - if (serviceId === "openai") { - await this.context.secrets.delete("recost.openaiApiKey"); - } - await this.clearValidationState(serviceId); - await this.sendKeyStatusUpdate(serviceId); - if (serviceId === "recost") { - await this.clearProjectIdValidationState(); - await this.sendProjectIdStatus(); - } + return this.keyManagementHandler.clearServiceKey(serviceId); } private async setServiceKey(serviceId: KeyServiceId, value: string) { - const service = getKeyService(serviceId); - const trimmed = value.trim(); - if (!trimmed) { - this.postMessage({ type: "keyActionError", serviceId, message: "API key must not be empty." }); - return; - } - if (!service.secretStorageKey) { - this.postMessage({ type: "keyActionError", serviceId, message: `${service.displayName} does not use stored API keys in this extension.` }); - return; - } - if (serviceId === "openai" && !/^sk-/.test(trimmed)) { - this.postMessage({ type: "keyActionError", serviceId, message: 'OpenAI API keys must start with "sk-".' }); - return; - } - await this.context.secrets.store(service.secretStorageKey, trimmed); - if (serviceId === "openai") { - await this.context.secrets.store("recost.openaiApiKey", trimmed); - } - await this.clearValidationState(serviceId); - await this.sendKeyStatusUpdate(serviceId); - await this.testServiceKey(serviceId); - if (serviceId === "recost" && this.getManualProjectId()) { - await this.validateManualProjectId(); - } + return this.keyManagementHandler.setServiceKey(serviceId, value); } private async testServiceKey(serviceId: KeyServiceId) { - const service = getKeyService(serviceId); - const current = await this.buildKeyStatus(service); - if (current.source === "missing") { - this.postMessage({ type: "keyActionError", serviceId, message: `${service.displayName} key is missing.` }); - return; - } - this.postMessage({ - type: "keyStatusUpdated", - status: { ...current, state: "checking", message: undefined }, - focusServiceId: serviceId, - }); - try { - const value = await resolveCurrentKeyValue(service, this.context.secrets); - if (!value) { - this.postMessage({ type: "keyActionError", serviceId, message: `${service.displayName} key is missing.` }); - return; - } - const validation = await validateServiceKey(service, value); - await this.setValidationState(serviceId, { - ...validation, - keyFingerprint: buildKeyFingerprint(value), - }); - await this.sendKeyStatusUpdate(serviceId, serviceId); - if (serviceId === "recost") { - await vscode.commands.executeCommand("setContext", "recost.keyOnline", validation.state === "valid"); - } - } catch (error) { - const previous = this.keyValidationState.get(serviceId); - await this.sendKeyStatusUpdate(serviceId, serviceId); - const message = error instanceof Error ? error.message : `Unable to test ${service.displayName} key.`; - if (previous) { - this.postMessage({ type: "keyActionError", serviceId, message }); - } else { - this.postMessage({ - type: "keyStatusUpdated", - status: { ...current, message, maskedPreview: current.maskedPreview ?? maskKeyPreview(undefined) }, - focusServiceId: serviceId, - }); - } - } + return this.keyManagementHandler.testServiceKey(serviceId); } private async handleMessage(message: WebviewMessage): Promise { await dispatchWebviewMessage(message, { - startScan: () => this.handleStartScan(), + startScan: () => this.scanPublishingHandler.handleStartScan(), runAiReview: () => this.handleRunAiReview(), chat: (text, provider, model) => this.handleChat(text, provider, model), modelChanged: async (provider, model) => { @@ -1113,7 +527,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { applyFix: (code, file, line) => this.handleApplyFix(code, file, line), openFile: (file, line) => this.handleOpenFile(file, line), openDashboard: () => this.handleOpenDashboard(), - runSimulation: (input) => { this.handleRunSimulation(input); }, + runSimulation: (input) => this.simulationHandler.handleRunSimulation(input), getAllKeyStatuses: () => this.sendAllKeyStatuses(), getProjectIdStatus: () => this.sendProjectIdStatus(), setKey: (serviceId, value) => this.setServiceKey(serviceId, value), @@ -1151,7 +565,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { }); const scored = scoreSnapshot(snapshot); const clusters = buildReviewClusters(scored); - const compressed = compressClusters(clusters, snapshot); + const compressed = await compressClusters(clusters, snapshot); const generatorVersion = String(this.context.extension.packageJSON.version ?? ""); const exportContext = buildExportContext(compressed, snapshot, scored, { generatorVersion: generatorVersion || undefined, @@ -1165,740 +579,9 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { } } - private handleRunSimulation(input: SimulatorInput): void { - try { - if (this.lastEndpoints.length === 0) { - this.postMessage({ type: "simulationError", message: "Run a scan first to use the simulator." }); - return; - } - const source = new StaticDataSource(this.lastEndpoints); - const result = runSimulation(source, input); - this.postMessage({ type: "simulationResult", result }); - } catch (err) { - const message = err instanceof Error ? err.message : "Simulation failed"; - this.postMessage({ type: "simulationError", message }); - } - } - - private async handleStartScan() { - await vscode.commands.executeCommand("setContext", "recost.scanning", true); - try { - this.chatHistory = []; - const scannedFiles = (await getWorkspaceScanFiles()).map((file) => file.relativePath); - - const apiCalls = await scanWorkspace((progress) => { - this.postMessage({ - type: "scanProgress", - stage: "scanning", - file: progress.file, - fileIndex: progress.fileIndex, - fileTotal: progress.fileTotal, - }); - }); - - this.postMessage({ type: "scanProgress", stage: "analyzing" }); - this.postMessage({ type: "scanProgress", stage: "detecting" }); - const localWasteFindings = await detectLocalWastePatterns(); - this.lastApiCalls = apiCalls; - this.lastFindings = localWasteFindings; - this.postMessage({ type: "scanProgress", stage: "resolving" }); - - if (process.env.RECOST_INTELLIGENCE_DEBUG === "1") { - const totalFilesScanned = await countScopedWorkspaceFiles(); - const snapshot = buildSnapshot({ - apiCalls, - findings: localWasteFindings, - totalFilesScanned, - }); - const scored = scoreSnapshot(snapshot); - for (const file of scored.scoredFiles.slice(0, 5)) { - console.log( - `[intelligence] ${file.filePath} | priority=${file.scores.aiReviewPriority.toFixed(2)} | ` + - `importance=${file.scores.importance.toFixed(2)} | ` + - `costLeak=${file.scores.costLeak.toFixed(2)} | ` + - `reliabilityRisk=${file.scores.reliabilityRisk.toFixed(2)} | ` + - `reasons=${file.reasons.join("; ")}` - ); - } - } - - this.postMessage({ type: "scanComplete" }); - - const publishLocalOnlyResults = (localProjectId: string, localScanId: string) => { - const endpoints = mergeRemoteAndLocalEndpoints([], apiCalls, localProjectId, localScanId); - const mergedSuggestions = mergeLocalWasteFindings( - [], - localWasteFindings, - endpoints, - localProjectId, - localScanId - ); - const summary: ScanSummary = { - totalEndpoints: endpoints.length, - totalCallsPerDay: endpoints.reduce((sum, ep) => sum + ep.callsPerDay, 0), - totalMonthlyCost: endpoints.reduce((sum, ep) => sum + ep.monthlyCost, 0), - highRiskCount: mergedSuggestions.filter((s) => s.severity === "high").length, - }; - - const externalEndpoints = endpoints.filter((ep) => ep.scope !== "internal"); - this.lastEndpoints = externalEndpoints; - this.lastSuggestions = mergedSuggestions; - this.lastSummary = { ...summary, totalEndpoints: externalEndpoints.length }; - this.postMessage({ - type: "scanResults", - endpoints: externalEndpoints, - suggestions: mergedSuggestions, - summary: { ...summary, totalEndpoints: externalEndpoints.length }, - }); - void this.exportDebugScanResults({ - mode: "local-only", - scannedFiles, - local: { - apiCalls, - localWasteFindings, - submittedRemoteApiCalls: [], - }, - remote: null, - final: { - projectId: localProjectId, - scanId: localScanId, - endpoints, - suggestions: mergedSuggestions, - summary, - }, - }); - }; - - if (apiCalls.length === 0) { - this.lastEndpoints = []; - this.lastSuggestions = []; - this.lastSummary = { - totalEndpoints: 0, - totalCallsPerDay: 0, - totalMonthlyCost: 0, - highRiskCount: 0, - }; - this.postMessage({ - type: "scanResults", - endpoints: [], - suggestions: [], - summary: this.lastSummary, - }); - void this.exportDebugScanResults({ - mode: "local-only", - scannedFiles, - local: { - apiCalls, - localWasteFindings, - submittedRemoteApiCalls: [], - }, - remote: null, - final: { - projectId: "local", - scanId: `local-${Date.now()}`, - endpoints: [], - suggestions: [], - summary: this.lastSummary, - }, - }); - return; - } - - // Ensure we have a project on the remote API - const manualProjectId = this.getManualProjectId(); - let rcApiKey = await this.getRcApiKey(); - if (!rcApiKey) { - publishLocalOnlyResults(manualProjectId ?? this.projectId ?? "local", `local-${Date.now()}`); - this.postMessage({ - type: "scanNotification", - message: "No ReCost API key — showing local results only. Add a key in Keys to enable remote sync.", - }); - return; - } - // Submit scan and fetch results - // Ensure every call has a provider — fall back to URL-based detection, then skip if still unknown. - const remoteApiCalls = apiCalls - .filter(shouldSubmitRemote) - .map((call) => ({ - ...call, - provider: call.provider ?? detectEndpointProvider(canonicalizeEndpointUrl(call.url)) ?? "unknown", - })) - .filter((call) => call.provider !== "unknown"); - if (remoteApiCalls.length === 0) { - publishLocalOnlyResults(manualProjectId ?? this.projectId ?? "local", `local-${Date.now()}`); - return; - } - - // Show local results immediately so UI unblocks, then update with remote - publishLocalOnlyResults(manualProjectId ?? this.projectId ?? "local", `local-${Date.now()}`); - - try { - const projectTarget = await this.resolveScanProjectTarget(rcApiKey); - let projectId = projectTarget.projectId; - let scanResult; - try { - scanResult = await submitScan(projectId, remoteApiCalls, rcApiKey); - } catch (err: unknown) { - // Project may have been deleted, create a fresh one and retry once. - if ((err as { status?: number }).status === 404 && projectTarget.source === "auto") { - const freshId = await createProject(this.getWorkspaceName(), rcApiKey); - this.projectId = freshId; - projectId = freshId; - await this.context.globalState.update("recost.projectId", freshId); - scanResult = await submitScan(projectId, remoteApiCalls, rcApiKey); - } else { - throw err; - } - } - - const [remoteEndpoints, suggestions] = await Promise.all([ - getAllEndpoints(projectId, scanResult.scanId, rcApiKey), - getAllSuggestions(projectId, scanResult.scanId, rcApiKey), - ]); - const taggedRemoteSuggestions = suggestions.map((s) => ({ ...s, source: s.source ?? "remote" })); - - const endpoints = mergeRemoteAndLocalEndpoints(remoteEndpoints, apiCalls, projectId, scanResult.scanId); - const externalEndpoints = endpoints.filter((ep) => ep.scope !== "internal"); - this.lastEndpoints = externalEndpoints; - const aggressiveSuggestions = buildAggressiveSuggestions(endpoints, taggedRemoteSuggestions, localWasteFindings); - const mergedSuggestions = mergeLocalWasteFindings( - aggressiveSuggestions, - localWasteFindings, - endpoints, - projectId, - scanResult.scanId - ); - this.lastSuggestions = mergedSuggestions; - this.lastSummary = { ...scanResult.summary, totalEndpoints: externalEndpoints.length }; - - this.postMessage({ - type: "scanResults", - endpoints: externalEndpoints, - suggestions: mergedSuggestions, - summary: { - ...scanResult.summary, - totalEndpoints: externalEndpoints.length, - }, - }); - void this.exportDebugScanResults({ - mode: "remote-enriched", - scannedFiles, - local: { - apiCalls, - localWasteFindings, - submittedRemoteApiCalls: remoteApiCalls, - }, - remote: { - projectId, - scanId: scanResult.scanId, - endpoints: remoteEndpoints, - suggestions, - summary: scanResult.summary, - }, - final: { - projectId, - scanId: scanResult.scanId, - endpoints, - suggestions: mergedSuggestions, - summary: { - ...scanResult.summary, - totalEndpoints: Math.max(scanResult.summary.totalEndpoints, endpoints.length), - }, - }, - }); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Remote analysis failed"; - const status = (err as { status?: number }).status; - const authLikeFailure = - status === 401 || - (status === 403 && /invalid|unauthori[sz]ed|forbidden|auth/i.test(message)); - - if (authLikeFailure) { - const rcApiKey = await this.getRcApiKey(); - if (rcApiKey) { - await this.setValidationState("recost", { - state: "invalid", - message, - lastCheckedAt: new Date().toISOString(), - keyFingerprint: buildKeyFingerprint(rcApiKey), - }); - } else { - await this.clearValidationState("recost"); - } - await this.sendKeyStatusUpdate("recost", "recost"); - this.openKeys("recost"); - } - publishLocalOnlyResults(manualProjectId ?? this.projectId ?? "local", `local-${Date.now()}`); - if (status === 404 && manualProjectId) { - this.postMessage({ - type: "scanNotification", - message: `Project ID ${manualProjectId} was not found. Keeping the saved manual Project ID and showing local results.`, - }); - return; - } - if (err instanceof Error && err.message === "fetch failed") { - this.postMessage({ - type: "scanNotification", - message: "Could not reach ReCost server. Showing local results.", - }); - } - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Unknown error during scan"; - this.postMessage({ type: "error", message }); - } finally { - await vscode.commands.executeCommand("setContext", "recost.scanning", false); - } - } - - private logAiReview(message: string) { - const stamp = new Date().toISOString(); - this.outputChannel.appendLine(`[${stamp}] ${message}`); - } - - private getAiReviewConfig() { - const config = vscode.workspace.getConfiguration("recost"); - return { - enabled: config.get("aiReview.enabled", true), - minConfidence: config.get("aiReview.minConfidence", 0.7), - maxFiles: config.get("aiReview.maxFiles", 25), - maxCharsPerFile: config.get("aiReview.maxCharsPerFile", 6000), - fallbackModel: config.get("aiReview.model", "gpt-4.1-mini"), - }; - } - - private resolveAiReviewSelection(fallbackModel: string): { providerId: ChatProviderId; model: string } { - const providerId = this.getSelectedChatProvider(); - const provider = getProviderAdapter(providerId); - const selectedModel = this.getSelectedChatModel(); - if (provider.models.some((entry) => entry.id === selectedModel)) { - return { providerId, model: selectedModel }; - } - if (providerId === "openai" && provider.models.some((entry) => entry.id === fallbackModel)) { - return { providerId, model: fallbackModel }; - } - return { providerId, model: provider.models[0]?.id ?? fallbackModel }; - } - - private async executeAiReviewRequest(request: NormalizedChatRequest) { - const modelMeta = findModelMetadata(request.provider, request.model); - const requiresFallback = request.provider === "openai" && modelMeta?.reasoning; - if (!requiresFallback) { - return executeChat({ request, secrets: this.context.secrets }); - } - try { - return await executeChat({ request: { ...request, stream: false }, secrets: this.context.secrets }); - } catch (error) { - const chatError = error as ChatAdapterError; - if (chatError?.status !== 400) { - throw error; - } - return executeChat({ - request: { - ...request, - stream: false, - messages: request.messages.filter((message) => message.role !== "system"), - }, - secrets: this.context.secrets, - }); - } - } - - private redactSensitiveText(value: string): string { - return value - .replace(/sk-[a-zA-Z0-9]{16,}/g, "[REDACTED_OPENAI_KEY]") - .replace(/(api[_-]?key|token|secret)\s*[:=]\s*["'`][^"'`\n]{8,}["'`]/gi, "$1=[REDACTED]") - .replace(/(authorization\s*:\s*["'`]bearer\s+)[^"'`\n]+/gi, "$1[REDACTED]"); - } - - private async buildAiReviewInputContext(maxFiles: number, maxCharsPerFile: number): Promise { - const scoreByFile = new Map(); - const lineHintByFile = new Map(); - const severityScore: Record = { high: 4, medium: 2, low: 1 }; - - for (const suggestion of this.lastSuggestions) { - for (const file of suggestion.affectedFiles) { - scoreByFile.set(file, (scoreByFile.get(file) ?? 0) + severityScore[suggestion.severity]); - if (suggestion.targetLine && !lineHintByFile.has(file)) { - lineHintByFile.set(file, suggestion.targetLine); - } - } - } - - for (const endpoint of this.lastEndpoints) { - const endpointScore = - endpoint.status === "n_plus_one_risk" || endpoint.status === "redundant" ? 4 : - endpoint.status === "rate_limit_risk" ? 3 : - endpoint.status === "cacheable" || endpoint.status === "batchable" ? 2 : - 1; - for (const callSite of endpoint.callSites) { - scoreByFile.set(callSite.file, (scoreByFile.get(callSite.file) ?? 0) + endpointScore); - if (!lineHintByFile.has(callSite.file)) { - lineHintByFile.set(callSite.file, callSite.line); - } - } - } - - const rankedFiles = [...scoreByFile.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, Math.max(1, maxFiles)) - .map(([file]) => file); - - const files: AiPromptFile[] = []; - for (let i = 0; i < rankedFiles.length; i += 1) { - const file = rankedFiles[i]; - this.postMessage({ - type: "aiReviewProgress", - stage: `Preparing context (${i + 1}/${rankedFiles.length})`, - current: i + 1, - total: rankedFiles.length, - }); - - const excerpt = await readWorkspaceFileExcerpt(file, { - centerLine: lineHintByFile.get(file), - contextLines: 40, - maxChars: maxCharsPerFile, - }); - if (!excerpt || !excerpt.content.trim()) continue; - files.push({ - path: file, - startLine: excerpt.startLine, - endLine: excerpt.endLine, - snippet: this.redactSensitiveText(excerpt.content), - }); - } - - return { - files, - summary: this.lastSummary, - endpoints: this.lastEndpoints.map((endpoint) => ({ - id: endpoint.id, - method: endpoint.method, - url: endpoint.url, - status: endpoint.status, - monthlyCost: endpoint.monthlyCost, - files: endpoint.files, - })), - suggestions: this.lastSuggestions.map((suggestion) => ({ - type: suggestion.type, - severity: suggestion.severity, - description: suggestion.description, - affectedFiles: suggestion.affectedFiles, - })), - }; - } - - private buildAiReviewPrompt(input: AiReviewInput): string { - const contract = { - findings: [ - { - type: "cache | batch | redundancy | n_plus_one | rate_limit", - severity: "high | medium | low", - confidence: 0.0, - description: "short, specific finding", - affectedFile: "path/to/file.ts", - targetLine: 1, - evidence: ["short reason 1", "short reason 2"], - }, - ], - }; - - return [ - "You are an API efficiency code reviewer.", - "Analyze only the provided snippets and existing scan context.", - "Return ONLY valid JSON with no markdown and no extra text.", - "Do not invent files. Use only provided file paths.", - "Prefer high precision over recall.", - `JSON contract: ${JSON.stringify(contract)}`, - `Context: ${JSON.stringify(input)}`, - ].join("\n"); - } - - private parseAndValidateAiFindings( - raw: string, - validFiles: Set, - minConfidence: number - ): { accepted: AiFinding[]; filtered: number } { - const allowedTypes = new Set(["cache", "batch", "redundancy", "n_plus_one", "rate_limit"]); - const allowedSeverity = new Set(["high", "medium", "low"]); - - const tryParse = (value: string): unknown => { - try { - return JSON.parse(value); - } catch { - return null; - } - }; - - let parsed = tryParse(raw); - if (!parsed) { - const fenced = raw.match(/```json\s*([\s\S]*?)\s*```/i) ?? raw.match(/```\s*([\s\S]*?)\s*```/i); - if (fenced) { - parsed = tryParse(fenced[1]); - } - } - if (!parsed) { - const start = raw.indexOf("{"); - const end = raw.lastIndexOf("}"); - if (start >= 0 && end > start) { - parsed = tryParse(raw.slice(start, end + 1)); - } - } - - const findings = (parsed as { findings?: unknown })?.findings; - if (!Array.isArray(findings)) { - return { accepted: [], filtered: 0 }; - } - - const accepted: AiFinding[] = []; - let filtered = 0; - for (const entry of findings) { - if (accepted.length >= 50) { - filtered += 1; - continue; - } - if (!entry || typeof entry !== "object") { - filtered += 1; - continue; - } - const candidate = entry as Record; - const type = candidate.type; - const severity = candidate.severity; - const affectedFile = candidate.affectedFile; - const description = candidate.description; - if ( - typeof type !== "string" || - typeof severity !== "string" || - typeof affectedFile !== "string" || - typeof description !== "string" - ) { - filtered += 1; - continue; - } - if (!allowedTypes.has(type as Suggestion["type"]) || !allowedSeverity.has(severity as Suggestion["severity"])) { - filtered += 1; - continue; - } - if (!validFiles.has(affectedFile)) { - filtered += 1; - continue; - } - - const confidence = clampConfidence(Number(candidate.confidence)); - if (confidence < minConfidence) { - filtered += 1; - continue; - } - - const rawLine = Number(candidate.targetLine); - const targetLine = Number.isFinite(rawLine) && rawLine > 0 ? Math.floor(rawLine) : undefined; - const evidence = Array.isArray(candidate.evidence) - ? candidate.evidence.filter((item): item is string => typeof item === "string").slice(0, 4).map((item) => trimText(item, 180)) - : []; - - accepted.push({ - type: type as Suggestion["type"], - severity: severity as Suggestion["severity"], - confidence, - description: trimText(description.trim(), 500), - affectedFile, - targetLine, - evidence, - }); - } - return { accepted, filtered }; - } - - private mapAiFindingToSuggestion(finding: AiFinding, index: number): Suggestion { - const scanId = this.lastEndpoints[0]?.scanId ?? this.projectId ?? `local-${Date.now()}`; - const projectId = this.lastEndpoints[0]?.projectId ?? this.projectId ?? "local"; - const fileEndpoints = this.lastEndpoints.filter((ep) => ep.files.includes(finding.affectedFile)); - const related = fileEndpoints.map((endpoint) => endpoint.id); - const closestEndpoint = findClosestEndpoint( - { affectedFile: finding.affectedFile, line: finding.targetLine }, - fileEndpoints - ); - const directCost = closestEndpoint?.monthlyCost ?? 0; - const fileMonthlyCost = fileEndpoints.reduce((sum, ep) => sum + ep.monthlyCost, 0); - const monthlyBaseline = directCost > 0 - ? directCost - : fileMonthlyCost > 0 - ? fileMonthlyCost - : 0; // unknown — no savings estimate - - return { - id: `ai-${Date.now()}-${index + 1}`, - projectId, - scanId, - type: finding.type, - severity: finding.severity, - affectedEndpoints: related, - affectedFiles: [finding.affectedFile], - targetLine: finding.targetLine, - estimatedMonthlySavings: calculateSavings(finding.type, finding.severity, monthlyBaseline), - description: finding.description, - codeFix: "", - source: "ai", - confidence: finding.confidence, - evidence: finding.evidence, - reviewedAt: new Date().toISOString(), - pricingClass: classifyPricing(fileEndpoints.map((ep) => ep.costModel)), - }; - } - - private mergeAiSuggestions(existing: Suggestion[], incoming: Suggestion[]): { merged: Suggestion[]; added: number; filtered: number } { - const existingByKey = new Set(); - const deterministicOverlap = new Map(); - - for (const suggestion of existing) { - const file = suggestion.affectedFiles[0] ?? ""; - const line = suggestion.targetLine ?? 0; - const key = `${suggestion.type}|${file}|${line}|${normalizeDescription(suggestion.description)}`; - existingByKey.add(key); - if (file && suggestion.source !== "ai") { - const overlapKey = `${suggestion.type}|${file}`; - const lines = deterministicOverlap.get(overlapKey) ?? []; - lines.push(line); - deterministicOverlap.set(overlapKey, lines); - } - } - - const aiByKey = new Set(); - const accepted: Suggestion[] = []; - let filtered = 0; - - for (const suggestion of incoming) { - const file = suggestion.affectedFiles[0] ?? ""; - const line = suggestion.targetLine ?? 0; - const key = `${suggestion.type}|${file}|${line}|${normalizeDescription(suggestion.description)}`; - if (existingByKey.has(key) || aiByKey.has(key)) { - filtered += 1; - continue; - } - - const overlapKey = `${suggestion.type}|${file}`; - const overlapLines = deterministicOverlap.get(overlapKey) ?? []; - const nearDeterministic = overlapLines.some((knownLine) => Math.abs(knownLine - line) <= 5); - if (nearDeterministic) { - filtered += 1; - continue; - } - - aiByKey.add(key); - accepted.push(suggestion); - } - - return { merged: [...existing, ...accepted], added: accepted.length, filtered }; - } - - private async handleRunAiReview() { - const { enabled, minConfidence, maxFiles, maxCharsPerFile, fallbackModel } = this.getAiReviewConfig(); - if (!enabled) { - this.postMessage({ type: "aiReviewError", message: "AI review is disabled in settings." }); - return; - } - if (this.lastEndpoints.length === 0 && this.lastSuggestions.length === 0) { - this.postMessage({ type: "aiReviewError", message: "Run a scan before AI review." }); - return; - } - - try { - const { providerId, model } = this.resolveAiReviewSelection(fallbackModel); - const provider = getProviderAdapter(providerId); - this.postMessage({ type: "aiReviewProgress", stage: "Collecting files..." }); - const input = await this.buildAiReviewInputContext(maxFiles, maxCharsPerFile); - if (input.files.length === 0) { - this.postMessage({ type: "aiReviewComplete", added: 0, filtered: 0 }); - return; - } - - this.postMessage({ type: "aiReviewProgress", stage: `Calling ${provider.displayName}...` }); - const response = await this.executeAiReviewRequest({ - provider: providerId, - model, - temperature: providerId === "recost" ? undefined : 0.1, - stream: false, - messages: [ - { - role: "system", - content: "You are a strict API efficiency reviewer. Return only JSON.", - }, - { - role: "user", - content: this.buildAiReviewPrompt(input), - }, - ], - }); - - const raw = response.content ?? ""; - this.postMessage({ type: "aiReviewProgress", stage: "Validating findings..." }); - const validFiles = new Set(input.files.map((file) => file.path)); - const { accepted, filtered } = this.parseAndValidateAiFindings(raw, validFiles, minConfidence); - const aiSuggestions = accepted.map((finding, index) => this.mapAiFindingToSuggestion(finding, index)); - const merged = this.mergeAiSuggestions(this.lastSuggestions, aiSuggestions); - - this.lastSuggestions = merged.merged; - const summary = this.lastSummary ?? { - totalEndpoints: this.lastEndpoints.length, - totalCallsPerDay: this.lastEndpoints.reduce((sum, endpoint) => sum + endpoint.callsPerDay, 0), - totalMonthlyCost: this.lastEndpoints.reduce((sum, endpoint) => sum + endpoint.monthlyCost, 0), - highRiskCount: 0, - }; - const updatedSummary: ScanSummary = { - ...summary, - totalEndpoints: Math.max(summary.totalEndpoints, this.lastEndpoints.length), - highRiskCount: this.lastSuggestions.filter((suggestion) => suggestion.severity === "high").length, - }; - this.lastSummary = updatedSummary; - - this.logAiReview( - `provider=${providerId} model=${model} files=${input.files.length} raw=${accepted.length + filtered} accepted=${merged.added} filtered=${filtered + merged.filtered}` - ); - - this.postMessage({ - type: "scanResults", - endpoints: this.lastEndpoints, - suggestions: this.lastSuggestions, - summary: updatedSummary, - }); - this.postMessage({ type: "aiReviewComplete", added: merged.added, filtered: filtered + merged.filtered }); - } catch (err: unknown) { - const chatError = err as ChatAdapterError; - const { providerId } = this.resolveAiReviewSelection(fallbackModel); - const serviceId = this.getKeyServiceIdForProvider(providerId); - if (chatError?.code === "bad_auth") { - if (serviceId) { - const apiKey = await this.getStoredProviderApiKey(providerId); - if (apiKey) { - await this.setValidationState(serviceId, { - state: "invalid", - message: chatError.message, - lastCheckedAt: new Date().toISOString(), - keyFingerprint: buildKeyFingerprint(apiKey), - }); - } else { - await this.clearValidationState(serviceId); - } - await this.sendKeyStatusUpdate(serviceId, serviceId); - } - this.openKeys(serviceId); - this.postMessage({ type: "aiReviewError", message: chatError.message }); - return; - } - if (chatError?.code === "missing_api_key") { - if (serviceId) { - await this.clearValidationState(serviceId); - await this.sendKeyStatusUpdate(serviceId, serviceId); - } - this.openKeys(serviceId); - this.postMessage({ type: "aiReviewError", message: chatError.message }); - return; - } - const message = err instanceof Error ? err.message : "AI review failed"; - this.logAiReview(`error=${message}`); - this.postMessage({ type: "aiReviewError", message }); - } + private handleRunAiReview() { + return this.chatHandler.handleRunAiReview(); } private async getOrCreateProject(rcApiKey?: string): Promise { @@ -1922,148 +605,16 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { return readStoredSecret(getKeyService("recost"), this.context.secrets); } - private restoreKeyValidationState() { - const stored = - this.context.globalState.get>>( - ReCostSidebarProvider.KEY_VALIDATION_STATE_STORAGE_KEY - ) ?? {}; - for (const [serviceId, snapshot] of Object.entries(stored) as [KeyServiceId, PersistedKeyValidationSnapshot | undefined][]) { - if (snapshot) { - this.keyValidationState.set(serviceId, snapshot); - } - } - } - - private async persistKeyValidationState() { - await this.context.globalState.update( - ReCostSidebarProvider.KEY_VALIDATION_STATE_STORAGE_KEY, - Object.fromEntries(this.keyValidationState.entries()) - ); - } - private async clearValidationState(serviceId: KeyServiceId) { - this.keyValidationState.delete(serviceId); - await this.persistKeyValidationState(); + return this.keyManagementHandler.clearValidationState(serviceId); } private async setValidationState(serviceId: KeyServiceId, snapshot: PersistedKeyValidationSnapshot) { - this.keyValidationState.set(serviceId, snapshot); - await this.persistKeyValidationState(); + return this.keyManagementHandler.setValidationState(serviceId, snapshot); } - private async getValidationSnapshot(serviceId: KeyServiceId): Promise { - const snapshot = this.keyValidationState.get(serviceId); - if (!snapshot) return undefined; - const service = getKeyService(serviceId); - const currentValue = await resolveCurrentKeyValue(service, this.context.secrets); - if (!currentValue || snapshot.keyFingerprint !== buildKeyFingerprint(currentValue)) { - await this.clearValidationState(serviceId); - return undefined; - } - return snapshot; - } - - private async buildKeyStatus(service: ReturnType): Promise { - return buildKeyStatusSummary( - service, - this.context.secrets, - await this.getValidationSnapshot(service.serviceId) - ); - } - - private buildMessages(text: string, limitContext = false): NormalizedChatMessage[] { - const suggestions = limitContext ? this.lastSuggestions.slice(0, 5) : this.lastSuggestions; - const endpoints = limitContext ? this.lastEndpoints.slice(0, 8) : this.lastEndpoints; - return [ - { role: "system", content: buildSystemPrompt(this.lastSummary, suggestions, endpoints) }, - ...this.chatHistory, - { role: "user", content: text }, - ]; - } - - private async executeProviderRequest(request: NormalizedChatRequest) { - return executeChat({ - request, - secrets: this.context.secrets, - onChunk: async (chunk) => { - if (chunk.delta) { - this.postMessage({ type: "chatStreaming", chunk: chunk.delta }); - } - }, - }); - } - - private async handleChat(text: string, providerId: string, model: string) { - const provider = getProviderAdapter(providerId); - const modelMeta = findModelMetadata(providerId, model); - const messages = this.buildMessages(text, providerId === "recost"); - const baseRequest: NormalizedChatRequest = { - provider: providerId, - model, - messages, - temperature: providerId === "recost" ? undefined : 0.7, - stream: provider.supportsStreaming && (modelMeta?.supportsStreaming ?? provider.supportsStreaming), - }; - - try { - let response; - const requiresFallback = providerId === "openai" && modelMeta?.reasoning; - if (requiresFallback) { - try { - response = await this.executeProviderRequest({ ...baseRequest, stream: false }); - } catch (error) { - const chatError = error as ChatAdapterError; - if (chatError?.status === 400) { - response = await this.executeProviderRequest({ - ...baseRequest, - stream: false, - messages: messages.filter((message) => message.role !== "system"), - }); - } else { - throw error; - } - } - } else { - response = await this.executeProviderRequest(baseRequest); - } - - this.chatHistory.push({ role: "user", content: text }); - this.chatHistory.push({ role: "assistant", content: response.content }); - this.postMessage({ type: "chatDone", fullContent: response.content }); - } catch (error) { - const chatError = error as ChatAdapterError; - const serviceId = this.getKeyServiceIdForProvider(providerId); - if (chatError?.code === "bad_auth") { - if (serviceId) { - const apiKey = await this.getStoredProviderApiKey(providerId); - if (apiKey) { - await this.setValidationState(serviceId, { - state: "invalid", - message: chatError.message, - lastCheckedAt: new Date().toISOString(), - keyFingerprint: buildKeyFingerprint(apiKey), - }); - } else { - await this.clearValidationState(serviceId); - } - await this.sendKeyStatusUpdate(serviceId, serviceId); - } - this.openKeys(serviceId); - this.postMessage({ type: "chatError", message: chatError.message }); - return; - } - if (chatError?.code === "missing_api_key") { - if (serviceId) { - await this.clearValidationState(serviceId); - await this.sendKeyStatusUpdate(serviceId, serviceId); - } - this.openKeys(serviceId); - this.postMessage({ type: "chatError", message: chatError.message }); - return; - } - const message = error instanceof Error ? error.message : "Network error. Check your connection."; - this.postMessage({ type: "chatError", message }); - } + private handleChat(text: string, provider: string, model: string) { + return this.chatHandler.handleChat(text, provider, model); } private async handleApplyFix(code: string, file: string, line?: number) { diff --git a/src/webview/chat-handler.ts b/src/webview/chat-handler.ts new file mode 100644 index 0000000..0a0cbe8 --- /dev/null +++ b/src/webview/chat-handler.ts @@ -0,0 +1,707 @@ +import * as vscode from "vscode"; +import type { HostMessage, KeyServiceId } from "../messages"; +import type { PersistedKeyValidationSnapshot } from "../key-management"; +import type { EndpointRecord, Suggestion, ScanSummary } from "../analysis/types"; +import { buildSystemPrompt } from "../chat/prompts"; +import { readWorkspaceFileExcerpt } from "../scanner/workspace-scanner"; +import { classifyPricing, calculateSavings } from "../scan-results"; +import { buildKeyFingerprint } from "../key-management"; +import { + buildProviderOptions, + executeChat, + findModelMetadata, + getProviderAdapter, + ChatAdapterError, + type ChatProviderId, + type NormalizedChatMessage, + type NormalizedChatRequest, +} from "../chat"; +// Local copies of small pure helpers used here. Avoid importing from +// webview-provider.ts to prevent a circular import. Originals remain in +// webview-provider.ts where non-chat code also uses them. +function normalizeDescription(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim(); +} + +function trimText(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max)}...`; +} + +function clampConfidence(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} + +const PROXIMITY_THRESHOLD_LINES = 25; + +function findClosestEndpoint( + finding: { affectedFile: string; line?: number }, + fileEndpoints: EndpointRecord[] +): EndpointRecord | null { + if (!finding.line || fileEndpoints.length === 0) return null; + + let closest: EndpointRecord | null = null; + let closestDistance = Infinity; + + for (const ep of fileEndpoints) { + // Skip route-def endpoints — they have monthlyCost === 0 and would + // produce misleading $0 savings estimates + if (ep.monthlyCost === 0 && ep.callSites.every(s => s.library === "route-def")) continue; + + for (const site of ep.callSites) { + if (site.file !== finding.affectedFile) continue; + const distance = Math.abs(site.line - finding.line); + if (distance < closestDistance) { + closestDistance = distance; + closest = ep; + } + } + } + + return closestDistance <= PROXIMITY_THRESHOLD_LINES ? closest : null; +} + +export interface ChatMessage { + role: "user" | "assistant"; + content: string; +} + +interface AiFinding { + type: Suggestion["type"]; + severity: Suggestion["severity"]; + confidence: number; + description: string; + affectedFile: string; + targetLine?: number; + evidence: string[]; +} + +interface AiPromptFile { + path: string; + snippet: string; + startLine: number; + endLine: number; +} + +interface AiReviewInput { + files: AiPromptFile[]; + summary: ScanSummary | null; + endpoints: Array<{ + id: string; + method: string; + url: string; + status: EndpointRecord["status"]; + monthlyCost: number; + files: string[]; + }>; + suggestions: Array<{ + type: Suggestion["type"]; + severity: Suggestion["severity"]; + description: string; + affectedFiles: string[]; + }>; +} + +export interface ChatHandlerContext { + postMessage(message: HostMessage): void; + outputChannel: vscode.OutputChannel; + context: vscode.ExtensionContext; + getSelectedChatProvider(): ChatProviderId; + getSelectedChatModel(): string; + // Scan state read accessors + getLastEndpoints(): EndpointRecord[]; + getLastSuggestions(): Suggestion[]; + getLastSummary(): ScanSummary | null; + getProjectId(): string | null; + // Scan state write accessors (AI review mutates these) + setLastSuggestions(suggestions: Suggestion[]): void; + setLastSummary(summary: ScanSummary | null): void; + // Key management callables (non-chat-related concerns) + getKeyServiceIdForProvider(providerId: string): KeyServiceId | undefined; + getStoredProviderApiKey(providerId: string): Promise; + setValidationState(serviceId: KeyServiceId, snapshot: PersistedKeyValidationSnapshot): Promise; + clearValidationState(serviceId: KeyServiceId): Promise; + sendKeyStatusUpdate(serviceId: KeyServiceId, focusServiceId?: KeyServiceId): Promise; + openKeys(focusServiceId?: KeyServiceId): void; +} + +export class ChatHandler { + private chatHistory: ChatMessage[] = []; + + constructor(private readonly ctx: ChatHandlerContext) {} + + public resetHistory(): void { + this.chatHistory = []; + } + + public async sendChatConfig( + providerId: ChatProviderId = this.ctx.getSelectedChatProvider(), + model: string = this.ctx.getSelectedChatModel() + ) { + this.ctx.postMessage({ + type: "chatConfig", + providers: buildProviderOptions(), + selectedProvider: providerId, + selectedModel: model, + }); + } + + private logAiReview(message: string) { + const stamp = new Date().toISOString(); + this.ctx.outputChannel.appendLine(`[${stamp}] ${message}`); + } + + private getAiReviewConfig() { + const config = vscode.workspace.getConfiguration("recost"); + return { + enabled: config.get("aiReview.enabled", true), + minConfidence: config.get("aiReview.minConfidence", 0.7), + maxFiles: config.get("aiReview.maxFiles", 25), + maxCharsPerFile: config.get("aiReview.maxCharsPerFile", 6000), + fallbackModel: config.get("aiReview.model", "gpt-4.1-mini"), + }; + } + + private resolveAiReviewSelection(fallbackModel: string): { providerId: ChatProviderId; model: string } { + const providerId = this.ctx.getSelectedChatProvider(); + const provider = getProviderAdapter(providerId); + const selectedModel = this.ctx.getSelectedChatModel(); + if (provider.models.some((entry) => entry.id === selectedModel)) { + return { providerId, model: selectedModel }; + } + if (providerId === "openai" && provider.models.some((entry) => entry.id === fallbackModel)) { + return { providerId, model: fallbackModel }; + } + return { providerId, model: provider.models[0]?.id ?? fallbackModel }; + } + + private async executeAiReviewRequest(request: NormalizedChatRequest) { + const modelMeta = findModelMetadata(request.provider, request.model); + const requiresFallback = request.provider === "openai" && modelMeta?.reasoning; + if (!requiresFallback) { + return executeChat({ request, secrets: this.ctx.context.secrets }); + } + try { + return await executeChat({ request: { ...request, stream: false }, secrets: this.ctx.context.secrets }); + } catch (error) { + const chatError = error as ChatAdapterError; + if (chatError?.status !== 400) { + throw error; + } + return executeChat({ + request: { + ...request, + stream: false, + messages: request.messages.filter((message) => message.role !== "system"), + }, + secrets: this.ctx.context.secrets, + }); + } + } + + private redactSensitiveText(value: string): string { + return value + .replace(/\brc-[a-zA-Z0-9_-]{8,}\b/g, "[REDACTED_RECOST_KEY]") + .replace(/sk-[a-zA-Z0-9]{16,}/g, "[REDACTED_OPENAI_KEY]") + .replace(/(api[_-]?key|token|secret)\s*[:=]\s*["'`][^"'`\n]{8,}["'`]/gi, "$1=[REDACTED]") + .replace(/(authorization\s*:\s*["'`]bearer\s+)[^"'`\n]+/gi, "$1[REDACTED]"); + } + + private async buildAiReviewInputContext(maxFiles: number, maxCharsPerFile: number): Promise { + const scoreByFile = new Map(); + const lineHintByFile = new Map(); + const severityScore: Record = { high: 4, medium: 2, low: 1 }; + + for (const suggestion of this.ctx.getLastSuggestions()) { + for (const file of suggestion.affectedFiles) { + scoreByFile.set(file, (scoreByFile.get(file) ?? 0) + severityScore[suggestion.severity]); + if (suggestion.targetLine && !lineHintByFile.has(file)) { + lineHintByFile.set(file, suggestion.targetLine); + } + } + } + + for (const endpoint of this.ctx.getLastEndpoints()) { + const endpointScore = + endpoint.status === "n_plus_one_risk" || endpoint.status === "redundant" ? 4 : + endpoint.status === "rate_limit_risk" ? 3 : + endpoint.status === "cacheable" || endpoint.status === "batchable" ? 2 : + 1; + for (const callSite of endpoint.callSites) { + scoreByFile.set(callSite.file, (scoreByFile.get(callSite.file) ?? 0) + endpointScore); + if (!lineHintByFile.has(callSite.file)) { + lineHintByFile.set(callSite.file, callSite.line); + } + } + } + + const rankedFiles = [...scoreByFile.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, Math.max(1, maxFiles)) + .map(([file]) => file); + + const files: AiPromptFile[] = []; + for (let i = 0; i < rankedFiles.length; i += 1) { + const file = rankedFiles[i]; + this.ctx.postMessage({ + type: "aiReviewProgress", + stage: `Preparing context (${i + 1}/${rankedFiles.length})`, + current: i + 1, + total: rankedFiles.length, + }); + + const excerpt = await readWorkspaceFileExcerpt(file, { + centerLine: lineHintByFile.get(file), + contextLines: 40, + maxChars: maxCharsPerFile, + }); + if (!excerpt || !excerpt.content.trim()) continue; + files.push({ + path: file, + startLine: excerpt.startLine, + endLine: excerpt.endLine, + snippet: this.redactSensitiveText(excerpt.content), + }); + } + + return { + files, + summary: this.ctx.getLastSummary(), + endpoints: this.ctx.getLastEndpoints().map((endpoint) => ({ + id: endpoint.id, + method: endpoint.method, + url: endpoint.url, + status: endpoint.status, + monthlyCost: endpoint.monthlyCost, + files: endpoint.files, + })), + suggestions: this.ctx.getLastSuggestions().map((suggestion) => ({ + type: suggestion.type, + severity: suggestion.severity, + description: suggestion.description, + affectedFiles: suggestion.affectedFiles, + })), + }; + } + + private buildAiReviewPrompt(input: AiReviewInput): string { + const contract = { + findings: [ + { + type: "cache | batch | redundancy | n_plus_one | rate_limit", + severity: "high | medium | low", + confidence: 0.0, + description: "short, specific finding", + affectedFile: "path/to/file.ts", + targetLine: 1, + evidence: ["short reason 1", "short reason 2"], + }, + ], + }; + + return [ + "You are an API efficiency code reviewer.", + "Analyze only the provided snippets and existing scan context.", + "Return ONLY valid JSON with no markdown and no extra text.", + "Do not invent files. Use only provided file paths.", + "Prefer high precision over recall.", + `JSON contract: ${JSON.stringify(contract)}`, + `Context: ${JSON.stringify(input)}`, + ].join("\n"); + } + + private parseAndValidateAiFindings( + raw: string, + validFiles: Set, + minConfidence: number + ): { accepted: AiFinding[]; filtered: number } { + const allowedTypes = new Set(["cache", "batch", "redundancy", "n_plus_one", "rate_limit"]); + const allowedSeverity = new Set(["high", "medium", "low"]); + + const tryParse = (value: string): unknown => { + try { + return JSON.parse(value); + } catch { + return null; + } + }; + + let parsed = tryParse(raw); + if (!parsed) { + const fenced = raw.match(/```json\s*([\s\S]*?)\s*```/i) ?? raw.match(/```\s*([\s\S]*?)\s*```/i); + if (fenced) { + parsed = tryParse(fenced[1]); + } + } + if (!parsed) { + const start = raw.indexOf("{"); + const end = raw.lastIndexOf("}"); + if (start >= 0 && end > start) { + parsed = tryParse(raw.slice(start, end + 1)); + } + } + + const findings = (parsed as { findings?: unknown })?.findings; + if (!Array.isArray(findings)) { + return { accepted: [], filtered: 0 }; + } + + const accepted: AiFinding[] = []; + let filtered = 0; + for (const entry of findings) { + if (accepted.length >= 50) { + filtered += 1; + continue; + } + if (!entry || typeof entry !== "object") { + filtered += 1; + continue; + } + const candidate = entry as Record; + const type = candidate.type; + const severity = candidate.severity; + const affectedFile = candidate.affectedFile; + const description = candidate.description; + if ( + typeof type !== "string" || + typeof severity !== "string" || + typeof affectedFile !== "string" || + typeof description !== "string" + ) { + filtered += 1; + continue; + } + if (!allowedTypes.has(type as Suggestion["type"]) || !allowedSeverity.has(severity as Suggestion["severity"])) { + filtered += 1; + continue; + } + if (!validFiles.has(affectedFile)) { + filtered += 1; + continue; + } + + const confidence = clampConfidence(Number(candidate.confidence)); + if (confidence < minConfidence) { + filtered += 1; + continue; + } + + const rawLine = Number(candidate.targetLine); + const targetLine = Number.isFinite(rawLine) && rawLine > 0 ? Math.floor(rawLine) : undefined; + const evidence = Array.isArray(candidate.evidence) + ? candidate.evidence.filter((item): item is string => typeof item === "string").slice(0, 4).map((item) => trimText(item, 180)) + : []; + + accepted.push({ + type: type as Suggestion["type"], + severity: severity as Suggestion["severity"], + confidence, + description: trimText(description.trim(), 500), + affectedFile, + targetLine, + evidence, + }); + } + + return { accepted, filtered }; + } + + private mapAiFindingToSuggestion(finding: AiFinding, index: number): Suggestion { + const lastEndpoints = this.ctx.getLastEndpoints(); + const providerProjectId = this.ctx.getProjectId(); + const scanId = lastEndpoints[0]?.scanId ?? providerProjectId ?? `local-${Date.now()}`; + const projectId = lastEndpoints[0]?.projectId ?? providerProjectId ?? "local"; + const fileEndpoints = lastEndpoints.filter((ep) => ep.files.includes(finding.affectedFile)); + const related = fileEndpoints.map((endpoint) => endpoint.id); + const closestEndpoint = findClosestEndpoint( + { affectedFile: finding.affectedFile, line: finding.targetLine }, + fileEndpoints + ); + const directCost = closestEndpoint?.monthlyCost ?? 0; + const fileMonthlyCost = fileEndpoints.reduce((sum, ep) => sum + ep.monthlyCost, 0); + const monthlyBaseline = directCost > 0 + ? directCost + : fileMonthlyCost > 0 + ? fileMonthlyCost + : 0; // unknown — no savings estimate + + return { + id: `ai-${Date.now()}-${index + 1}`, + projectId, + scanId, + type: finding.type, + severity: finding.severity, + affectedEndpoints: related, + affectedFiles: [finding.affectedFile], + targetLine: finding.targetLine, + estimatedMonthlySavings: calculateSavings(finding.type, finding.severity, monthlyBaseline), + description: finding.description, + codeFix: "", + source: "ai", + confidence: finding.confidence, + evidence: finding.evidence, + reviewedAt: new Date().toISOString(), + pricingClass: classifyPricing(fileEndpoints.map((ep) => ep.costModel)), + }; + } + + private mergeAiSuggestions(existing: Suggestion[], incoming: Suggestion[]): { merged: Suggestion[]; added: number; filtered: number } { + const existingByKey = new Set(); + const deterministicOverlap = new Map(); + + for (const suggestion of existing) { + const file = suggestion.affectedFiles[0] ?? ""; + const line = suggestion.targetLine ?? 0; + const key = `${suggestion.type}|${file}|${line}|${normalizeDescription(suggestion.description)}`; + existingByKey.add(key); + if (file && suggestion.source !== "ai") { + const overlapKey = `${suggestion.type}|${file}`; + const lines = deterministicOverlap.get(overlapKey) ?? []; + lines.push(line); + deterministicOverlap.set(overlapKey, lines); + } + } + + const aiByKey = new Set(); + const accepted: Suggestion[] = []; + let filtered = 0; + + for (const suggestion of incoming) { + const file = suggestion.affectedFiles[0] ?? ""; + const line = suggestion.targetLine ?? 0; + const key = `${suggestion.type}|${file}|${line}|${normalizeDescription(suggestion.description)}`; + if (existingByKey.has(key) || aiByKey.has(key)) { + filtered += 1; + continue; + } + + const overlapKey = `${suggestion.type}|${file}`; + const overlapLines = deterministicOverlap.get(overlapKey) ?? []; + const nearDeterministic = overlapLines.some((knownLine) => Math.abs(knownLine - line) <= 5); + if (nearDeterministic) { + filtered += 1; + continue; + } + + aiByKey.add(key); + accepted.push(suggestion); + } + + return { merged: [...existing, ...accepted], added: accepted.length, filtered }; + } + + public async handleRunAiReview() { + const { enabled, minConfidence, maxFiles, maxCharsPerFile, fallbackModel } = this.getAiReviewConfig(); + if (!enabled) { + this.ctx.postMessage({ type: "aiReviewError", message: "AI review is disabled in settings." }); + return; + } + const lastEndpoints = this.ctx.getLastEndpoints(); + const lastSuggestions = this.ctx.getLastSuggestions(); + if (lastEndpoints.length === 0 && lastSuggestions.length === 0) { + this.ctx.postMessage({ type: "aiReviewError", message: "Run a scan before AI review." }); + return; + } + + try { + const { providerId, model } = this.resolveAiReviewSelection(fallbackModel); + const provider = getProviderAdapter(providerId); + this.ctx.postMessage({ type: "aiReviewProgress", stage: "Collecting files..." }); + const input = await this.buildAiReviewInputContext(maxFiles, maxCharsPerFile); + if (input.files.length === 0) { + this.ctx.postMessage({ type: "aiReviewComplete", added: 0, filtered: 0 }); + return; + } + + this.ctx.postMessage({ type: "aiReviewProgress", stage: `Calling ${provider.displayName}...` }); + const response = await this.executeAiReviewRequest({ + provider: providerId, + model, + temperature: providerId === "recost" ? undefined : 0.1, + stream: false, + messages: [ + { + role: "system", + content: "You are a strict API efficiency reviewer. Return only JSON.", + }, + { + role: "user", + content: this.buildAiReviewPrompt(input), + }, + ], + }); + + const raw = response.content ?? ""; + this.ctx.postMessage({ type: "aiReviewProgress", stage: "Validating findings..." }); + const validFiles = new Set(input.files.map((file) => file.path)); + const { accepted, filtered } = this.parseAndValidateAiFindings(raw, validFiles, minConfidence); + const aiSuggestions = accepted.map((finding, index) => this.mapAiFindingToSuggestion(finding, index)); + const currentSuggestions = this.ctx.getLastSuggestions(); + const merged = this.mergeAiSuggestions(currentSuggestions, aiSuggestions); + + this.ctx.setLastSuggestions(merged.merged); + const currentEndpoints = this.ctx.getLastEndpoints(); + const currentSummary = this.ctx.getLastSummary(); + const summary = currentSummary ?? { + totalEndpoints: currentEndpoints.length, + totalCallsPerDay: currentEndpoints.reduce((sum, endpoint) => sum + endpoint.callsPerDay, 0), + totalMonthlyCost: currentEndpoints.reduce((sum, endpoint) => sum + endpoint.monthlyCost, 0), + highRiskCount: 0, + }; + const updatedSuggestions = this.ctx.getLastSuggestions(); + const updatedSummary: ScanSummary = { + ...summary, + totalEndpoints: Math.max(summary.totalEndpoints, currentEndpoints.length), + highRiskCount: updatedSuggestions.filter((suggestion) => suggestion.severity === "high").length, + }; + this.ctx.setLastSummary(updatedSummary); + + this.logAiReview( + `provider=${providerId} model=${model} files=${input.files.length} raw=${accepted.length + filtered} accepted=${merged.added} filtered=${filtered + merged.filtered}` + ); + + this.ctx.postMessage({ + type: "scanResults", + endpoints: currentEndpoints, + suggestions: updatedSuggestions, + summary: updatedSummary, + }); + this.ctx.postMessage({ type: "aiReviewComplete", added: merged.added, filtered: filtered + merged.filtered }); + } catch (err: unknown) { + const chatError = err as ChatAdapterError; + const { providerId } = this.resolveAiReviewSelection(fallbackModel); + const serviceId = this.ctx.getKeyServiceIdForProvider(providerId); + if (chatError?.code === "bad_auth") { + if (serviceId) { + const apiKey = await this.ctx.getStoredProviderApiKey(providerId); + if (apiKey) { + await this.ctx.setValidationState(serviceId, { + state: "invalid", + message: chatError.message, + lastCheckedAt: new Date().toISOString(), + keyFingerprint: buildKeyFingerprint(apiKey), + }); + } else { + await this.ctx.clearValidationState(serviceId); + } + await this.ctx.sendKeyStatusUpdate(serviceId, serviceId); + } + this.ctx.openKeys(serviceId); + this.ctx.postMessage({ type: "aiReviewError", message: chatError.message }); + return; + } + if (chatError?.code === "missing_api_key") { + if (serviceId) { + await this.ctx.clearValidationState(serviceId); + await this.ctx.sendKeyStatusUpdate(serviceId, serviceId); + } + this.ctx.openKeys(serviceId); + this.ctx.postMessage({ type: "aiReviewError", message: chatError.message }); + return; + } + const message = err instanceof Error ? err.message : "AI review failed"; + this.logAiReview(`error=${message}`); + this.ctx.postMessage({ type: "aiReviewError", message }); + } + } + + private buildMessages(text: string, limitContext = false): NormalizedChatMessage[] { + const lastSuggestions = this.ctx.getLastSuggestions(); + const lastEndpoints = this.ctx.getLastEndpoints(); + const lastSummary = this.ctx.getLastSummary(); + const suggestions = limitContext ? lastSuggestions.slice(0, 5) : lastSuggestions; + const endpoints = limitContext ? lastEndpoints.slice(0, 8) : lastEndpoints; + return [ + { role: "system", content: buildSystemPrompt(lastSummary, suggestions, endpoints) }, + ...this.chatHistory, + { role: "user", content: text }, + ]; + } + + private async executeProviderRequest(request: NormalizedChatRequest) { + return executeChat({ + request, + secrets: this.ctx.context.secrets, + onChunk: async (chunk) => { + if (chunk.delta) { + this.ctx.postMessage({ type: "chatStreaming", chunk: chunk.delta }); + } + }, + }); + } + + public async handleChat(text: string, providerId: string, model: string) { + const provider = getProviderAdapter(providerId); + const modelMeta = findModelMetadata(providerId, model); + const messages = this.buildMessages(text, providerId === "recost"); + const baseRequest: NormalizedChatRequest = { + provider: providerId, + model, + messages, + temperature: providerId === "recost" ? undefined : 0.7, + stream: provider.supportsStreaming && (modelMeta?.supportsStreaming ?? provider.supportsStreaming), + }; + + try { + let response; + const requiresFallback = providerId === "openai" && modelMeta?.reasoning; + if (requiresFallback) { + try { + response = await this.executeProviderRequest({ ...baseRequest, stream: false }); + } catch (error) { + const chatError = error as ChatAdapterError; + if (chatError?.status === 400) { + response = await this.executeProviderRequest({ + ...baseRequest, + stream: false, + messages: messages.filter((message) => message.role !== "system"), + }); + } else { + throw error; + } + } + } else { + response = await this.executeProviderRequest(baseRequest); + } + + this.chatHistory.push({ role: "user", content: text }); + this.chatHistory.push({ role: "assistant", content: response.content }); + this.ctx.postMessage({ type: "chatDone", fullContent: response.content }); + } catch (error) { + const chatError = error as ChatAdapterError; + const serviceId = this.ctx.getKeyServiceIdForProvider(providerId); + if (chatError?.code === "bad_auth") { + if (serviceId) { + const apiKey = await this.ctx.getStoredProviderApiKey(providerId); + if (apiKey) { + await this.ctx.setValidationState(serviceId, { + state: "invalid", + message: chatError.message, + lastCheckedAt: new Date().toISOString(), + keyFingerprint: buildKeyFingerprint(apiKey), + }); + } else { + await this.ctx.clearValidationState(serviceId); + } + await this.ctx.sendKeyStatusUpdate(serviceId, serviceId); + } + this.ctx.openKeys(serviceId); + this.ctx.postMessage({ type: "chatError", message: chatError.message }); + return; + } + if (chatError?.code === "missing_api_key") { + if (serviceId) { + await this.ctx.clearValidationState(serviceId); + await this.ctx.sendKeyStatusUpdate(serviceId, serviceId); + } + this.ctx.openKeys(serviceId); + this.ctx.postMessage({ type: "chatError", message: chatError.message }); + return; + } + const message = error instanceof Error ? error.message : "Network error. Check your connection."; + this.ctx.postMessage({ type: "chatError", message }); + } + } +} diff --git a/src/webview/key-management-handler.ts b/src/webview/key-management-handler.ts new file mode 100644 index 0000000..d28cad9 --- /dev/null +++ b/src/webview/key-management-handler.ts @@ -0,0 +1,210 @@ +import * as vscode from "vscode"; +import type { HostMessage, KeyServiceId, KeyStatusSummary } from "../messages"; +import { + buildKeyFingerprint, + buildKeyStatusSummary, + getKeyService, + listKeyServices, + maskKeyPreview, + readStoredSecret, + resolveCurrentKeyValue, + validateServiceKey, + type PersistedKeyValidationSnapshot, +} from "../key-management"; + +export interface KeyManagementHandlerContext { + postMessage(message: HostMessage): void; + context: vscode.ExtensionContext; + outputChannel: vscode.OutputChannel; + // For UI navigation only: + openKeys(focusServiceId?: KeyServiceId): void; + // Project-id coupling: setServiceKey/clearServiceKey must trigger project-id + // revalidation when the recost key changes. The provider continues to own + // the project-id family (separate storage keys + workspaceState). + getManualProjectId(): string | null; + clearProjectIdValidationState(): Promise; + sendProjectIdStatus(): Promise; + validateManualProjectId(): Promise; +} + +export class KeyManagementHandler { + private static readonly KEY_VALIDATION_STATE_STORAGE_KEY = "recost.keyValidationState"; + + private readonly keyValidationState = new Map(); + + constructor(private readonly ctx: KeyManagementHandlerContext) { + this.restoreKeyValidationState(); + } + + private get context(): vscode.ExtensionContext { + return this.ctx.context; + } + + private postMessage(message: HostMessage): void { + this.ctx.postMessage(message); + } + + public getKeyServiceIdForProvider(providerId: string): KeyServiceId | undefined { + return listKeyServices().find((service) => service.providerId === providerId)?.serviceId; + } + + public async getStoredProviderApiKey(providerId: string): Promise { + const serviceId = this.getKeyServiceIdForProvider(providerId); + if (!serviceId) return undefined; + return readStoredSecret(getKeyService(serviceId), this.context.secrets); + } + + public async buildAllKeyStatuses(): Promise { + const services = listKeyServices(); + return Promise.all( + services.map((service) => + this.buildKeyStatus(service) + ) + ); + } + + public async sendAllKeyStatuses(focusServiceId?: KeyServiceId) { + this.postMessage({ type: "allKeyStatuses", statuses: await this.buildAllKeyStatuses(), focusServiceId }); + } + + public async sendKeyStatusUpdate(serviceId: KeyServiceId, focusServiceId?: KeyServiceId) { + const service = getKeyService(serviceId); + const status = await this.buildKeyStatus(service); + this.postMessage({ type: "keyStatusUpdated", status, focusServiceId }); + } + + public async clearServiceKey(serviceId: KeyServiceId) { + const service = getKeyService(serviceId); + if (service.secretStorageKey) { + await this.context.secrets.delete(service.secretStorageKey); + } + if (serviceId === "openai") { + await this.context.secrets.delete("recost.openaiApiKey"); + } + await this.clearValidationState(serviceId); + await this.sendKeyStatusUpdate(serviceId); + if (serviceId === "recost") { + await this.ctx.clearProjectIdValidationState(); + await this.ctx.sendProjectIdStatus(); + } + } + + public async setServiceKey(serviceId: KeyServiceId, value: string) { + const service = getKeyService(serviceId); + const trimmed = value.trim(); + if (!trimmed) { + this.postMessage({ type: "keyActionError", serviceId, message: "API key must not be empty." }); + return; + } + if (!service.secretStorageKey) { + this.postMessage({ type: "keyActionError", serviceId, message: `${service.displayName} does not use stored API keys in this extension.` }); + return; + } + if (serviceId === "openai" && !/^sk-/.test(trimmed)) { + this.postMessage({ type: "keyActionError", serviceId, message: 'OpenAI API keys must start with "sk-".' }); + return; + } + await this.context.secrets.store(service.secretStorageKey, trimmed); + if (serviceId === "openai") { + await this.context.secrets.store("recost.openaiApiKey", trimmed); + } + await this.clearValidationState(serviceId); + await this.sendKeyStatusUpdate(serviceId); + await this.testServiceKey(serviceId); + if (serviceId === "recost" && this.ctx.getManualProjectId()) { + await this.ctx.validateManualProjectId(); + } + } + + public async testServiceKey(serviceId: KeyServiceId) { + const service = getKeyService(serviceId); + const current = await this.buildKeyStatus(service); + if (current.source === "missing") { + this.postMessage({ type: "keyActionError", serviceId, message: `${service.displayName} key is missing.` }); + return; + } + this.postMessage({ + type: "keyStatusUpdated", + status: { ...current, state: "checking", message: undefined }, + focusServiceId: serviceId, + }); + try { + const value = await resolveCurrentKeyValue(service, this.context.secrets); + if (!value) { + this.postMessage({ type: "keyActionError", serviceId, message: `${service.displayName} key is missing.` }); + return; + } + const validation = await validateServiceKey(service, value); + await this.setValidationState(serviceId, { + ...validation, + keyFingerprint: buildKeyFingerprint(value), + }); + await this.sendKeyStatusUpdate(serviceId, serviceId); + if (serviceId === "recost") { + await vscode.commands.executeCommand("setContext", "recost.keyOnline", validation.state === "valid"); + } + } catch (error) { + const previous = this.keyValidationState.get(serviceId); + await this.sendKeyStatusUpdate(serviceId, serviceId); + const message = error instanceof Error ? error.message : `Unable to test ${service.displayName} key.`; + if (previous) { + this.postMessage({ type: "keyActionError", serviceId, message }); + } else { + this.postMessage({ + type: "keyStatusUpdated", + status: { ...current, message, maskedPreview: current.maskedPreview ?? maskKeyPreview(undefined) }, + focusServiceId: serviceId, + }); + } + } + } + + private restoreKeyValidationState() { + const stored = + this.context.globalState.get>>( + KeyManagementHandler.KEY_VALIDATION_STATE_STORAGE_KEY + ) ?? {}; + for (const [serviceId, snapshot] of Object.entries(stored) as [KeyServiceId, PersistedKeyValidationSnapshot | undefined][]) { + if (snapshot) { + this.keyValidationState.set(serviceId, snapshot); + } + } + } + + private async persistKeyValidationState() { + await this.context.globalState.update( + KeyManagementHandler.KEY_VALIDATION_STATE_STORAGE_KEY, + Object.fromEntries(this.keyValidationState.entries()) + ); + } + + public async clearValidationState(serviceId: KeyServiceId) { + this.keyValidationState.delete(serviceId); + await this.persistKeyValidationState(); + } + + public async setValidationState(serviceId: KeyServiceId, snapshot: PersistedKeyValidationSnapshot) { + this.keyValidationState.set(serviceId, snapshot); + await this.persistKeyValidationState(); + } + + private async getValidationSnapshot(serviceId: KeyServiceId): Promise { + const snapshot = this.keyValidationState.get(serviceId); + if (!snapshot) return undefined; + const service = getKeyService(serviceId); + const currentValue = await resolveCurrentKeyValue(service, this.context.secrets); + if (!currentValue || snapshot.keyFingerprint !== buildKeyFingerprint(currentValue)) { + await this.clearValidationState(serviceId); + return undefined; + } + return snapshot; + } + + private async buildKeyStatus(service: ReturnType): Promise { + return buildKeyStatusSummary( + service, + this.context.secrets, + await this.getValidationSnapshot(service.serviceId) + ); + } +} diff --git a/src/webview/scan-publishing-handler.ts b/src/webview/scan-publishing-handler.ts new file mode 100644 index 0000000..79280d6 --- /dev/null +++ b/src/webview/scan-publishing-handler.ts @@ -0,0 +1,790 @@ +import * as vscode from "vscode"; +import { + scanWorkspace, + detectLocalWastePatterns, + countScopedWorkspaceFiles, + getWorkspaceScanFiles, +} from "../scanner/workspace-scanner"; +import { createProject, submitScan, getAllEndpoints, getAllSuggestions } from "../api-client"; +import type { HostMessage, KeyServiceId } from "../messages"; +import type { ApiCallInput, EndpointRecord, Suggestion, ScanSummary } from "../analysis/types"; +import { classifyEndpointScope, detectEndpointProvider } from "../scanner/endpoint-classification"; +import { classifyPricing, calculateSavings } from "../scan-results"; +import { buildSnapshot } from "../intelligence/builder"; +import { scoreSnapshot } from "../intelligence/scorer"; +import { estimateLocalMonthlyCost } from "../intelligence/cost-utils"; +import { buildKeyFingerprint, type PersistedKeyValidationSnapshot } from "../key-management"; +import { getOutputChannel } from "../output"; + +export interface ExportDebugPayload { + mode: "local-only" | "remote-enriched"; + scannedFiles: string[]; + local: { + apiCalls: ApiCallInput[]; + localWasteFindings: Awaited>; + submittedRemoteApiCalls: ApiCallInput[]; + }; + remote: null | { + projectId: string; + scanId: string; + endpoints: EndpointRecord[]; + suggestions: Suggestion[]; + summary: ScanSummary; + }; + final: { + projectId: string; + scanId: string; + endpoints: EndpointRecord[]; + suggestions: Suggestion[]; + summary: ScanSummary; + }; +} + +export interface ScanPublishingHandlerContext { + postMessage(message: HostMessage): void; + context: vscode.ExtensionContext; + setLastEndpoints(endpoints: EndpointRecord[]): void; + setLastSuggestions(suggestions: Suggestion[]): void; + setLastSummary(summary: ScanSummary | null): void; + setLastApiCalls(calls: ApiCallInput[]): void; + setLastFindings(findings: Awaited>): void; + setProjectId(id: string | null): void; + getProjectId(): string | null; + getManualProjectId(): string | null; + getRcApiKey(): Promise; + resolveScanProjectTarget(rcApiKey: string): Promise<{ projectId: string; source: "manual" | "auto" }>; + getWorkspaceName(): string; + openKeys(focusServiceId?: KeyServiceId): void; + setRecostValidationState(snapshot: PersistedKeyValidationSnapshot): Promise; + clearRecostValidationState(): Promise; + sendRecostKeyStatusUpdate(): Promise; + resetChatHistory(): void; + exportDebugScanResults(payload: ExportDebugPayload): Promise; +} + +function normalizeDescription(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim(); +} + +function trimText(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max)}...`; +} + +function clampConfidence(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} + +function mapStatusToSuggestionType(status: EndpointRecord["status"]): Suggestion["type"] | null { + switch (status) { + case "cacheable": + return "cache"; + case "batchable": + return "batch"; + case "redundant": + return "redundancy"; + case "n_plus_one_risk": + return "n_plus_one"; + case "rate_limit_risk": + return "rate_limit"; + default: + return null; + } +} + +function chooseSeverity(status: EndpointRecord["status"], monthlyCost: number): Suggestion["severity"] { + if (status === "n_plus_one_risk" || status === "redundant") { + return monthlyCost >= 100 ? "high" : "medium"; + } + if (status === "rate_limit_risk") { + return monthlyCost >= 50 ? "high" : "medium"; + } + return monthlyCost >= 100 ? "medium" : "low"; +} + +function confidenceFromEndpointStatus(endpoint: EndpointRecord): number { + const base = + endpoint.status === "n_plus_one_risk" ? 0.78 : + endpoint.status === "redundant" ? 0.72 : + endpoint.status === "rate_limit_risk" ? 0.7 : + endpoint.status === "cacheable" ? 0.66 : + endpoint.status === "batchable" ? 0.66 : + 0.55; + const perRequestBoost = endpoint.callSites.some((site) => site.frequency === "per-request") ? 0.07 : 0; + return clampConfidence(base + perRequestBoost); +} + +function buildAggressiveDescription(endpoint: EndpointRecord, type: Suggestion["type"]): string { + const firstSite = endpoint.callSites[0]; + const location = firstSite ? ` (${firstSite.file}:${firstSite.line})` : ""; + switch (type) { + case "cache": + return `Potential caching opportunity detected for \`${endpoint.method} ${endpoint.url}\`${location}. This endpoint appears cacheable; consider adding response caching with explicit TTL and cache invalidation rules to reduce repeated requests and cost.`; + case "batch": + return `Potential batching opportunity detected for \`${endpoint.method} ${endpoint.url}\`${location}. This endpoint appears in a pattern that may benefit from request batching or bulk-fetch patterns to reduce request volume.`; + case "redundancy": + return `Potential redundant API usage detected for \`${endpoint.method} ${endpoint.url}\`${location}. Multiple call paths may be invoking equivalent requests; consider deduping in-flight requests and consolidating repeated fetches.`; + case "n_plus_one": + return `Potential N+1 API pattern detected for \`${endpoint.method} ${endpoint.url}\`${location}. Review loop-driven request behavior and replace with prefetch/batch patterns where possible.`; + case "rate_limit": + return `Potential rate-limit risk detected for \`${endpoint.method} ${endpoint.url}\`${location}. Add throttling/backoff and request coalescing to reduce burst frequency and avoid provider limits.`; + default: + return `Potential optimization opportunity detected for \`${endpoint.method} ${endpoint.url}\`${location}.`; + } +} + +function buildAggressiveSuggestions( + endpoints: EndpointRecord[], + suggestions: Suggestion[], + localFindings: Awaited> +): Suggestion[] { + const existing = new Set(); + for (const suggestion of suggestions) { + for (const endpointId of suggestion.affectedEndpoints) { + existing.add(`${endpointId}:${suggestion.type}`); + } + } + + function normalizePath(p: string): string { + return p.replace(/\\/g, "/").replace(/^\.\//, ""); + } + + const coveredByWaste = new Set(); + for (const finding of localFindings) { + coveredByWaste.add(`${finding.type}:${normalizePath(finding.affectedFile)}`); + } + + const extras: Suggestion[] = []; + for (const endpoint of endpoints) { + const type = mapStatusToSuggestionType(endpoint.status); + if (!type) continue; + + const dedupeKey = `${endpoint.id}:${type}`; + if (existing.has(dedupeKey)) continue; + + if (endpoint.scope === "internal") continue; + + const suppressedByWaste = endpoint.files.some((f) => + coveredByWaste.has(`${type}:${normalizePath(f)}`) + ); + if (suppressedByWaste) continue; + + const severity = chooseSeverity(endpoint.status, endpoint.monthlyCost); + extras.push({ + id: `local-${endpoint.id}-${type}`, + projectId: endpoint.projectId, + scanId: endpoint.scanId, + type, + severity, + affectedEndpoints: [endpoint.id], + affectedFiles: endpoint.files, + estimatedMonthlySavings: calculateSavings(type, severity, endpoint.monthlyCost), + description: buildAggressiveDescription(endpoint, type), + codeFix: "", + source: "local-rule", + confidence: confidenceFromEndpointStatus(endpoint), + evidence: endpoint.callSites.slice(0, 3).map((site) => `Observed callsite: ${site.file}:${site.line}`), + pricingClass: classifyPricing([endpoint.costModel]), + }); + } + + return [...suggestions, ...extras]; +} + +const PROXIMITY_THRESHOLD_LINES = 25; + +function findClosestEndpoint( + finding: { affectedFile: string; line?: number }, + fileEndpoints: EndpointRecord[] +): EndpointRecord | null { + if (!finding.line || fileEndpoints.length === 0) return null; + + let closest: EndpointRecord | null = null; + let closestDistance = Infinity; + + for (const ep of fileEndpoints) { + if (ep.monthlyCost === 0 && ep.callSites.every(s => s.library === "route-def")) continue; + + for (const site of ep.callSites) { + if (site.file !== finding.affectedFile) continue; + const distance = Math.abs(site.line - finding.line); + if (distance < closestDistance) { + closestDistance = distance; + closest = ep; + } + } + } + + return closestDistance <= PROXIMITY_THRESHOLD_LINES ? closest : null; +} + +function mergeLocalWasteFindings( + baseSuggestions: Suggestion[], + localFindings: Awaited>, + endpoints: EndpointRecord[], + projectId: string, + scanId: string +): Suggestion[] { + const existingByDescAndFile = new Set( + baseSuggestions.map((s) => `${s.description}::${s.affectedFiles[0] ?? ""}`) + ); + + const locals: Suggestion[] = []; + for (const finding of localFindings) { + if (finding.confidence < 0.35) continue; + + const key = `${finding.description}::${finding.affectedFile}`; + if (existingByDescAndFile.has(key)) continue; + existingByDescAndFile.add(key); + + const fileEndpoints = endpoints.filter((ep) => ep.files.includes(finding.affectedFile)); + const closestEndpoint = findClosestEndpoint(finding, fileEndpoints); + const directCost = closestEndpoint?.monthlyCost ?? 0; + const fileMonthlyCost = fileEndpoints.reduce((sum, ep) => sum + ep.monthlyCost, 0); + const baselineCost = directCost > 0 + ? directCost + : fileMonthlyCost > 0 + ? fileMonthlyCost + : 0; + const estimatedMonthlySavings = calculateSavings(finding.type, finding.severity, baselineCost); + const pricingClass = classifyPricing(fileEndpoints.map((ep) => ep.costModel)); + + locals.push({ + id: finding.id, + projectId, + scanId, + type: finding.type, + severity: finding.severity, + affectedEndpoints: fileEndpoints.map((ep) => ep.id), + affectedFiles: [finding.affectedFile], + targetLine: finding.line, + estimatedMonthlySavings, + description: finding.description, + codeFix: "", + source: "local-rule", + confidence: finding.confidence, + evidence: finding.evidence, + pricingClass, + }); + } + + return [...baseSuggestions, ...locals]; +} + +// Silence unused-helper warnings while keeping the helpers available for future +// callers of mergeLocalWasteFindings or buildAggressiveSuggestions that may need them. +void normalizeDescription; +void trimText; + +const GENERIC_DYNAMIC_TOKENS = new Set(["endpoint", "url", "path", "uri", "route"]); +const OUTBOUND_LIBRARIES = new Set([ + "fetch", + "axios", + "got", + "superagent", + "ky", + "requests", + "http", + "HttpClient", + "$http", + "openai", +]); + +function isHighConfidenceEndpointUrl(url: string): boolean { + if (!url) return false; + if (/^https?:\/\//i.test(url)) return true; + if (url.startsWith("/")) return true; + if (/\$\{\s*(endpoint|url|path|uri|route)\s*\}/i.test(url)) return false; + const dynamic = url.match(/^]+)>$/i); + if (!dynamic) return false; + const token = dynamic[1].trim().toLowerCase(); + if (GENERIC_DYNAMIC_TOKENS.has(token)) return false; + if (/base[_-]?url/.test(token)) return false; + return /base[_-]?url|api|endpoint/i.test(token); +} + +function shouldSubmitRemote(call: ApiCallInput): boolean { + if (!call.library || !OUTBOUND_LIBRARIES.has(call.library)) return false; + return isHighConfidenceEndpointUrl(call.url); +} + +function shouldIncludeSynthetic(call: ApiCallInput): boolean { + if (!isHighConfidenceEndpointUrl(call.url)) return false; + if (call.library === "route-def" || call.library === "api-helper") return call.url.startsWith("/"); + return true; +} + +function normalizePathParams(url: string): string { + return url + .replace(/\$\{\s*[^}]+\s*\}/g, ":param") + .replace(/<[^>]+>/g, ":param") + .replace(/\{[^}]+\}/g, ":param"); +} + +function stripQueryAndHash(url: string): string { + const queryIdx = url.indexOf("?"); + const hashIdx = url.indexOf("#"); + const cutAt = + queryIdx >= 0 && hashIdx >= 0 ? Math.min(queryIdx, hashIdx) : + queryIdx >= 0 ? queryIdx : + hashIdx >= 0 ? hashIdx : + -1; + return cutAt >= 0 ? url.slice(0, cutAt) : url; +} + +function canonicalizeEndpointUrl(url: string): string { + const stripped = stripQueryAndHash(url.trim()); + return normalizePathParams(stripped); +} + +function isDynamicPlaceholderUrl(url: string): boolean { + return /^]+>$/i.test(url.trim()); +} + +function buildEndpointKey(method: string, url: string): string { + return `${method.toUpperCase()} ${canonicalizeEndpointUrl(url)}`; +} + +function pickDisplayUrl(current: string, candidate: string): string { + const currentCanonical = canonicalizeEndpointUrl(current); + const candidateCanonical = canonicalizeEndpointUrl(candidate); + + const score = (value: string): number => { + let s = 0; + if (!isDynamicPlaceholderUrl(value)) s += 3; + if (value === stripQueryAndHash(value)) s += 2; + if (value.includes(":param")) s += 1; + if (value.includes("/")) s += 1; + return s; + }; + + const currentScore = score(currentCanonical); + const candidateScore = score(candidateCanonical); + return candidateScore > currentScore ? candidateCanonical : currentCanonical; +} + +const FREQUENCY_SEVERITY: Record = { + polling: 6, + "unbounded-loop": 5, + parallel: 4, + "bounded-loop": 3, + conditional: 2, + "cache-guarded": 1, + single: 0, +}; + +function pickMostSevereFrequency(a: string | undefined, b: string | undefined): string | undefined { + if (!a && !b) return undefined; + if (!a) return b; + if (!b) return a; + return (FREQUENCY_SEVERITY[a] ?? 0) >= (FREQUENCY_SEVERITY[b] ?? 0) ? a : b; +} + +function mergeRemoteAndLocalEndpoints( + remote: EndpointRecord[], + localCalls: ApiCallInput[], + projectId: string, + scanId: string +): EndpointRecord[] { + const merged = remote.map((endpoint) => ({ + ...endpoint, + scope: endpoint.scope ?? classifyEndpointScope(endpoint.url), + })); + const byMethodUrl = new Map(); + for (const endpoint of merged) { + byMethodUrl.set(buildEndpointKey(endpoint.method, endpoint.url), endpoint); + } + + const syntheticByMethodUrl = new Map(); + for (const call of localCalls) { + if (!shouldIncludeSynthetic(call)) continue; + const key = buildEndpointKey(call.method, call.url); + if (byMethodUrl.has(key)) { + const endpoint = byMethodUrl.get(key)!; + endpoint.url = pickDisplayUrl(endpoint.url, call.url); + if (!endpoint.files.includes(call.file)) { + endpoint.files.push(call.file); + } + const hasSite = endpoint.callSites.some( + (site) => site.file === call.file && site.line === call.line && site.library === call.library + ); + if (!hasSite) { + endpoint.callSites.push({ + file: call.file, + line: call.line, + library: call.library ?? "", + frequency: call.frequency, + frequencyClass: call.frequencyClass, + crossFileOrigin: call.crossFileOrigin ?? null, + }); + } + if (!endpoint.methodSignature && call.methodSignature) endpoint.methodSignature = call.methodSignature; + if (!endpoint.costModel && call.costModel) endpoint.costModel = call.costModel; + endpoint.frequencyClass = pickMostSevereFrequency(endpoint.frequencyClass, call.frequencyClass); + if (call.batchCapable) endpoint.batchCapable = true; + if (call.cacheCapable) endpoint.cacheCapable = true; + if (call.streaming) endpoint.streaming = true; + if (call.isMiddleware) endpoint.isMiddleware = true; + if (call.crossFileOrigin) { + endpoint.crossFileOrigins = endpoint.crossFileOrigins ?? []; + endpoint.crossFileOrigins.push(call.crossFileOrigin); + } + continue; + } + + if (!syntheticByMethodUrl.has(key)) { + const canonicalUrl = canonicalizeEndpointUrl(call.url); + const provider = call.provider ?? detectEndpointProvider(canonicalUrl); + const callsPerDay = call.frequency === "per-request" ? 100 : call.library === "route-def" ? 0 : 1; + syntheticByMethodUrl.set(key, { + id: `local-${scanId}-${syntheticByMethodUrl.size + 1}`, + projectId, + scanId, + provider, + method: call.method, + url: canonicalUrl, + scope: classifyEndpointScope(canonicalUrl), + files: [call.file], + callSites: [{ + file: call.file, + line: call.line, + library: call.library ?? "", + frequency: call.frequency, + frequencyClass: call.frequencyClass, + crossFileOrigin: call.crossFileOrigin ?? null, + }], + callsPerDay, + monthlyCost: estimateLocalMonthlyCost(provider, callsPerDay, call.methodSignature) ?? 0, + status: + call.frequency === "per-request" + ? "n_plus_one_risk" + : call.library === "route-def" + ? "normal" + : "normal", + methodSignature: call.methodSignature, + costModel: call.costModel, + frequencyClass: call.frequencyClass, + batchCapable: call.batchCapable, + cacheCapable: call.cacheCapable, + streaming: call.streaming, + isMiddleware: call.isMiddleware, + crossFileOrigins: call.crossFileOrigin ? [call.crossFileOrigin] : undefined, + }); + continue; + } + + const synthetic = syntheticByMethodUrl.get(key)!; + synthetic.url = pickDisplayUrl(synthetic.url, call.url); + synthetic.scope = classifyEndpointScope(synthetic.url); + synthetic.provider = call.provider ?? detectEndpointProvider(synthetic.url); + if (!synthetic.files.includes(call.file)) { + synthetic.files.push(call.file); + } + const hasSite = synthetic.callSites.some( + (site) => site.file === call.file && site.line === call.line && site.library === call.library + ); + if (!hasSite) { + synthetic.callSites.push({ + file: call.file, + line: call.line, + library: call.library ?? "", + frequency: call.frequency, + frequencyClass: call.frequencyClass, + crossFileOrigin: call.crossFileOrigin ?? null, + }); + } + if (call.frequency === "per-request") { + synthetic.status = "n_plus_one_risk"; + synthetic.callsPerDay = Math.max(synthetic.callsPerDay, 100); + } + if (!synthetic.methodSignature && call.methodSignature) synthetic.methodSignature = call.methodSignature; + if (!synthetic.costModel && call.costModel) synthetic.costModel = call.costModel; + synthetic.frequencyClass = pickMostSevereFrequency(synthetic.frequencyClass, call.frequencyClass); + if (call.batchCapable) synthetic.batchCapable = true; + if (call.cacheCapable) synthetic.cacheCapable = true; + if (call.streaming) synthetic.streaming = true; + if (call.isMiddleware) synthetic.isMiddleware = true; + if (call.crossFileOrigin) { + synthetic.crossFileOrigins = synthetic.crossFileOrigins ?? []; + synthetic.crossFileOrigins.push(call.crossFileOrigin); + } + } + + return [...merged, ...syntheticByMethodUrl.values()] + .filter((ep) => ep.scope !== "internal"); +} + +export class ScanPublishingHandler { + constructor(private readonly ctx: ScanPublishingHandlerContext) {} + + public async handleStartScan() { + await vscode.commands.executeCommand("setContext", "recost.scanning", true); + try { + this.ctx.resetChatHistory(); + const scannedFiles = (await getWorkspaceScanFiles()).map((file) => file.relativePath); + + const apiCalls = await scanWorkspace((progress) => { + this.ctx.postMessage({ + type: "scanProgress", + stage: "scanning", + file: progress.file, + fileIndex: progress.fileIndex, + fileTotal: progress.fileTotal, + }); + }); + + this.ctx.postMessage({ type: "scanProgress", stage: "analyzing" }); + this.ctx.postMessage({ type: "scanProgress", stage: "detecting" }); + const localWasteFindings = await detectLocalWastePatterns(); + this.ctx.setLastApiCalls(apiCalls); + this.ctx.setLastFindings(localWasteFindings); + this.ctx.postMessage({ type: "scanProgress", stage: "resolving" }); + + if (process.env.RECOST_INTELLIGENCE_DEBUG === "1") { + const totalFilesScanned = await countScopedWorkspaceFiles(); + const snapshot = buildSnapshot({ + apiCalls, + findings: localWasteFindings, + totalFilesScanned, + }); + const scored = scoreSnapshot(snapshot); + const ch = getOutputChannel(); + for (const file of scored.scoredFiles.slice(0, 5)) { + ch.appendLine( + `[intelligence] ${file.filePath} | priority=${file.scores.aiReviewPriority.toFixed(2)} | ` + + `importance=${file.scores.importance.toFixed(2)} | ` + + `costLeak=${file.scores.costLeak.toFixed(2)} | ` + + `reliabilityRisk=${file.scores.reliabilityRisk.toFixed(2)} | ` + + `reasons=${file.reasons.join("; ")}` + ); + } + } + + this.ctx.postMessage({ type: "scanComplete" }); + + const publishLocalOnlyResults = (localProjectId: string, localScanId: string) => { + const endpoints = mergeRemoteAndLocalEndpoints([], apiCalls, localProjectId, localScanId); + const aggressiveSuggestions = buildAggressiveSuggestions(endpoints, [], localWasteFindings); + const mergedSuggestions = mergeLocalWasteFindings( + aggressiveSuggestions, + localWasteFindings, + endpoints, + localProjectId, + localScanId + ); + const summary: ScanSummary = { + totalEndpoints: endpoints.length, + totalCallsPerDay: endpoints.reduce((sum, ep) => sum + ep.callsPerDay, 0), + totalMonthlyCost: endpoints.reduce((sum, ep) => sum + ep.monthlyCost, 0), + highRiskCount: mergedSuggestions.filter((s) => s.severity === "high").length, + }; + + const externalEndpoints = endpoints.filter((ep) => ep.scope !== "internal"); + this.ctx.setLastEndpoints(externalEndpoints); + this.ctx.setLastSuggestions(mergedSuggestions); + this.ctx.setLastSummary({ ...summary, totalEndpoints: externalEndpoints.length }); + this.ctx.postMessage({ + type: "scanResults", + endpoints: externalEndpoints, + suggestions: mergedSuggestions, + summary: { ...summary, totalEndpoints: externalEndpoints.length }, + }); + void this.ctx.exportDebugScanResults({ + mode: "local-only", + scannedFiles, + local: { + apiCalls, + localWasteFindings, + submittedRemoteApiCalls: [], + }, + remote: null, + final: { + projectId: localProjectId, + scanId: localScanId, + endpoints, + suggestions: mergedSuggestions, + summary, + }, + }); + }; + + if (apiCalls.length === 0) { + this.ctx.setLastEndpoints([]); + this.ctx.setLastSuggestions([]); + const emptySummary: ScanSummary = { + totalEndpoints: 0, + totalCallsPerDay: 0, + totalMonthlyCost: 0, + highRiskCount: 0, + }; + this.ctx.setLastSummary(emptySummary); + this.ctx.postMessage({ + type: "scanResults", + endpoints: [], + suggestions: [], + summary: emptySummary, + }); + void this.ctx.exportDebugScanResults({ + mode: "local-only", + scannedFiles, + local: { + apiCalls, + localWasteFindings, + submittedRemoteApiCalls: [], + }, + remote: null, + final: { + projectId: "local", + scanId: `local-${Date.now()}`, + endpoints: [], + suggestions: [], + summary: emptySummary, + }, + }); + return; + } + + const manualProjectId = this.ctx.getManualProjectId(); + let rcApiKey = await this.ctx.getRcApiKey(); + if (!rcApiKey) { + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + this.ctx.postMessage({ + type: "scanNotification", + message: "No ReCost API key — showing local results only. Add a key in Keys to enable remote sync.", + }); + return; + } + const remoteApiCalls = apiCalls + .filter(shouldSubmitRemote) + .map((call) => ({ + ...call, + provider: call.provider ?? detectEndpointProvider(canonicalizeEndpointUrl(call.url)) ?? "unknown", + })) + .filter((call) => call.provider !== "unknown"); + if (remoteApiCalls.length === 0) { + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + return; + } + + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + + try { + const projectTarget = await this.ctx.resolveScanProjectTarget(rcApiKey); + let projectId = projectTarget.projectId; + let scanResult; + try { + scanResult = await submitScan(projectId, remoteApiCalls, rcApiKey); + } catch (err: unknown) { + if ((err as { status?: number }).status === 404 && projectTarget.source === "auto") { + const freshId = await createProject(this.ctx.getWorkspaceName(), rcApiKey); + this.ctx.setProjectId(freshId); + projectId = freshId; + await this.ctx.context.globalState.update("recost.projectId", freshId); + scanResult = await submitScan(projectId, remoteApiCalls, rcApiKey); + } else { + throw err; + } + } + + const [remoteEndpoints, suggestions] = await Promise.all([ + getAllEndpoints(projectId, scanResult.scanId, rcApiKey), + getAllSuggestions(projectId, scanResult.scanId, rcApiKey), + ]); + const taggedRemoteSuggestions = suggestions.map((s) => ({ ...s, source: s.source ?? "remote" })); + + const endpoints = mergeRemoteAndLocalEndpoints(remoteEndpoints, apiCalls, projectId, scanResult.scanId); + const externalEndpoints = endpoints.filter((ep) => ep.scope !== "internal"); + this.ctx.setLastEndpoints(externalEndpoints); + const aggressiveSuggestions = buildAggressiveSuggestions(endpoints, taggedRemoteSuggestions, localWasteFindings); + const mergedSuggestions = mergeLocalWasteFindings( + aggressiveSuggestions, + localWasteFindings, + endpoints, + projectId, + scanResult.scanId + ); + this.ctx.setLastSuggestions(mergedSuggestions); + this.ctx.setLastSummary({ ...scanResult.summary, totalEndpoints: externalEndpoints.length }); + + this.ctx.postMessage({ + type: "scanResults", + endpoints: externalEndpoints, + suggestions: mergedSuggestions, + summary: { + ...scanResult.summary, + totalEndpoints: externalEndpoints.length, + }, + }); + void this.ctx.exportDebugScanResults({ + mode: "remote-enriched", + scannedFiles, + local: { + apiCalls, + localWasteFindings, + submittedRemoteApiCalls: remoteApiCalls, + }, + remote: { + projectId, + scanId: scanResult.scanId, + endpoints: remoteEndpoints, + suggestions, + summary: scanResult.summary, + }, + final: { + projectId, + scanId: scanResult.scanId, + endpoints, + suggestions: mergedSuggestions, + summary: { + ...scanResult.summary, + totalEndpoints: Math.max(scanResult.summary.totalEndpoints, endpoints.length), + }, + }, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Remote analysis failed"; + const status = (err as { status?: number }).status; + const authLikeFailure = + status === 401 || + (status === 403 && /invalid|unauthori[sz]ed|forbidden|auth/i.test(message)); + + if (authLikeFailure) { + const rcKey = await this.ctx.getRcApiKey(); + if (rcKey) { + await this.ctx.setRecostValidationState({ + state: "invalid", + message, + lastCheckedAt: new Date().toISOString(), + keyFingerprint: buildKeyFingerprint(rcKey), + }); + } else { + await this.ctx.clearRecostValidationState(); + } + await this.ctx.sendRecostKeyStatusUpdate(); + this.ctx.openKeys("recost"); + } + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + if (status === 404 && manualProjectId) { + this.ctx.postMessage({ + type: "scanNotification", + message: `Project ID ${manualProjectId} was not found. Keeping the saved manual Project ID and showing local results.`, + }); + return; + } + if (err instanceof Error && err.message === "fetch failed") { + this.ctx.postMessage({ + type: "scanNotification", + message: "Could not reach ReCost server. Showing local results.", + }); + } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error during scan"; + this.ctx.postMessage({ type: "error", message }); + } finally { + await vscode.commands.executeCommand("setContext", "recost.scanning", false); + } + } +} diff --git a/src/webview/simulation-handler.ts b/src/webview/simulation-handler.ts new file mode 100644 index 0000000..66a768c --- /dev/null +++ b/src/webview/simulation-handler.ts @@ -0,0 +1,55 @@ +import * as vscode from "vscode"; +import type { HostMessage } from "../messages"; +import type { EndpointRecord } from "../analysis/types"; +import type { SavedScenario, SimulatorInput } from "../simulator/types"; +import { runSimulation, StaticDataSource } from "../simulator"; + +export interface SimulationHandlerContext { + postMessage(message: HostMessage): void; + context: vscode.ExtensionContext; + getLastEndpoints(): EndpointRecord[]; +} + +export class SimulationHandler { + private static readonly SCENARIOS_STORAGE_KEY = "recost.simulatorScenarios"; + + private savedScenarios: SavedScenario[] = []; + private scenarioPersistQueue: Promise = Promise.resolve(); + + constructor(private readonly ctx: SimulationHandlerContext) { + this.savedScenarios = + this.ctx.context.globalState.get( + SimulationHandler.SCENARIOS_STORAGE_KEY + ) ?? []; + } + + public handleRunSimulation(input: SimulatorInput): void { + try { + const endpoints = this.ctx.getLastEndpoints(); + if (endpoints.length === 0) { + this.ctx.postMessage({ type: "simulationError", message: "Run a scan first to use the simulator." }); + return; + } + const source = new StaticDataSource(endpoints); + const result = runSimulation(source, input); + this.ctx.postMessage({ type: "simulationResult", result }); + } catch (err) { + const message = err instanceof Error ? err.message : "Simulation failed"; + this.ctx.postMessage({ type: "simulationError", message }); + } + } + + public async persistScenarios(next: SavedScenario[]): Promise { + this.scenarioPersistQueue = this.scenarioPersistQueue + .catch(() => {}) + .then(async () => { + await this.ctx.context.globalState.update(SimulationHandler.SCENARIOS_STORAGE_KEY, next); + this.savedScenarios = next; + }); + await this.scenarioPersistQueue; + } + + public getSavedScenarios(): SavedScenario[] { + return this.savedScenarios; + } +}