diff --git a/bun.lock b/bun.lock index b402ceb..0e7c1a3 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "prettier": "^3.8.1", "tsup": "^8.5.0", "typescript-eslint": "^8.57.1", + "vitest": "^4.1.5", }, "peerDependencies": { "typescript": "^5", @@ -24,6 +25,12 @@ }, }, "packages": { + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -108,6 +115,42 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], @@ -158,8 +201,16 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -190,6 +241,20 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A=="], + "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="], + + "@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="], + + "@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="], + + "@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -200,6 +265,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], @@ -210,6 +277,8 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], @@ -218,12 +287,18 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -246,8 +321,12 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -294,6 +373,30 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -312,10 +415,14 @@ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -336,6 +443,8 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -348,6 +457,8 @@ "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], + "rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -356,24 +467,38 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -388,8 +513,14 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="], + + "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -397,5 +528,11 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "vite/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "vitest/tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], } } diff --git a/eslint.config.js b/eslint.config.js index e1d94c0..6cc93fd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -21,6 +21,11 @@ export default tseslint.config( }, prettierConfig, { - ignores: ["node_modules/**", "dist/**", "build/**"], + ignores: [ + "node_modules/**", + "dist/**", + "build/**", + "src/extractors/__fixtures__/**", + ], }, ); diff --git a/package.json b/package.json index c82b61c..006d534 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "tsc": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "test:smoke": "node scripts/smoke-test.mjs", "prepublishOnly": "npm run build && npm run test:smoke" }, @@ -47,7 +49,8 @@ "eslint-plugin-unused-imports": "^4.4.1", "prettier": "^3.8.1", "tsup": "^8.5.0", - "typescript-eslint": "^8.57.1" + "typescript-eslint": "^8.57.1", + "vitest": "^4.1.5" }, "peerDependencies": { "typescript": "^5" diff --git a/src/extractors/__fixtures__/django/manage.py b/src/extractors/__fixtures__/django/manage.py new file mode 100644 index 0000000..0bbf6e8 --- /dev/null +++ b/src/extractors/__fixtures__/django/manage.py @@ -0,0 +1 @@ +# manage.py diff --git a/src/extractors/__fixtures__/django/myapp/consumers.py b/src/extractors/__fixtures__/django/myapp/consumers.py new file mode 100644 index 0000000..5368655 --- /dev/null +++ b/src/extractors/__fixtures__/django/myapp/consumers.py @@ -0,0 +1,12 @@ +from channels.generic.websocket import AsyncWebsocketConsumer + + +class ChatConsumer(AsyncWebsocketConsumer): + async def connect(self): + await self.accept() + + async def disconnect(self, close_code): + pass + + async def receive(self, text_data=None, bytes_data=None): + await self.send(text_data=text_data or "") diff --git a/src/extractors/__fixtures__/django/myapp/routing.py b/src/extractors/__fixtures__/django/myapp/routing.py new file mode 100644 index 0000000..0c77274 --- /dev/null +++ b/src/extractors/__fixtures__/django/myapp/routing.py @@ -0,0 +1,6 @@ +from django.urls import path +from .consumers import ChatConsumer + +websocket_urlpatterns = [ + path("ws/chat/", ChatConsumer.as_asgi()), +] diff --git a/src/extractors/__fixtures__/django/myapp/urls.py b/src/extractors/__fixtures__/django/myapp/urls.py new file mode 100644 index 0000000..da55394 --- /dev/null +++ b/src/extractors/__fixtures__/django/myapp/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path("api/health/", views.health), + path("", views.HomeView.as_view(), name="home"), + path("about/", views.about_page, name="about"), +] diff --git a/src/extractors/__fixtures__/django/myapp/views.py b/src/extractors/__fixtures__/django/myapp/views.py new file mode 100644 index 0000000..d2998b1 --- /dev/null +++ b/src/extractors/__fixtures__/django/myapp/views.py @@ -0,0 +1,17 @@ +from django.shortcuts import render +from django.views.generic import TemplateView +from rest_framework.decorators import api_view +from rest_framework.response import Response + + +class HomeView(TemplateView): + template_name = "home.html" + + +def about_page(request): + return render(request, "about.html", {"title": "About"}) + + +@api_view(["GET"]) +def health(request): + return Response({"status": "ok"}) diff --git a/src/extractors/__fixtures__/django/requirements.txt b/src/extractors/__fixtures__/django/requirements.txt new file mode 100644 index 0000000..4fc9cc8 --- /dev/null +++ b/src/extractors/__fixtures__/django/requirements.txt @@ -0,0 +1,2 @@ +django +channels diff --git a/src/extractors/__fixtures__/express/index.js b/src/extractors/__fixtures__/express/index.js new file mode 100644 index 0000000..837faac --- /dev/null +++ b/src/extractors/__fixtures__/express/index.js @@ -0,0 +1,34 @@ +import express from "express"; +import expressWs from "express-ws"; + +const app = express(); +expressWs(app); + +// API route — returns JSON. +app.get("/api/users", (req, res) => { + res.json({ users: [] }); +}); + +// API route with explicit POST. +app.post("/api/users", (req, res) => { + res.json({ created: true }); +}); + +// Page route — server-side rendered template. +app.get("/about", (req, res) => { + res.render("about", { title: "About" }); +}); + +// Page route — file response. +app.get("/file", (req, res) => { + res.sendFile("/var/data/doc.pdf"); +}); + +// WebSocket route via express-ws. +app.ws("/chat", (ws, req) => { + ws.on("message", (msg) => { + ws.send(`echo: ${msg}`); + }); +}); + +app.listen(3000); diff --git a/src/extractors/__fixtures__/express/package.json b/src/extractors/__fixtures__/express/package.json new file mode 100644 index 0000000..f1a30de --- /dev/null +++ b/src/extractors/__fixtures__/express/package.json @@ -0,0 +1,12 @@ +{ + "name": "express-fixture", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "index.js", + "dependencies": { + "express": "^4.18.0", + "express-ws": "^5.0.2", + "socket.io": "^4.7.0" + } +} diff --git a/src/extractors/__fixtures__/fastapi/main.py b/src/extractors/__fixtures__/fastapi/main.py new file mode 100644 index 0000000..b46a7d0 --- /dev/null +++ b/src/extractors/__fixtures__/fastapi/main.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI, Request, WebSocket +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +app = FastAPI() +templates = Jinja2Templates(directory="templates") + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.get("/about", response_class=HTMLResponse) +async def about(): + return "

About

" + + +@app.get("/dashboard") +async def dashboard(request: Request): + return templates.TemplateResponse("dashboard.html", {"request": request}) + + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + while True: + data = await websocket.receive_text() + await websocket.send_text(f"echo: {data}") diff --git a/src/extractors/__fixtures__/fastapi/requirements.txt b/src/extractors/__fixtures__/fastapi/requirements.txt new file mode 100644 index 0000000..6b0b939 --- /dev/null +++ b/src/extractors/__fixtures__/fastapi/requirements.txt @@ -0,0 +1 @@ +fastapi diff --git a/src/extractors/__fixtures__/flask/app.py b/src/extractors/__fixtures__/flask/app.py new file mode 100644 index 0000000..65d788b --- /dev/null +++ b/src/extractors/__fixtures__/flask/app.py @@ -0,0 +1,13 @@ +from flask import Flask, jsonify, render_template + +app = Flask(__name__) + + +@app.route("/api/users") +def list_users(): + return jsonify({"users": []}) + + +@app.route("/about") +def about(): + return render_template("about.html") diff --git a/src/extractors/__fixtures__/flask/requirements.txt b/src/extractors/__fixtures__/flask/requirements.txt new file mode 100644 index 0000000..7e10602 --- /dev/null +++ b/src/extractors/__fixtures__/flask/requirements.txt @@ -0,0 +1 @@ +flask diff --git a/src/extractors/__fixtures__/go-gin/go.mod b/src/extractors/__fixtures__/go-gin/go.mod new file mode 100644 index 0000000..ebc0065 --- /dev/null +++ b/src/extractors/__fixtures__/go-gin/go.mod @@ -0,0 +1,8 @@ +module testapp + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/gorilla/websocket v1.5.1 +) diff --git a/src/extractors/__fixtures__/go-gin/main.go b/src/extractors/__fixtures__/go-gin/main.go new file mode 100644 index 0000000..f216b75 --- /dev/null +++ b/src/extractors/__fixtures__/go-gin/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// listUsers is a plain JSON API handler — no upgrade. +func listUsers(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"users": []string{"alice", "bob"}}) +} + +// chatHandler upgrades the connection to websocket via gorilla. +func chatHandler(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + for { + _, msg, err := conn.ReadMessage() + if err != nil { + break + } + if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { + break + } + } +} + +func main() { + r := gin.Default() + r.GET("/api/users", listUsers) + r.GET("/ws", chatHandler) + r.Run(":8080") +} diff --git a/src/extractors/__fixtures__/laravel/composer.json b/src/extractors/__fixtures__/laravel/composer.json new file mode 100644 index 0000000..cfd966d --- /dev/null +++ b/src/extractors/__fixtures__/laravel/composer.json @@ -0,0 +1,8 @@ +{ + "name": "fixture/laravel-app", + "type": "project", + "require": { + "php": "^8.1", + "laravel/framework": "^10.0" + } +} diff --git a/src/extractors/__fixtures__/laravel/routes/api.php b/src/extractors/__fixtures__/laravel/routes/api.php new file mode 100644 index 0000000..a867de7 --- /dev/null +++ b/src/extractors/__fixtures__/laravel/routes/api.php @@ -0,0 +1,6 @@ + view('home')); +Route::get('/about', [AboutController::class, 'show']); diff --git a/src/extractors/__fixtures__/nestjs/events.gateway.ts b/src/extractors/__fixtures__/nestjs/events.gateway.ts new file mode 100644 index 0000000..fb499de --- /dev/null +++ b/src/extractors/__fixtures__/nestjs/events.gateway.ts @@ -0,0 +1,9 @@ +import { SubscribeMessage, WebSocketGateway } from "@nestjs/websockets"; + +@WebSocketGateway() +export class EventsGateway { + @SubscribeMessage("message") + handleMessage(client: unknown, payload: unknown) { + return { event: "message", data: payload }; + } +} diff --git a/src/extractors/__fixtures__/nestjs/package.json b/src/extractors/__fixtures__/nestjs/package.json new file mode 100644 index 0000000..73442fb --- /dev/null +++ b/src/extractors/__fixtures__/nestjs/package.json @@ -0,0 +1,11 @@ +{ + "name": "nestjs-fixture", + "version": "0.0.0", + "private": true, + "dependencies": { + "@nestjs/core": "^10.0.0", + "@nestjs/common": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "@nestjs/platform-express": "^10.0.0" + } +} diff --git a/src/extractors/__fixtures__/nestjs/pages.controller.ts b/src/extractors/__fixtures__/nestjs/pages.controller.ts new file mode 100644 index 0000000..76429a3 --- /dev/null +++ b/src/extractors/__fixtures__/nestjs/pages.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Render } from "@nestjs/common"; + +@Controller() +export class PagesController { + @Get("about") + @Render("about") + renderAbout() { + return { title: "About us" }; + } + + // Sibling api method: must NOT inherit the @Render kind from the + // page handler immediately above it. + @Get("data") + getData() { + return { ok: true }; + } +} diff --git a/src/extractors/__fixtures__/nestjs/users.controller.ts b/src/extractors/__fixtures__/nestjs/users.controller.ts new file mode 100644 index 0000000..1f05dc6 --- /dev/null +++ b/src/extractors/__fixtures__/nestjs/users.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from "@nestjs/common"; + +@Controller("users") +export class UsersController { + @Get() + findAll() { + return [{ id: 1, name: "Ada" }]; + } +} diff --git a/src/extractors/__fixtures__/rails/Gemfile b/src/extractors/__fixtures__/rails/Gemfile new file mode 100644 index 0000000..16066d4 --- /dev/null +++ b/src/extractors/__fixtures__/rails/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gem 'rails', '~> 7.1' diff --git a/src/extractors/__fixtures__/rails/app/controllers/api/base_controller.rb b/src/extractors/__fixtures__/rails/app/controllers/api/base_controller.rb new file mode 100644 index 0000000..93544e0 --- /dev/null +++ b/src/extractors/__fixtures__/rails/app/controllers/api/base_controller.rb @@ -0,0 +1,2 @@ +class Api::BaseController < ActionController::API +end diff --git a/src/extractors/__fixtures__/rails/app/controllers/api/users_controller.rb b/src/extractors/__fixtures__/rails/app/controllers/api/users_controller.rb new file mode 100644 index 0000000..91d7428 --- /dev/null +++ b/src/extractors/__fixtures__/rails/app/controllers/api/users_controller.rb @@ -0,0 +1,5 @@ +class Api::UsersController < Api::BaseController + def index + render json: [] + end +end diff --git a/src/extractors/__fixtures__/rails/app/controllers/application_controller.rb b/src/extractors/__fixtures__/rails/app/controllers/application_controller.rb new file mode 100644 index 0000000..09705d1 --- /dev/null +++ b/src/extractors/__fixtures__/rails/app/controllers/application_controller.rb @@ -0,0 +1,2 @@ +class ApplicationController < ActionController::Base +end diff --git a/src/extractors/__fixtures__/rails/app/controllers/pages_controller.rb b/src/extractors/__fixtures__/rails/app/controllers/pages_controller.rb new file mode 100644 index 0000000..91c15a4 --- /dev/null +++ b/src/extractors/__fixtures__/rails/app/controllers/pages_controller.rb @@ -0,0 +1,4 @@ +class PagesController < ApplicationController + def about + end +end diff --git a/src/extractors/__fixtures__/rails/config/routes.rb b/src/extractors/__fixtures__/rails/config/routes.rb new file mode 100644 index 0000000..45fa8af --- /dev/null +++ b/src/extractors/__fixtures__/rails/config/routes.rb @@ -0,0 +1,7 @@ +Rails.application.routes.draw do + get '/about' => 'pages#about' + + namespace :api do + resources :users, only: [:index] + end +end diff --git a/src/extractors/__fixtures__/spring/pom.xml b/src/extractors/__fixtures__/spring/pom.xml new file mode 100644 index 0000000..3375fd6 --- /dev/null +++ b/src/extractors/__fixtures__/spring/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + com.example + demo + 0.0.1-SNAPSHOT + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + diff --git a/src/extractors/__fixtures__/spring/src/main/java/com/example/demo/ChatController.java b/src/extractors/__fixtures__/spring/src/main/java/com/example/demo/ChatController.java new file mode 100644 index 0000000..023eddf --- /dev/null +++ b/src/extractors/__fixtures__/spring/src/main/java/com/example/demo/ChatController.java @@ -0,0 +1,15 @@ +package com.example.demo; + +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; + +@Controller +public class ChatController { + + @MessageMapping("/chat") + @SendTo("/topic/messages") + public String chat(String message) { + return message; + } +} diff --git a/src/extractors/__fixtures__/spring/src/main/java/com/example/demo/PageController.java b/src/extractors/__fixtures__/spring/src/main/java/com/example/demo/PageController.java new file mode 100644 index 0000000..3530cbf --- /dev/null +++ b/src/extractors/__fixtures__/spring/src/main/java/com/example/demo/PageController.java @@ -0,0 +1,13 @@ +package com.example.demo; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class PageController { + + @GetMapping("/about") + public String about() { + return "about"; + } +} diff --git a/src/extractors/__fixtures__/spring/src/main/java/com/example/demo/UserApiController.java b/src/extractors/__fixtures__/spring/src/main/java/com/example/demo/UserApiController.java new file mode 100644 index 0000000..6b013c3 --- /dev/null +++ b/src/extractors/__fixtures__/spring/src/main/java/com/example/demo/UserApiController.java @@ -0,0 +1,13 @@ +package com.example.demo; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class UserApiController { + + @GetMapping("/api/users") + public String listUsers() { + return "[]"; + } +} diff --git a/src/extractors/django.test.ts b/src/extractors/django.test.ts new file mode 100644 index 0000000..071533e --- /dev/null +++ b/src/extractors/django.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { map } from "../index.ts"; + +// Pattern for kind-detection tests: +// 1. Load fixture dir under src/extractors/__fixtures__// +// 2. Call map() against it (filter via frameworkOverride for isolation) +// 3. Assert endpoints contain expected shapes by kind using +// expect.objectContaining so unrelated fields don't break the match. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, "__fixtures__/django"); + +describe("django extractor", () => { + it("emits correct kinds for api, page, and websocket routes", () => { + const result = map(FIXTURE_DIR, { frameworkOverride: "django" }); + const endpoints = result.endpoints.all; + + // CBV with TemplateView base → page + expect(endpoints).toContainEqual( + expect.objectContaining({ + kind: "page", + handler: "HomeView", + }), + ); + + // FBV body containing render() → page + expect(endpoints).toContainEqual( + expect.objectContaining({ + kind: "page", + path: "/about", + handler: "about_page", + }), + ); + + // @api_view function → api (decorator-based, emitted at /) + expect(endpoints).toContainEqual( + expect.objectContaining({ + kind: "api", + path: "/health", + }), + ); + + // Django Channels websocket_urlpatterns → websocket + expect(endpoints).toContainEqual( + expect.objectContaining({ + kind: "websocket", + method: "WS", + path: "/ws/chat", + handler: "ChatConsumer", + }), + ); + }); +}); diff --git a/src/extractors/django.ts b/src/extractors/django.ts index 2f952f4..e9c5e88 100644 --- a/src/extractors/django.ts +++ b/src/extractors/django.ts @@ -1,4 +1,4 @@ -import type { EndpointInfo, Extractor } from "../types.ts"; +import type { EndpointInfo, EndpointKind, Extractor } from "../types.ts"; import { buildLineIndex, endpoint, @@ -9,6 +9,27 @@ import { const PY_EXTS = [".py"]; +// Django generic class-based views that render templates → page. +// Match by base class name (last segment) so `TemplateView`, +// `django.views.generic.TemplateView`, or `generic.TemplateView` all work. +const PAGE_CBV_BASES = new Set([ + "TemplateView", + "ListView", + "DetailView", + "CreateView", + "UpdateView", + "DeleteView", + "FormView", +]); + +interface ClassDef { + bases: string[]; // last-segment names +} + +interface FuncDef { + body: string; // raw text of the function body (heuristic: until next top-level def/class) +} + export const django: Extractor = { id: "django", detect: { depKeywords: ["django"], markers: ["manage.py"], scope: "all" }, @@ -16,6 +37,76 @@ export const django: Extractor = { const endpoints: EndpointInfo[] = []; const pyFiles = ctx.iterFiles(PY_EXTS); + // ------------------------------------------------------------------- + // Pass 0: build a registry of class/function definitions across the + // project so url entries can be classified as page vs api. + // Registry is keyed by the symbol's last segment (e.g. `MyView`), + // which matches the way views are typically referenced in urls.py + // (`views.MyView.as_view()` → captured ref `views.MyView`). + // ------------------------------------------------------------------- + const classes: Record = {}; + const funcs: Record = {}; + + const classRe = /^class\s+(\w+)\s*\(([^)]*)\)\s*:/gm; + // Capture function bodies via "until next top-level def/class or EOF". + // JS regex has no \Z; use a lookahead that allows end-of-string by + // matching the next top-level def/class OR end-of-string. + const funcRe = + /^def\s+(\w+)\s*\([^)]*\)\s*:\s*\n([\s\S]*?)(?=^(?:def |class )|$(?![\s\S]))/gm; + + for (const f of pyFiles) { + const content = ctx.readFile(f); + if (!content) continue; + + for (const m of content.matchAll(classRe)) { + const name = m[1]!; + const bases = m[2]! + .split(",") + .map((b) => b.trim()) + .filter(Boolean) + .map((b) => b.split(".").pop()!.replace(/\s+/g, "")); + classes[name] = { bases }; + } + + for (const m of content.matchAll(funcRe)) { + const name = m[1]!; + const body = m[2] ?? ""; + funcs[name] = { body }; + } + } + + // Resolve the meaningful handler name from a view ref. + // CBVs are typically `views.HomeView.as_view()` → captured as + // `views.HomeView.as_view`. Strip the `.as_view`/`.as_asgi` suffix + // so the handler is `HomeView`, not `as_view`. + const resolveHandlerName = (viewRef: string): string => { + const parts = viewRef.split("."); + const last = parts[parts.length - 1]!; + if ((last === "as_view" || last === "as_asgi") && parts.length >= 2) { + return parts[parts.length - 2]!; + } + return last; + }; + + // Helper: classify a path() entry's view reference. + // - CBV with TemplateView/ListView/etc. base = page + // - FBV body containing render(...) = page + // - everything else = api + const classifyView = (viewRef: string): EndpointKind => { + const handler = resolveHandlerName(viewRef); + const cls = classes[handler]; + if (cls) { + if (cls.bases.some((b) => PAGE_CBV_BASES.has(b))) return "page"; + return "api"; + } + const fn = funcs[handler]; + if (fn && /\brender\s*\(/.test(fn.body)) return "page"; + return "api"; + }; + + // ------------------------------------------------------------------- + // urls.py / routes.py — HTTP routes + // ------------------------------------------------------------------- const urlFiles = pyFiles.filter((f) => { const name = f.split("/").pop()!; return name === "urls.py" || name === "routes.py"; @@ -62,12 +153,14 @@ export const django: Extractor = { const viewRef = m[2]!; const line = lines.lineAt(m.index); const fullPath = normalizePath(ownPrefix + routePath); + const kind = classifyView(viewRef); endpoints.push( endpoint({ method: "ANY", + kind, path: fullPath, - handler: viewRef.split(".").pop()!, + handler: resolveHandlerName(viewRef), file: rel, line, framework: "django", @@ -109,6 +202,47 @@ export const django: Extractor = { } } + // ------------------------------------------------------------------- + // Django Channels — websocket_urlpatterns from routing.py (any file). + // Each `path("ws/x/", Consumer.as_asgi())` or `re_path(...)` entry + // emits a websocket endpoint. + // ------------------------------------------------------------------- + const wsBlockRe = /websocket_urlpatterns\s*=\s*\[([\s\S]*?)\]/g; + const wsEntryRe = + /(?:re_path|path|url)\s*\(\s*r?['"]([^'"]*)['"]\s*,\s*(\w[\w.]*)/g; + + for (const f of pyFiles) { + const content = ctx.readFile(f); + if (!content || !content.includes("websocket_urlpatterns")) continue; + const rel = ctx.rel(f); + const lines = buildLineIndex(content); + + for (const block of content.matchAll(wsBlockRe)) { + const blockBody = block[1]!; + const blockOffset = block.index + block[0]!.indexOf(blockBody); + for (const m of blockBody.matchAll(wsEntryRe)) { + const routePath = m[1]!; + const consumerRef = m[2]!; + const absOffset = blockOffset + m.index; + const line = lines.lineAt(absOffset); + const fullPath = normalizePath(routePath); + + endpoints.push( + endpoint({ + method: "WS", + kind: "websocket", + path: fullPath, + handler: resolveHandlerName(consumerRef), + file: rel, + line, + framework: "django", + params: extractPathParams(fullPath), + }), + ); + } + } + } + return endpoints; }, }; diff --git a/src/extractors/express.test.ts b/src/extractors/express.test.ts new file mode 100644 index 0000000..dea545e --- /dev/null +++ b/src/extractors/express.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { map } from "../index.ts"; + +// See fastapi.test.ts for the canonical kind-detection test pattern. +// 1. Load fixture dir under src/extractors/__fixtures__// +// 2. Call map() with frameworkOverride for isolation +// 3. Assert kinds via expect.objectContaining + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, "__fixtures__/express"); + +describe("express extractor", () => { + it("emits correct kinds for api, page, and websocket routes", () => { + const result = map(FIXTURE_DIR, { frameworkOverride: "express" }); + const endpoints = result.endpoints.all; + + // Plain JSON route → api + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "GET", + path: "/api/users", + kind: "api", + }), + ); + + // res.render(...) → page + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "GET", + path: "/about", + kind: "page", + }), + ); + + // res.sendFile(...) → page + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "GET", + path: "/file", + kind: "page", + }), + ); + + // express-ws → websocket + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "WS", + path: "/chat", + kind: "websocket", + }), + ); + }); +}); diff --git a/src/extractors/express.ts b/src/extractors/express.ts index d145b99..b913718 100644 --- a/src/extractors/express.ts +++ b/src/extractors/express.ts @@ -9,6 +9,98 @@ import { const JS_EXTS = [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"]; +// Kind detection signals (inspected against the full route call body): +// - res.render(...) → server-side rendered page +// - res.sendFile(...) → file response, treated as a page +// - default → api +const PAGE_RENDER_RE = /\bres\s*\.\s*render\s*\(/; +const PAGE_SENDFILE_RE = /\bres\s*\.\s*sendFile\s*\(/; + +/** + * Walks forward from `start` (the index of the opening `(`) and returns the + * index of the matching closing `)`, respecting nested parens, brackets, + * braces, string literals, template literals, and line/block comments. + * + * Returns -1 if no match is found before EOF. + */ +function findMatchingParen(content: string, start: number): number { + let depth = 0; + let i = start; + const len = content.length; + + while (i < len) { + const ch = content[i]!; + + // Line comment + if (ch === "/" && content[i + 1] === "/") { + const nl = content.indexOf("\n", i); + if (nl === -1) return -1; + i = nl + 1; + continue; + } + // Block comment + if (ch === "/" && content[i + 1] === "*") { + const end = content.indexOf("*/", i + 2); + if (end === -1) return -1; + i = end + 2; + continue; + } + // String literals (', ", `) + if (ch === "'" || ch === '"') { + i++; + while (i < len) { + const c = content[i]!; + if (c === "\\") { + i += 2; + continue; + } + if (c === ch) { + i++; + break; + } + i++; + } + continue; + } + if (ch === "`") { + i++; + while (i < len) { + const c = content[i]!; + if (c === "\\") { + i += 2; + continue; + } + if (c === "`") { + i++; + break; + } + // Template expression ${...} + if (c === "$" && content[i + 1] === "{") { + i += 2; + let d = 1; + while (i < len && d > 0) { + const cc = content[i]!; + if (cc === "{") d++; + else if (cc === "}") d--; + i++; + } + continue; + } + i++; + } + continue; + } + + if (ch === "(" || ch === "[" || ch === "{") depth++; + else if (ch === ")" || ch === "]" || ch === "}") { + depth--; + if (depth === 0 && ch === ")") return i; + } + i++; + } + return -1; +} + export const express: Extractor = { id: "express", detect: { @@ -21,7 +113,7 @@ export const express: Extractor = { const endpoints: EndpointInfo[] = []; const jsFiles = ctx.iterFiles(JS_EXTS); - // Pass 1: find router mounts + // Pass 1: find router mounts, e.g. `app.use("/api", router)` const mountPrefixes: Record = {}; const mountRe = /(?:app|router)\s*\.\s*use\s*\(\s*['"]([^'"]+)['"]\s*,\s*(\w+)/g; @@ -34,9 +126,14 @@ export const express: Extractor = { } } - // Pass 2: extract route handlers - const routeRe = - /(\w+)\s*\.\s*(get|post|put|delete|patch|all|head|options)\s*\(\s*['"]([^'"]+)['"]\s*,\s*(.*?)\)/gs; + // Pass 2: HTTP route handlers + express-ws routes + // We scan for the route-call opener, then balance parens to capture the + // ENTIRE call (so handler bodies — even multi-line arrow fns — are + // available for kind detection). + // + // Methods include `ws` to capture express-ws routes (`app.ws("/path", ...)`). + const routeOpenerRe = + /(\w+)\s*\.\s*(get|post|put|delete|patch|all|head|options|ws)\s*\(\s*(['"])([^'"]+)\3\s*,/g; for (const f of jsFiles) { const content = ctx.readFile(f); @@ -44,24 +141,61 @@ export const express: Extractor = { const rel = ctx.rel(f); const lines = buildLineIndex(content); - for (const m of content.matchAll(routeRe)) { + for (const m of content.matchAll(routeOpenerRe)) { const varName = m[1]!; - let httpMethod = m[2]!.toUpperCase(); - const routePath = m[3]!; - const handlerArgs = m[4]!; - const line = lines.lineAt(m.index); + const methodTok = m[2]!.toLowerCase(); + const routePath = m[4]!; + const matchStart = m.index; + const line = lines.lineAt(matchStart); + // The opening paren of the route call: `app.get(` ← that one. + // Locate it by scanning forward from the variable for the first `(`. + const openParen = content.indexOf("(", matchStart); + if (openParen === -1) continue; + const closeParen = findMatchingParen(content, openParen); + if (closeParen === -1) continue; + + // Full call body between the parens. + const callBody = content.slice(openParen + 1, closeParen); + // Drop the leading path-string + comma to get just the handler args. + const afterPathComma = callBody.indexOf(","); + const handlerArgs = + afterPathComma >= 0 ? callBody.slice(afterPathComma + 1) : callBody; + + const isWs = methodTok === "ws"; + let httpMethod = methodTok.toUpperCase(); if (httpMethod === "ALL") httpMethod = "ANY"; + if (isWs) httpMethod = "WS"; const prefix = mountPrefixes[varName] ?? ""; const fullPath = normalizePath(prefix + routePath); - const handlerMatch = handlerArgs.match(/(\w+)\s*[,)]/); + + // Best-effort handler name: first identifier in args. Misses inline + // arrow fns (recorded as ), which is fine. + const handlerNameMatch = handlerArgs.match(/^\s*(\w+)\s*[,)]/); + const handlerName = handlerNameMatch + ? handlerNameMatch[1]! + : ""; + + // Page detection: inspect the handler body for `res.render(` or + // `res.sendFile(`. Both indicate an HTML/file response, so the route + // is a server-side page rather than a JSON API. + let kind: "api" | "page" | "websocket" = "api"; + if (isWs) { + kind = "websocket"; + } else if ( + PAGE_RENDER_RE.test(handlerArgs) || + PAGE_SENDFILE_RE.test(handlerArgs) + ) { + kind = "page"; + } endpoints.push( endpoint({ method: httpMethod, + kind, path: fullPath, - handler: handlerMatch ? handlerMatch[1]! : "", + handler: handlerName, file: rel, line, framework: "express", @@ -72,6 +206,95 @@ export const express: Extractor = { } } + // Pass 3: socket.io websocket detection. + // - `io.of("/ns")` → websocket endpoint at "/ns" (the namespace IS the path) + // - `io.on("connection", ...)` (without a preceding `.of(...)`) → "/" namespace + // Best-effort: we don't try to track which `io` instance is which, just + // emit one endpoint per `.of("/ns")` plus a single root endpoint if any + // `connection` handler is registered without a namespace. + const ioOfRe = /\b(\w+)\s*\.\s*of\s*\(\s*(['"])([^'"]+)\2\s*\)/g; + const ioOnConnRe = + /\b(\w+)\s*\.\s*on\s*\(\s*(['"])connection\2\s*,\s*(.*?)\)/gs; + // ws / WebSocketServer detection: `new WebSocketServer(...)` or + // `new WebSocket.Server(...)`. Single endpoint, path "/". + const wsServerRe = + /new\s+(?:WebSocket\s*\.\s*Server|WebSocketServer)\s*\(/g; + + for (const f of jsFiles) { + const content = ctx.readFile(f); + if (!content) continue; + const rel = ctx.rel(f); + const lines = buildLineIndex(content); + + // Cheap hint: only run io.* scans when the file references socket.io + // patterns. Avoids spurious matches on unrelated `.of(...)` chains. + const looksLikeSocketIo = + /socket\.io|io\s*\.\s*on\s*\(\s*['"]connection/.test(content); + + const namespacePaths = new Set(); + if (looksLikeSocketIo) { + for (const m of content.matchAll(ioOfRe)) { + const ns = m[3]!; + namespacePaths.add(ns); + endpoints.push( + endpoint({ + method: "WS", + kind: "websocket", + path: normalizePath(ns), + handler: "", + file: rel, + line: lines.lineAt(m.index), + framework: "express", + params: extractPathParams(normalizePath(ns)), + }), + ); + } + + // A single `io.on("connection", ...)` registers the root "/" namespace. + // Only emit once per file even if the pattern appears multiple times. + const firstConn = ioOnConnRe.exec(content); + ioOnConnRe.lastIndex = 0; + if (firstConn && !namespacePaths.has("/")) { + endpoints.push( + endpoint({ + method: "WS", + kind: "websocket", + path: "/", + handler: "", + file: rel, + line: lines.lineAt(firstConn.index), + framework: "express", + }), + ); + } + } + + // ws library: each WebSocketServer instantiation is a single endpoint. + // Path discovery is non-trivial (often configured via separate options + // or attached to an http server), so we conservatively use "/". + // Skip if file already emitted a socket.io "/" endpoint to avoid dup. + const hasRootIoEndpoint = + looksLikeSocketIo && + (namespacePaths.has("/") || ioOnConnRe.test(content)); + ioOnConnRe.lastIndex = 0; + if (!hasRootIoEndpoint) { + for (const m of content.matchAll(wsServerRe)) { + endpoints.push( + endpoint({ + method: "WS", + kind: "websocket", + path: "/", + handler: "", + file: rel, + line: lines.lineAt(m.index), + framework: "express", + }), + ); + break; // one endpoint per file is enough + } + } + } + return endpoints; }, }; diff --git a/src/extractors/fastapi.test.ts b/src/extractors/fastapi.test.ts new file mode 100644 index 0000000..df26d4e --- /dev/null +++ b/src/extractors/fastapi.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { map } from "../index.ts"; + +// Pattern for kind-detection tests: +// 1. Load fixture dir under src/extractors/__fixtures__// +// 2. Call map() against it (filter via frameworkOverride for isolation) +// 3. Assert endpoints contain expected shapes by kind using +// expect.objectContaining so unrelated fields don't break the match. +// +// Other framework extractor tests should mirror this structure exactly. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, "__fixtures__/fastapi"); + +describe("fastapi extractor", () => { + it("emits correct kinds for api, page, and websocket routes", async () => { + const result = map(FIXTURE_DIR, { frameworkOverride: "fastapi" }); + const endpoints = result.endpoints.all; + + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "GET", + path: "/health", + kind: "api", + }), + ); + + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "GET", + path: "/about", + kind: "page", + }), + ); + + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "GET", + path: "/dashboard", + kind: "page", + }), + ); + + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "WS", + path: "/ws", + kind: "websocket", + }), + ); + }); +}); diff --git a/src/extractors/fastapi.ts b/src/extractors/fastapi.ts index b2a3d5c..d8b43d4 100644 --- a/src/extractors/fastapi.ts +++ b/src/extractors/fastapi.ts @@ -41,8 +41,9 @@ export const fastapi: Extractor = { } // Pass 2: extract routes + // Group 4 captures the remaining decorator args (e.g. `response_class=HTMLResponse`). const routeRe = - /@(\w+)\.(get|post|put|delete|patch|head|options|websocket)\s*\(\s*['"]([^'"]+)['"]\s*(?:,.*?)?\)(.*?)(?:async\s+)?def\s+(\w+)\s*\(([^)]*)\)/gs; + /@(\w+)\.(get|post|put|delete|patch|head|options|websocket)\s*\(\s*['"]([^'"]+)['"]\s*((?:,.*?)?)\)(.*?)(?:async\s+)?def\s+(\w+)\s*\(([^)]*)\)/gs; for (const f of pyFiles) { const content = ctx.readFile(f); @@ -54,14 +55,36 @@ export const fastapi: Extractor = { const varName = m[1]!; let httpMethod = m[2]!.toUpperCase(); const routePath = m[3]!; - const between = m[4]!; - const funcName = m[5]!; - const funcParams = m[6]!; + const decoratorArgs = m[4]!; + const between = m[5]!; + const funcName = m[6]!; + const funcParams = m[7]!; const line = lines.lineAt(m.index); const isWebSocket = httpMethod === "WEBSOCKET"; if (isWebSocket) httpMethod = "WS"; + // Page detection: response_class=HTMLResponse decorator arg, or + // HTMLResponse()/TemplateResponse() in body = page. + // Bound the body window to the next top-level decorator/def so we don't + // bleed into adjacent routes' bodies. + const bodyStart = m.index + m[0].length; + const after = content.slice(bodyStart, bodyStart + 4000); + const nextRouteRe = /\n(?=@\w+\.|def\s|async\s+def\s)/; + const nextMatch = after.match(nextRouteRe); + const body = nextMatch ? after.slice(0, nextMatch.index) : after; + const isPage = + !isWebSocket && + (/response_class\s*=\s*\w*HTMLResponse\b/.test(decoratorArgs) || + /\bHTMLResponse\s*\(/.test(body) || + /\.TemplateResponse\s*\(/.test(body)); + + const kind: EndpointInfo["kind"] = isWebSocket + ? "websocket" + : isPage + ? "page" + : "api"; + const prefix = routerPrefixes[varName] ?? ""; const fullPath = normalizePath(prefix + routePath); const auth = findAuthDecorators(between + funcParams); @@ -84,7 +107,7 @@ export const fastapi: Extractor = { endpoints.push( endpoint({ method: httpMethod, - kind: isWebSocket ? "websocket" : "api", + kind, path: fullPath, handler: funcName, file: rel, diff --git a/src/extractors/flask.test.ts b/src/extractors/flask.test.ts new file mode 100644 index 0000000..b8be432 --- /dev/null +++ b/src/extractors/flask.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { map } from "../index.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, "__fixtures__/flask"); + +describe("flask extractor", () => { + it("emits page kind for handlers using render_template, api otherwise", () => { + const result = map(FIXTURE_DIR, { frameworkOverride: "flask" }); + const endpoints = result.endpoints.all; + + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "GET", + path: "/api/users", + kind: "api", + }), + ); + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "GET", + path: "/about", + kind: "page", + }), + ); + }); +}); diff --git a/src/extractors/flask.ts b/src/extractors/flask.ts index 2fd58af..e69d954 100644 --- a/src/extractors/flask.ts +++ b/src/extractors/flask.ts @@ -44,6 +44,10 @@ export const flask: Extractor = { const routeRe = /@(\w+)\.(?:route|get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"](?:\s*,\s*methods\s*=\s*\[([^\]]*)\])?\s*\)(.*?)(?:async\s+)?def\s+(\w+)\s*\(/gs; + // Detect handler body boundary: next top-level def/class or decorator at column 0. + // Used to scope render_template detection to the current handler only. + const bodyEndRe = /^(?:@\w|(?:async\s+)?def\s|class\s)/m; + for (const f of pyFiles) { const content = ctx.readFile(f); if (!content) continue; @@ -74,10 +78,22 @@ export const flask: Extractor = { const auth = findAuthDecorators(between); const params = extractPathParams(fullPath); + // Slice handler body from end of `def name(` match to next top-level + // def/class/decorator. Body containing render_template(...) / + // render_template_string(...) = HTML page; otherwise = JSON api. + const bodyStart = m.index + m[0].length; + const rest = content.slice(bodyStart); + const endMatch = bodyEndRe.exec(rest); + const body = endMatch ? rest.slice(0, endMatch.index) : rest; + const kind = /\brender_template(?:_string)?\s*\(/.test(body) + ? "page" + : "api"; + for (const method of methods) { endpoints.push( endpoint({ method, + kind, path: fullPath, handler: funcName, file: rel, diff --git a/src/extractors/go.test.ts b/src/extractors/go.test.ts new file mode 100644 index 0000000..db62834 --- /dev/null +++ b/src/extractors/go.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { map } from "../index.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, "__fixtures__/go-gin"); + +describe("go (gin) extractor", () => { + it("emits websocket kind for gorilla-upgraded handlers, api otherwise", () => { + const result = map(FIXTURE_DIR, { frameworkOverride: "gin" }); + const endpoints = result.endpoints.all; + + expect(endpoints).toContainEqual( + expect.objectContaining({ path: "/api/users", kind: "api" }), + ); + expect(endpoints).toContainEqual( + expect.objectContaining({ path: "/ws", kind: "websocket" }), + ); + }); +}); diff --git a/src/extractors/go.ts b/src/extractors/go.ts index 9b17d2a..1b1ee79 100644 --- a/src/extractors/go.ts +++ b/src/extractors/go.ts @@ -10,6 +10,57 @@ import { const GO_EXTS = [".go"]; const GO_GROUP_RE = /(\w+)\s*[:=]+\s*(\w+)\.Group\s*\(\s*['"]([^'"]+)['"]/g; +// Websocket detection signals: +// - File imports "github.com/gorilla/websocket" (the de-facto Go ws lib). +// - Handler function body contains a `.Upgrade(` call (gorilla's upgrader, +// also gin's c.Upgrade()). +// When both hold, the route is a websocket. Default remains "api". +const GORILLA_WS_IMPORT_RE = /["']github\.com\/gorilla\/websocket["']/; +const UPGRADE_CALL_RE = /\.\s*Upgrade\s*\(/; + +/** + * Build a per-file map of websocket-handler names. A file's handler is a + * websocket handler iff the file imports gorilla/websocket AND the function + * body contains `.Upgrade(`. Returns the set of handler names found across + * all scanned Go files (handler names are typically globally unique within + * a Go package, which is good enough for this heuristic). + */ +function findWebsocketHandlers( + ctx: Parameters[0], + goFiles: string[], +): Set { + const wsHandlers = new Set(); + // Match a top-level `func Name(...)` or `func (recv T) Name(...)` and capture + // its body via balanced braces below. + const funcRe = /\bfunc\s+(?:\([^)]*\)\s+)?(\w+)\s*\([^)]*\)[^{]*\{/g; + + for (const f of goFiles) { + const content = ctx.readFile(f); + if (!content) continue; + if (!GORILLA_WS_IMPORT_RE.test(content)) continue; + + for (const m of content.matchAll(funcRe)) { + const name = m[1]!; + const bodyStart = m.index + m[0].length; // just past the opening `{` + // Walk forward to find the matching closing brace (naive — does not + // strip comments/strings, but Go's `{`/`}` balance in source is reliable + // enough for this heuristic). + let depth = 1; + let i = bodyStart; + const len = content.length; + while (i < len && depth > 0) { + const ch = content[i]!; + if (ch === "{") depth++; + else if (ch === "}") depth--; + i++; + } + const body = content.slice(bodyStart, i - 1); + if (UPGRADE_CALL_RE.test(body)) wsHandlers.add(name); + } + } + return wsHandlers; +} + function extractGoFramework( ctx: Parameters[0], framework: FrameworkId, @@ -18,6 +69,9 @@ function extractGoFramework( const endpoints: EndpointInfo[] = []; const goFiles = ctx.iterFiles(GO_EXTS); + // Pass 0: collect websocket handler names across the repo. + const wsHandlers = findWebsocketHandlers(ctx, goFiles); + // Pass 1: find group definitions const groupPrefixes: Record = {}; const groupParents: Record = {}; @@ -49,6 +103,11 @@ function extractGoFramework( if (!content) continue; const rel = ctx.rel(f); const lines = buildLineIndex(content); + // A route's handler is a ws handler if either: + // (a) the resolved handler name is in wsHandlers, OR + // (b) the route handler args themselves contain `.Upgrade(` (inline + // upgrade) AND the file imports gorilla/websocket. + const fileImportsGorilla = GORILLA_WS_IMPORT_RE.test(content); for (const m of content.matchAll(routeRe)) { const varName = m[1]!; @@ -61,13 +120,24 @@ function extractGoFramework( const prefix = resolved[varName] ?? ""; const fullPath = normalizePath(prefix + routePath); - const handlerMatch = handlerArgs.match(/(\w+)\s*[,)]/); + // Handler is the LAST identifier in the args (the route call may include + // preceding middleware, e.g. `r.GET(path, authMW, handler)`). + const idMatches = [...handlerArgs.matchAll(/(\w+)/g)]; + const handlerName = + idMatches.length > 0 + ? idMatches[idMatches.length - 1]![1]! + : ""; + + const inlineUpgrade = + fileImportsGorilla && UPGRADE_CALL_RE.test(handlerArgs); + const isWebsocket = wsHandlers.has(handlerName) || inlineUpgrade; endpoints.push( endpoint({ - method: httpMethod, + method: isWebsocket ? "WS" : httpMethod, + kind: isWebsocket ? "websocket" : "api", path: fullPath, - handler: handlerMatch ? handlerMatch[1]! : "", + handler: handlerName, file: rel, line, framework, @@ -137,6 +207,9 @@ export const netHttp: Extractor = { const handleRe = /(?:http\.HandleFunc|mux\.HandleFunc|http\.Handle|mux\.Handle)\s*\(\s*['"]([^'"]+)['"]\s*,\s*(\w+)/g; + // gorilla/websocket import + .Upgrade() call in handler body = websocket route. + const wsHandlers = findWebsocketHandlers(ctx, goFiles); + for (const f of goFiles) { const content = ctx.readFile(f); if (!content) continue; @@ -145,11 +218,14 @@ export const netHttp: Extractor = { const lines = buildLineIndex(content); for (const m of content.matchAll(handleRe)) { const fullPath = normalizePath(m[1]!); + const handlerName = m[2]!; + const isWebsocket = wsHandlers.has(handlerName); endpoints.push( endpoint({ - method: "ANY", + method: isWebsocket ? "WS" : "ANY", + kind: isWebsocket ? "websocket" : "api", path: fullPath, - handler: m[2]!, + handler: handlerName, file: rel, line: lines.lineAt(m.index), framework: "net_http", diff --git a/src/extractors/laravel.test.ts b/src/extractors/laravel.test.ts new file mode 100644 index 0000000..119c17b --- /dev/null +++ b/src/extractors/laravel.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { map } from "../index.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, "__fixtures__/laravel"); + +describe("laravel extractor", () => { + it("emits page kind for web.php routes and api kind for api.php routes", () => { + const result = map(FIXTURE_DIR, { frameworkOverride: "laravel" }); + const endpoints = result.endpoints.all; + + expect(endpoints).toContainEqual( + expect.objectContaining({ path: "/about", kind: "page" }), + ); + expect(endpoints).toContainEqual( + expect.objectContaining({ path: "/api/users", kind: "api" }), + ); + }); +}); diff --git a/src/extractors/laravel.ts b/src/extractors/laravel.ts index bbc8614..596a121 100644 --- a/src/extractors/laravel.ts +++ b/src/extractors/laravel.ts @@ -33,7 +33,11 @@ export const laravel: Extractor = { ctx.filesScanned++; const rel = ctx.rel(rf); + // routes/web.php = session-based page routes (Blade views, CSRF, session middleware). + // routes/api.php = stateless API routes (prefixed with /api by Laravel convention). + const isWeb = rf.endsWith("web.php"); const filePrefix = rf.endsWith("api.php") ? "/api" : ""; + const kind = isWeb ? "page" : "api"; const groupPrefixes: string[] = []; for (const pm of content.matchAll(prefixGroupRe)) { @@ -67,6 +71,7 @@ export const laravel: Extractor = { file: rel, line: lines.lineAt(m.index), framework: "laravel", + kind, params: extractPathParams(fullPath), auth: [...fileAuth], }), diff --git a/src/extractors/nestjs.test.ts b/src/extractors/nestjs.test.ts new file mode 100644 index 0000000..ccb1061 --- /dev/null +++ b/src/extractors/nestjs.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { map } from "../index.ts"; + +// Pattern for kind-detection tests: +// 1. Load fixture dir under src/extractors/__fixtures__// +// 2. Call map() against it (filter via frameworkOverride for isolation) +// 3. Assert endpoints contain expected shapes by kind using +// expect.objectContaining so unrelated fields don't break the match. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, "__fixtures__/nestjs"); + +describe("nestjs extractor", () => { + it("emits correct kinds for api, page, and websocket routes", () => { + const result = map(FIXTURE_DIR, { frameworkOverride: "nestjs" }); + const endpoints = result.endpoints.all; + + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "GET", + path: expect.stringContaining("users"), + kind: "api", + }), + ); + + expect(endpoints).toContainEqual( + expect.objectContaining({ + path: expect.stringContaining("about"), + kind: "page", + }), + ); + + expect(endpoints).toContainEqual( + expect.objectContaining({ + path: expect.stringContaining("message"), + kind: "websocket", + }), + ); + }); + + it("does not let @Render bleed onto sibling api methods", () => { + const result = map(FIXTURE_DIR, { frameworkOverride: "nestjs" }); + const endpoints = result.endpoints.all; + + // The "/data" endpoint sits directly below a @Render-decorated method + // in the same controller. Its kind must be "api", not "page". + const data = endpoints.find((e) => e.path.endsWith("data")); + expect(data).toBeDefined(); + expect(data?.kind).toBe("api"); + }); +}); diff --git a/src/extractors/nestjs.ts b/src/extractors/nestjs.ts index 7e195cc..622a858 100644 --- a/src/extractors/nestjs.ts +++ b/src/extractors/nestjs.ts @@ -1,4 +1,4 @@ -import type { EndpointInfo, Extractor } from "../types.ts"; +import type { EndpointInfo, EndpointKind, Extractor } from "../types.ts"; import { buildLineIndex, endpoint, @@ -6,6 +6,106 @@ import { normalizePath, } from "../utils.ts"; +// --------------------------------------------------------------------------- +// Kind detection signals +// --------------------------------------------------------------------------- +// - Default: a controller method with @Get/@Post/... → kind: "api" +// - @Render('view') decorator on a method → kind: "page" (server-side +// rendered template; the method returns view data, not JSON) +// - @WebSocketGateway() class + @SubscribeMessage('event') method → +// kind: "websocket" (event-driven socket handler, not an HTTP route) +// --------------------------------------------------------------------------- + +interface ClassRange { + start: number; + end: number; + name: string; + // Decorators attached to the class declaration (text immediately preceding + // the `class` keyword, back to the previous class or top of file). + decorators: string; +} + +/** Find every `class Foo` declaration with the decorator preamble that + * immediately precedes it. Used to associate methods with their owning + * class so we can read class-level decorators (e.g. @WebSocketGateway, + * @Controller). */ +function findClasses(content: string): ClassRange[] { + const classes: ClassRange[] = []; + const classRe = /\bclass\s+(\w+)/g; + let lastEnd = 0; + for (const m of content.matchAll(classRe)) { + const start = m.index!; + const name = m[1]!; + const decorators = content.slice(lastEnd, start); + // Patch the previous class's `end` to be this class's start + if (classes.length > 0) classes[classes.length - 1]!.end = start; + classes.push({ start, end: content.length, name, decorators }); + lastEnd = start; + } + return classes; +} + +// Returns the contiguous decorator stack around `offset` (the position of an +// `@Get/@Post/...` match), walking outward line-by-line. Stops at lines that +// end the previous method or statement (}, ;) or otherwise look like code, +// so a `@Render` on a sibling method cannot bleed into the result. +function methodDecoratorBlock(content: string, offset: number): string { + const lineStart = (pos: number) => { + let p = pos; + while (p > 0 && content[p - 1] !== "\n") p--; + return p; + }; + const isStackLine = (line: string) => + line === "" || + line.startsWith("@") || + line.startsWith("//") || + line.startsWith("/*") || + line.startsWith("*") || + line.endsWith(",") || + line.endsWith("(") || + line.endsWith(")"); + const endsPrevStatement = (line: string) => + line.endsWith("}") || line.endsWith(";"); + + // Walk backwards from the @Get line. + let start = lineStart(offset); + while (start > 0) { + const prev = lineStart(start - 1); + const line = content.slice(prev, start - 1).trim(); + if (endsPrevStatement(line)) break; + if (!isStackLine(line)) break; + start = prev; + } + + // Walk forwards past any decorators between @Get and the method signature. + // The method signature is the first line that doesn't start with `@` and + // isn't a continuation of decorator args. + let end = offset; + while (end < content.length && content[end] !== "\n") end++; + while (end < content.length) { + const nextStart = end + 1; + let nextEnd = nextStart; + while (nextEnd < content.length && content[nextEnd] !== "\n") nextEnd++; + const line = content.slice(nextStart, nextEnd).trim(); + if (line === "") { + end = nextEnd; + continue; + } + if (line.startsWith("@") || line.startsWith("//") || line.startsWith("*")) { + end = nextEnd; + continue; + } + if (line.endsWith(",") || line.endsWith(")")) { + // Continuation of a multi-line decorator argument list. + end = nextEnd; + continue; + } + break; + } + + return content.slice(start, end); +} + export const nestjs: Extractor = { id: "nestjs", detect: { depKeywords: ["@nestjs/core"], markers: [], scope: "root" }, @@ -14,37 +114,123 @@ export const nestjs: Extractor = { const tsFiles = ctx.iterFiles([".ts"]); const controllerRe = /@Controller\s*\(\s*['"]([^'"]*)['"]\s*\)/; + const wsGatewayRe = /@WebSocketGateway\s*\(([^)]*)\)|@WebSocketGateway\b/; + // Captures: 1=method, 2=path, 3=between (text from after ) to handler), 4=handler + // The lazy `.*?` may stop short if a Nest decorator like @Render / @Header + // follows the http verb — those would otherwise be captured as the handler + // name. We re-scan a forward window after the match to catch them. const methodRe = /@(Get|Post|Put|Delete|Patch|Head|Options)\s*\(\s*['"]?([^'")]*?)['"]?\s*\)(.*?)(?:async\s+)?(\w+)\s*\(/gs; + // @SubscribeMessage('event'): websocket event handler + const subscribeRe = + /@SubscribeMessage\s*\(\s*['"]([^'"]*)['"]\s*\)(.*?)(?:async\s+)?(\w+)\s*\(/gs; const guardRe = /@UseGuards?\s*\(\s*(\w+)/g; for (const f of tsFiles) { const content = ctx.readFile(f); - if (!content || !content.includes("@Controller")) continue; + if (!content) continue; + // Only consider files that look like Nest classes — controllers or gateways. + if ( + !content.includes("@Controller") && + !content.includes("@WebSocketGateway") + ) + continue; const rel = ctx.rel(f); - const ctrlMatch = controllerRe.exec(content); - const controllerPrefix = ctrlMatch?.[1] ?? ""; + const lines = buildLineIndex(content); + const classes = findClasses(content); + + // Fallback: file-level controller prefix (used when methods appear + // outside any class, or the class scan fails for some reason). + const fileCtrlMatch = controllerRe.exec(content); + const fileControllerPrefix = fileCtrlMatch?.[1] ?? ""; - // Class-level guards - const classAuth: string[] = []; - const classIdx = content.indexOf("class "); - if (classIdx > 0) { - const preClass = content.slice(0, classIdx); + // Class-level guards from the very first class block (legacy behaviour). + const fileClassAuth: string[] = []; + const firstClassIdx = content.indexOf("class "); + if (firstClassIdx > 0) { + const preClass = content.slice(0, firstClassIdx); for (const gm of preClass.matchAll(guardRe)) { - classAuth.push(`@UseGuards(${gm[1]})`); + fileClassAuth.push(`@UseGuards(${gm[1]})`); } } - const lines = buildLineIndex(content); + // Resolve the class containing a given offset (or null if none). + const classAt = (offset: number): ClassRange | null => { + for (const c of classes) { + if (offset >= c.start && offset < c.end) return c; + } + return null; + }; + + // Per-class context: prefix, guards, whether it's a websocket gateway, + // and an optional namespace pulled out of @WebSocketGateway options. + const classCtx = new Map< + string, + { + prefix: string; + auth: string[]; + isGateway: boolean; + wsNamespace: string; + } + >(); + for (const c of classes) { + const ctrl = controllerRe.exec(c.decorators); + const prefix = ctrl?.[1] ?? ""; + const auth: string[] = []; + for (const gm of c.decorators.matchAll(guardRe)) { + auth.push(`@UseGuards(${gm[1]})`); + } + const ws = wsGatewayRe.exec(c.decorators); + const isGateway = !!ws; + // @WebSocketGateway(81, { namespace: '/events' }) → '/events' + let wsNamespace = ""; + if (ws && ws[1]) { + const nsMatch = /namespace\s*:\s*['"]([^'"]*)['"]/.exec(ws[1]); + if (nsMatch) wsNamespace = nsMatch[1]!; + } + classCtx.set(c.name, { prefix, auth, isGateway, wsNamespace }); + } + + // ----------------------------------------------------------------- + // HTTP method handlers (@Get/@Post/...): kind = "api" or "page" + // ----------------------------------------------------------------- for (const m of content.matchAll(methodRe)) { const httpMethod = m[1]!.toUpperCase(); const routePath = m[2]!; const between = m[3]!; - const handler = m[4]!; - const line = lines.lineAt(m.index); + let handler = m[4]!; + const offset = m.index!; + const line = lines.lineAt(offset); + + // The lazy capture may have grabbed a Nest decorator name (e.g. + // @Render, @Header) as the "handler" because that decorator sits + // between @Get and the real method. If `between` still contains an + // `@`, walk forward past any remaining decorators to find the real + // handler identifier. + if (between.includes("@")) { + const fwd = content.slice(offset, offset + 600); + const after = fwd.indexOf(")"); + if (after >= 0) { + const handlerRe = + /(?:@\w+\s*\([^)]*\)\s*)*(?:async\s+)?(\w+)\s*\(/g; + handlerRe.lastIndex = after + 1; + const hm = handlerRe.exec(fwd); + if (hm) handler = hm[1]!; + } + } - const fullPath = normalizePath(controllerPrefix + "/" + routePath); + // Pick up class-level info if the match is inside a class; fall back + // to the legacy file-level lookup otherwise. + const cls = classAt(offset); + const prefix = cls + ? (classCtx.get(cls.name)?.prefix ?? "") + : fileControllerPrefix; + const classAuth = cls + ? (classCtx.get(cls.name)?.auth ?? []) + : fileClassAuth; + + const fullPath = normalizePath(prefix + "/" + routePath); const params = extractPathParams(fullPath); const methodAuth = [...classAuth]; @@ -60,6 +246,16 @@ export const nestjs: Extractor = { params.push({ name: pm[1]!, location: "path", required: true }); } + // @Render decorator on the method = server-side rendered page → + // kind: "page". The decorator may appear before @Get in the preceding + // decorator stack, or between @Get and the handler. Walk line-by-line + // out from the @Get match to capture exactly the contiguous decorator + // stack of THIS method — stopping at any line that ends a previous + // method or statement (}, ;) so we don't bleed into adjacent methods. + const decoratorBlock = methodDecoratorBlock(content, offset); + const hasRender = /@Render\s*\(/.test(decoratorBlock); + const kind: EndpointKind = hasRender ? "page" : "api"; + endpoints.push( endpoint({ method: httpMethod, @@ -68,11 +264,52 @@ export const nestjs: Extractor = { file: rel, line, framework: "nestjs", + kind, params, auth: methodAuth, }), ); } + + // ----------------------------------------------------------------- + // Websocket handlers: @SubscribeMessage inside an @WebSocketGateway + // class. Path = (gateway namespace) + event name. method = "WS". + // ----------------------------------------------------------------- + for (const m of content.matchAll(subscribeRe)) { + const eventName = m[1]!; + const between = m[2]!; + const handler = m[3]!; + const offset = m.index!; + const line = lines.lineAt(offset); + + const cls = classAt(offset); + if (!cls) continue; + const info = classCtx.get(cls.name); + if (!info || !info.isGateway) continue; + + const fullPath = normalizePath( + (info.wsNamespace || "") + "/" + eventName, + ); + + const methodAuth = [...info.auth]; + for (const gm of between.matchAll(/@UseGuards?\s*\(\s*(\w+)/g)) { + methodAuth.push(`@UseGuards(${gm[1]})`); + } + + endpoints.push( + endpoint({ + method: "WS", + path: fullPath, + handler, + file: rel, + line, + framework: "nestjs", + kind: "websocket", + params: extractPathParams(fullPath), + auth: methodAuth, + }), + ); + } } return endpoints; diff --git a/src/extractors/nextjs.test.ts b/src/extractors/nextjs.test.ts index c83cb22..8a5d028 100644 --- a/src/extractors/nextjs.test.ts +++ b/src/extractors/nextjs.test.ts @@ -1,9 +1,11 @@ -import { describe, expect, test } from "bun:test"; -import { resolve } from "path"; +import { describe, expect, test } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { nextjs } from "./nextjs.ts"; import { createScanContext } from "../scan-context.ts"; -const FIXTURE = resolve(import.meta.dir, "../../scripts/fixtures/nextjs-pages"); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE = path.resolve(__dirname, "../../scripts/fixtures/nextjs-pages"); function extract(fixturePath: string = FIXTURE) { const ctx = createScanContext(fixturePath); diff --git a/src/extractors/rails.test.ts b/src/extractors/rails.test.ts new file mode 100644 index 0000000..df21a1d --- /dev/null +++ b/src/extractors/rails.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { map } from "../index.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, "__fixtures__/rails"); + +describe("rails extractor", () => { + it("emits page kind for ActionController::Base controllers and api kind for ActionController::API", () => { + const result = map(FIXTURE_DIR, { frameworkOverride: "rails" }); + const endpoints = result.endpoints.all; // getter, no parens + + expect(endpoints).toContainEqual( + expect.objectContaining({ path: "/about", kind: "page" }), + ); + expect(endpoints).toContainEqual( + expect.objectContaining({ path: "/api/users", kind: "api" }), + ); + }); +}); diff --git a/src/extractors/rails.ts b/src/extractors/rails.ts index 63e12ea..d3d931c 100644 --- a/src/extractors/rails.ts +++ b/src/extractors/rails.ts @@ -1,4 +1,4 @@ -import type { EndpointInfo, Extractor } from "../types.ts"; +import type { EndpointInfo, EndpointKind, Extractor } from "../types.ts"; import { buildLineIndex, endpoint, @@ -16,6 +16,113 @@ const REST_ACTIONS: [string, string, string][] = [ ["DELETE", "destroy", "/:id"], ]; +// Controller inheritance signal: +// ActionController::Base = page-rendering app (Blade/ERB views, sessions, CSRF) +// ActionController::API = headless JSON API (no view layer, no CSRF) +// We resolve the controller for each route and walk its ancestor chain in +// app/controllers/ until we hit one of these two roots; that determines `kind`. +type RailsKind = Extract; + +interface ControllerResolver { + resolve(controllerRef: string): RailsKind; +} + +function createControllerResolver(ctx: { + repoPath: string; + readFile(path: string): string | null; +}): ControllerResolver { + // Cache by controller class name (e.g. "Api::UsersController") to avoid + // re-reading and re-parsing the same file when multiple routes share a + // controller. + const kindCache = new Map(); + + // Convert a controller reference from routes.rb into a controller file path. + // "users#index" -> app/controllers/users_controller.rb + // "admin/users#index" -> app/controllers/admin/users_controller.rb + // "Api::V1::UsersController" -> app/controllers/api/v1/users_controller.rb + function controllerPath(className: string): string { + // Strip trailing "Controller" if present, snake_case each segment. + const parts = className + .replace(/Controller$/i, "") + .split("::") + .map((p) => + p + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .toLowerCase(), + ) + .filter(Boolean); + return join( + ctx.repoPath, + "app", + "controllers", + ...parts.slice(0, -1), + `${parts[parts.length - 1]}_controller.rb`, + ); + } + + // Normalize a route handler reference ("users#index", "admin/users#index", + // "Api::UsersController#index") into a canonical class name like + // "Api::UsersController" so we share a single cache key per controller. + function classNameFromRef(ref: string): string { + const head = ref.split("#")[0]!; + if (head.includes("::")) { + // Already a class-style ref; ensure the Controller suffix. + return head.endsWith("Controller") ? head : `${head}Controller`; + } + // path-style ("admin/users") -> "Admin::UsersController" + const segs = head.split("/").filter(Boolean); + const camel = segs.map((s) => + s + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(""), + ); + return camel.join("::") + "Controller"; + } + + // Walk the inheritance chain. Each step reads a controller file, finds its + // `class X < Y` declaration, and recurses on Y. Stops when: + // - parent is ActionController::Base / ::API (definitive answer) + // - parent file isn't found in app/controllers (treat as api default — no + // regression vs. previous behavior where everything was "api") + // - we've already seen this class on this resolution path (cycle guard) + function classifyClass(className: string, seen: Set): RailsKind { + const cached = kindCache.get(className); + if (cached) return cached; + + if (seen.has(className)) return "api"; + seen.add(className); + + const path = controllerPath(className); + const src = ctx.readFile(path); + if (!src) return "api"; + + // class Foo::BarController < SomeBase + const m = + /class\s+([A-Za-z0-9_:]+)\s*<\s*([A-Za-z0-9_:]+(?:::[A-Za-z0-9_]+)*)/.exec( + src, + ); + if (!m) return "api"; + const parent = m[2]!; + + let kind: RailsKind; + if (parent === "ActionController::Base") kind = "page"; + else if (parent === "ActionController::API") kind = "api"; + else kind = classifyClass(parent, seen); + + kindCache.set(className, kind); + return kind; + } + + return { + resolve(controllerRef: string): RailsKind { + const className = classNameFromRef(controllerRef); + return classifyClass(className, new Set()); + }, + }; +} + export const rails: Extractor = { id: "rails", detect: { @@ -31,31 +138,82 @@ export const rails: Extractor = { ctx.filesScanned++; const rel = ctx.rel(routesFile); + const resolver = createControllerResolver(ctx); + + // Match `get '/about' => 'pages#about'` AND `get '/about', to: 'pages#about'`. const routeRe = - /(get|post|put|patch|delete)\s+['"]([^'"]+)['"](?:\s*,\s*to:\s*['"]([^'"]+)['"])?/g; + /(get|post|put|patch|delete)\s+['"]([^'"]+)['"](?:\s*(?:,\s*to:|=>)\s*['"]([^'"]+)['"])?/g; const resourcesRe = /resources?\s+:(\w+)/g; - const namespaceRe = /namespace\s+:(\w+)/g; + const namespaceLineRe = /\bnamespace\s+:(\w+)\b/; + + // Track namespace nesting by depth of `do ... end` blocks. The previous + // implementation flattened all namespaces in the file into a single prefix + // applied to every route, which is incorrect when only some routes live + // inside a `namespace :foo do ... end` block. + const lines = buildLineIndex(content); + const sourceLines = content.split("\n"); + const nsStack: { name: string; depth: number }[] = []; + let depth = 0; + + // Pre-compute the namespace prefix in effect at each line of routes.rb. + const prefixAtLine: string[] = new Array(sourceLines.length + 1).fill(""); + + for (let i = 0; i < sourceLines.length; i++) { + const line = sourceLines[i]!; - const namespaces: string[] = []; - for (const line of content.split("\n")) { - const nsM = namespaceRe.exec(line); - if (nsM) namespaces.push(nsM[1]!); - namespaceRe.lastIndex = 0; + // Strip Ruby comments so `# do` / `# end` don't shift depth. + const code = line.replace(/#.*$/, ""); + + const nsM = namespaceLineRe.exec(code); + if (nsM) { + // Namespace block opens at the current depth; will be popped when the + // matching `end` brings us back below this depth. + nsStack.push({ name: nsM[1]!, depth }); + } + + // Update depth from this line's block keywords. Heuristic: any `do` or + // trailing `do |x|` opens a block; a standalone `end` closes one. + const doMatches = code.match(/\bdo\b(?:\s*\|[^|]*\|)?\s*$/); + if (doMatches) depth++; + const endMatches = /^\s*end\b/.test(code); + if (endMatches) { + depth = Math.max(0, depth - 1); + while ( + nsStack.length > 0 && + nsStack[nsStack.length - 1]!.depth >= depth + ) { + nsStack.pop(); + } + } + + const prefix = nsStack.length + ? "/" + nsStack.map((n) => n.name).join("/") + : ""; + // Record prefix on the NEXT line (1-indexed), since routes declared on + // the line that opens a namespace are themselves outside that namespace. + prefixAtLine[i + 1] = prefix; } - const prefix = namespaces.length ? "/" + namespaces.join("/") : ""; - const lines = buildLineIndex(content); + // Helper: get the prefix in effect for an offset in the file. + const prefixAt = (offset: number): string => { + const ln = lines.lineAt(offset); + return prefixAtLine[ln] ?? ""; + }; for (const m of content.matchAll(routeRe)) { + const prefix = prefixAt(m.index); const fullPath = normalizePath(prefix + "/" + m[2]!); + const handler = m[3] || m[2]!.replace(/\//g, "_"); + const kind: RailsKind = m[3] ? resolver.resolve(m[3]) : "api"; endpoints.push( endpoint({ method: m[1]!.toUpperCase(), path: fullPath, - handler: m[3] || m[2]!.replace(/\//g, "_"), + handler, file: rel, line: lines.lineAt(m.index), framework: "rails", + kind, params: extractPathParams(fullPath), }), ); @@ -64,7 +222,15 @@ export const rails: Extractor = { for (const m of content.matchAll(resourcesRe)) { const resource = m[1]!; const line = lines.lineAt(m.index); + const prefix = prefixAt(m.index); const base = normalizePath(prefix + "/" + resource); + // Resource controller name: namespace segments + resource name pluralized + // as-is. Pass the joined "/" path-form to the resolver so + // it picks the right file (e.g. api/users -> Api::UsersController). + const controllerRef = (prefix.replace(/^\//, "") + "/" + resource) + .replace(/^\/+/, "") + .replace(/\/+/g, "/"); + const kind = resolver.resolve(`${controllerRef}#index`); for (const [method, action, suffix] of REST_ACTIONS) { const fullPath = normalizePath(base + suffix); @@ -76,6 +242,7 @@ export const rails: Extractor = { file: rel, line, framework: "rails", + kind, params: extractPathParams(fullPath), }), ); diff --git a/src/extractors/spring.test.ts b/src/extractors/spring.test.ts new file mode 100644 index 0000000..e14ce03 --- /dev/null +++ b/src/extractors/spring.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { map } from "../index.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, "__fixtures__/spring"); + +describe("spring extractor", () => { + it("emits correct kinds for api, page, and websocket routes", () => { + const result = map(FIXTURE_DIR, { frameworkOverride: "spring" }); + const endpoints = result.endpoints.all; // getter, NOT a method — no parens + + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "GET", + path: "/api/users", + kind: "api", + }), + ); + expect(endpoints).toContainEqual( + expect.objectContaining({ + method: "GET", + path: "/about", + kind: "page", + }), + ); + expect(endpoints).toContainEqual( + expect.objectContaining({ path: "/chat", kind: "websocket" }), + ); + }); +}); diff --git a/src/extractors/spring.ts b/src/extractors/spring.ts index 215d98f..4bdb38f 100644 --- a/src/extractors/spring.ts +++ b/src/extractors/spring.ts @@ -1,4 +1,4 @@ -import type { EndpointInfo, Extractor } from "../types.ts"; +import type { EndpointInfo, EndpointKind, Extractor } from "../types.ts"; import { buildLineIndex, endpoint, @@ -17,6 +17,61 @@ const METHOD_MAP: Record = { PatchMapping: "PATCH", }; +// Represents a class-level context discovered in a Java/Kotlin file. +// Used to decide whether mapped methods inside the class are page-rendering +// or REST API handlers. +type ClassCtx = { + start: number; // offset of the class keyword + // kind is "page" when the class is annotated with @Controller but NOT + // @RestController. @RestController = @Controller + @ResponseBody, so any + // class with @RestController (with or without @Controller) is treated as api. + kind: EndpointKind; +}; + +// Find class-level annotation contexts in a single source file. +// We scan the annotations preceding each `class` declaration and decide its +// kind. Inner classes are supported because we just look at the immediately +// preceding annotation block per class declaration. +function collectClassContexts(content: string): ClassCtx[] { + const out: ClassCtx[] = []; + // Match `class Foo` (Java/Kotlin). We use the start of the `class` keyword + // as the anchor; then look backwards through a window of preceding text for + // class-level annotations. + const classRe = /\bclass\s+\w+/g; + for (const m of content.matchAll(classRe)) { + const classStart = m.index!; + // Look back up to ~600 chars for the annotation block. Annotations live on + // their own lines just above the class keyword. We stop at the previous + // `}` or `;` to avoid bleeding into earlier classes. + const windowStart = Math.max(0, classStart - 600); + let preamble = content.slice(windowStart, classStart); + const lastBrace = Math.max( + preamble.lastIndexOf("}"), + preamble.lastIndexOf(";"), + ); + if (lastBrace >= 0) preamble = preamble.slice(lastBrace + 1); + const hasRest = /@RestController\b/.test(preamble); + const hasController = /@Controller\b/.test(preamble); + if (!hasRest && !hasController) continue; + // @Controller without @RestController = page-rendering controller. + // @RestController (alone or alongside @Controller) = REST api. + out.push({ start: classStart, kind: hasRest ? "api" : "page" }); + } + return out; +} + +// Returns the kind of the class that contains the given offset. Defaults to +// "api" when no class context is found (e.g., free-floating mapping in a +// config or a file without @Controller / @RestController). +function kindForOffset(classes: ClassCtx[], offset: number): EndpointKind { + let current: EndpointKind = "api"; + for (const c of classes) { + if (c.start <= offset) current = c.kind; + else break; + } + return current; +} + export const spring: Extractor = { id: "spring", detect: { depKeywords: ["spring-boot"], markers: [], scope: "root" }, @@ -30,6 +85,10 @@ export const spring: Extractor = { /@(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)\s*\(\s*(?:value\s*=\s*)?['"]?([^'")]*?)['"]?\s*\)(.*?)(?:public|private|protected)?\s*\w[\w<>,\s]*\s+(\w+)\s*\(/gs; const reqMappingMethodRe = /@RequestMapping\s*\([^)]*method\s*=\s*RequestMethod\.(\w+)[^)]*(?:value\s*=\s*)?['"]?([^'")]*?)['"]?[^)]*\)(.*?)(?:public|private|protected)?\s*\w[\w<>,\s]*\s+(\w+)\s*\(/gs; + // @MessageMapping / @SubscribeMapping = STOMP/WebSocket message handler. + // The annotation's path is the destination; method name is the handler. + const wsMappingRe = + /@(MessageMapping|SubscribeMapping)\s*\(\s*(?:value\s*=\s*)?['"]([^'"]+)['"]\s*\)(.*?)(?:public|private|protected)?\s*\w[\w<>,\s]*\s+(\w+)\s*\(/gs; for (const f of javaFiles) { const content = ctx.readFile(f); @@ -39,6 +98,7 @@ export const spring: Extractor = { const cm = classMappingRe.exec(content); const classPrefix = cm?.[1] ?? ""; const lines = buildLineIndex(content); + const classCtxs = collectClassContexts(content); for (const m of content.matchAll(methodMappingRe)) { const fullPath = normalizePath(classPrefix + "/" + m[2]!); @@ -50,6 +110,7 @@ export const spring: Extractor = { file: rel, line: lines.lineAt(m.index), framework: "spring", + kind: kindForOffset(classCtxs, m.index), params: extractPathParams(fullPath), auth: findAuthDecorators(m[3]!), }), @@ -66,6 +127,27 @@ export const spring: Extractor = { file: rel, line: lines.lineAt(m.index), framework: "spring", + kind: kindForOffset(classCtxs, m.index), + params: extractPathParams(fullPath), + auth: findAuthDecorators(m[3]!), + }), + ); + } + + // WebSocket handlers via Spring Messaging (STOMP). These can live in + // any class — including @Controller — but they're always websocket + // routes, not page or api routes. + for (const m of content.matchAll(wsMappingRe)) { + const fullPath = normalizePath(m[2]!); + endpoints.push( + endpoint({ + method: "WS", + path: fullPath, + handler: m[4]!, + file: rel, + line: lines.lineAt(m.index), + framework: "spring", + kind: "websocket", params: extractPathParams(fullPath), auth: findAuthDecorators(m[3]!), }), diff --git a/tsconfig.json b/tsconfig.json index d1ea496..2e7050b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,11 @@ "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false }, - "exclude": ["scripts/fixtures"] + "exclude": [ + "node_modules", + "dist", + "build", + "scripts/fixtures", + "src/extractors/__fixtures__" + ] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..74abbea --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + exclude: ["node_modules", "dist"], + }, +});