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"],
+ },
+});