From 31e85068b476b3528e0d6f02c3c76b873010dffd Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 15:43:56 -0400 Subject: [PATCH 01/14] test: add vitest + reference fastapi extractor test Sets up vitest as the test runner and adds the first per-extractor test using the FastAPI fixture (which already emits kind: "websocket" for @app.websocket routes). Establishes the fixture-driven pattern that subsequent per-framework tests will follow. --- bun.lock | 137 ++++++++++++++++++ package.json | 5 +- src/extractors/__fixtures__/fastapi/main.py | 16 ++ .../__fixtures__/fastapi/requirements.txt | 1 + src/extractors/fastapi.test.ts | 38 +++++ vitest.config.ts | 9 ++ 6 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 src/extractors/__fixtures__/fastapi/main.py create mode 100644 src/extractors/__fixtures__/fastapi/requirements.txt create mode 100644 src/extractors/fastapi.test.ts create mode 100644 vitest.config.ts 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/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__/fastapi/main.py b/src/extractors/__fixtures__/fastapi/main.py new file mode 100644 index 0000000..0ea839a --- /dev/null +++ b/src/extractors/__fixtures__/fastapi/main.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI, WebSocket + +app = FastAPI() + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@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/fastapi.test.ts b/src/extractors/fastapi.test.ts new file mode 100644 index 0000000..3df5500 --- /dev/null +++ b/src/extractors/fastapi.test.ts @@ -0,0 +1,38 @@ +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 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: "WS", + path: "/ws", + kind: "websocket", + }), + ); + }); +}); 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"], + }, +}); From cabfddf92b87f9e267f030ef10e8d82acbc2b121 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 15:51:38 -0400 Subject: [PATCH 02/14] feat(django): detect page (TemplateView/render) and websocket (Channels) kinds Class-based views deriving from TemplateView/ListView/DetailView and FBVs calling render() are now emitted as kind: "page". websocket_urlpatterns entries (Django Channels) emit kind: "websocket". Strips .as_view/.as_asgi from handler names so view classes can be looked up by name. --- src/extractors/__fixtures__/django/manage.py | 1 + .../__fixtures__/django/myapp/consumers.py | 12 ++ .../__fixtures__/django/myapp/routing.py | 6 + .../__fixtures__/django/myapp/urls.py | 8 + .../__fixtures__/django/myapp/views.py | 17 +++ .../__fixtures__/django/requirements.txt | 2 + src/extractors/django.test.ts | 55 +++++++ src/extractors/django.ts | 138 +++++++++++++++++- 8 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 src/extractors/__fixtures__/django/manage.py create mode 100644 src/extractors/__fixtures__/django/myapp/consumers.py create mode 100644 src/extractors/__fixtures__/django/myapp/routing.py create mode 100644 src/extractors/__fixtures__/django/myapp/urls.py create mode 100644 src/extractors/__fixtures__/django/myapp/views.py create mode 100644 src/extractors/__fixtures__/django/requirements.txt create mode 100644 src/extractors/django.test.ts 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/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; }, }; From 02a22fc136e08ab7bd4b4c0029c94154aa29d06e Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 15:51:43 -0400 Subject: [PATCH 03/14] feat(express): detect page (res.render/sendFile) and websocket kinds Replaced the lazy single-line route regex with a balanced-paren walker so multi-line arrow handler bodies can be inspected. Page detection looks for res.render(...) or res.sendFile(...) in the handler body. Websocket detection covers app.ws() (express-ws), io.on('connection')/io.of() (socket.io), and new WebSocketServer (ws library). --- src/extractors/__fixtures__/express/index.js | 34 +++ .../__fixtures__/express/package.json | 12 + src/extractors/express.test.ts | 55 ++++ src/extractors/express.ts | 245 +++++++++++++++++- 4 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 src/extractors/__fixtures__/express/index.js create mode 100644 src/extractors/__fixtures__/express/package.json create mode 100644 src/extractors/express.test.ts 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/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; }, }; From b297aded5b218ff38512c86a0ee71dabac28f115 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 15:51:48 -0400 Subject: [PATCH 04/14] feat(fastapi): detect page kind for HTMLResponse / TemplateResponse A route is page-shaped when its decorator args set response_class=HTMLResponse or its function body returns an HTMLResponse(...) or .TemplateResponse(...). Body window is bounded to the next top-level decorator/def to avoid bleeding between adjacent routes. --- src/extractors/__fixtures__/fastapi/main.py | 15 +++++++++- src/extractors/fastapi.test.ts | 18 ++++++++++- src/extractors/fastapi.ts | 33 +++++++++++++++++---- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/extractors/__fixtures__/fastapi/main.py b/src/extractors/__fixtures__/fastapi/main.py index 0ea839a..b46a7d0 100644 --- a/src/extractors/__fixtures__/fastapi/main.py +++ b/src/extractors/__fixtures__/fastapi/main.py @@ -1,6 +1,9 @@ -from fastapi import FastAPI, WebSocket +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") @@ -8,6 +11,16 @@ 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() diff --git a/src/extractors/fastapi.test.ts b/src/extractors/fastapi.test.ts index 3df5500..df26d4e 100644 --- a/src/extractors/fastapi.test.ts +++ b/src/extractors/fastapi.test.ts @@ -15,7 +15,7 @@ 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 and websocket routes", async () => { + it("emits correct kinds for api, page, and websocket routes", async () => { const result = map(FIXTURE_DIR, { frameworkOverride: "fastapi" }); const endpoints = result.endpoints.all; @@ -27,6 +27,22 @@ describe("fastapi extractor", () => { }), ); + 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", 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, From 2433d061c1aa3d00ab52fe4737190672023a3c30 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 15:51:53 -0400 Subject: [PATCH 05/14] feat(flask): detect page kind for handlers using render_template A handler whose body contains render_template() or render_template_string() emits kind: "page". Body window is bounded to the next top-level def/class/ decorator at column 0 so adjacent handlers do not leak into each other's classification. --- src/extractors/__fixtures__/flask/app.py | 13 +++++++++ .../__fixtures__/flask/requirements.txt | 1 + src/extractors/flask.test.ts | 29 +++++++++++++++++++ src/extractors/flask.ts | 16 ++++++++++ 4 files changed, 59 insertions(+) create mode 100644 src/extractors/__fixtures__/flask/app.py create mode 100644 src/extractors/__fixtures__/flask/requirements.txt create mode 100644 src/extractors/flask.test.ts 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/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, From cc54144e0fcbf523c1bdc553ad6615101211d8ff Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 15:51:59 -0400 Subject: [PATCH 06/14] feat(go): detect websocket kind for gorilla/websocket upgrades A route handler emits kind: "websocket" when its file imports github.com/gorilla/websocket and the handler body contains an .Upgrade( call. Applies uniformly across gin, echo, fiber, and net/http via a shared findWebsocketHandlers helper. Also tightened the handler-name regex so trailing handler args resolve correctly. --- src/extractors/__fixtures__/go-gin/go.mod | 8 ++ src/extractors/__fixtures__/go-gin/main.go | 42 +++++++++++ src/extractors/go.test.ts | 21 ++++++ src/extractors/go.ts | 86 ++++++++++++++++++++-- 4 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 src/extractors/__fixtures__/go-gin/go.mod create mode 100644 src/extractors/__fixtures__/go-gin/main.go create mode 100644 src/extractors/go.test.ts 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/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", From fdb73692b57dc8072b11d736c2a406aedcf1379a Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 15:52:04 -0400 Subject: [PATCH 07/14] feat(laravel): detect page kind for routes/web.php Routes defined in routes/web.php emit kind: "page" (session/CSRF/Blade view convention); routes from routes/api.php remain kind: "api". The file-of-origin distinction is reused from the existing /api prefix logic. --- .../__fixtures__/laravel/composer.json | 8 +++++++ .../__fixtures__/laravel/routes/api.php | 6 ++++++ .../__fixtures__/laravel/routes/web.php | 7 +++++++ src/extractors/laravel.test.ts | 21 +++++++++++++++++++ src/extractors/laravel.ts | 5 +++++ 5 files changed, 47 insertions(+) create mode 100644 src/extractors/__fixtures__/laravel/composer.json create mode 100644 src/extractors/__fixtures__/laravel/routes/api.php create mode 100644 src/extractors/__fixtures__/laravel/routes/web.php create mode 100644 src/extractors/laravel.test.ts 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/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], }), From d965b4401b8ca19d8a999e1da6f4fe96812089ae Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 15:52:09 -0400 Subject: [PATCH 08/14] feat(nestjs): detect page (@Render) and websocket (@WebSocketGateway) kinds Methods carrying @Render('view') emit kind: "page". Classes annotated with @WebSocketGateway() emit each @SubscribeMessage('event') method as a websocket endpoint with method WS. Methods are now associated with their owning class via a per-class scan so controller vs gateway is disambiguated. --- .../__fixtures__/nestjs/events.gateway.ts | 10 + .../__fixtures__/nestjs/package.json | 11 + .../__fixtures__/nestjs/pages.controller.ts | 11 + .../__fixtures__/nestjs/users.controller.ts | 10 + src/extractors/nestjs.test.ts | 42 ++++ src/extractors/nestjs.ts | 212 ++++++++++++++++-- 6 files changed, 282 insertions(+), 14 deletions(-) create mode 100644 src/extractors/__fixtures__/nestjs/events.gateway.ts create mode 100644 src/extractors/__fixtures__/nestjs/package.json create mode 100644 src/extractors/__fixtures__/nestjs/pages.controller.ts create mode 100644 src/extractors/__fixtures__/nestjs/users.controller.ts create mode 100644 src/extractors/nestjs.test.ts diff --git a/src/extractors/__fixtures__/nestjs/events.gateway.ts b/src/extractors/__fixtures__/nestjs/events.gateway.ts new file mode 100644 index 0000000..ccdccf1 --- /dev/null +++ b/src/extractors/__fixtures__/nestjs/events.gateway.ts @@ -0,0 +1,10 @@ +// @ts-nocheck — fixture: extractor scans source text, no need to typecheck. +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..4c7ab14 --- /dev/null +++ b/src/extractors/__fixtures__/nestjs/pages.controller.ts @@ -0,0 +1,11 @@ +// @ts-nocheck — fixture: extractor scans source text, no need to typecheck. +import { Controller, Get, Render } from "@nestjs/common"; + +@Controller() +export class PagesController { + @Get("about") + @Render("about") + renderAbout() { + return { title: "About us" }; + } +} diff --git a/src/extractors/__fixtures__/nestjs/users.controller.ts b/src/extractors/__fixtures__/nestjs/users.controller.ts new file mode 100644 index 0000000..719ed5a --- /dev/null +++ b/src/extractors/__fixtures__/nestjs/users.controller.ts @@ -0,0 +1,10 @@ +// @ts-nocheck — fixture: extractor scans source text, no need to typecheck. +import { Controller, Get } from "@nestjs/common"; + +@Controller("users") +export class UsersController { + @Get() + findAll() { + return [{ id: 1, name: "Ada" }]; + } +} diff --git a/src/extractors/nestjs.test.ts b/src/extractors/nestjs.test.ts new file mode 100644 index 0000000..759dec9 --- /dev/null +++ b/src/extractors/nestjs.test.ts @@ -0,0 +1,42 @@ +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", + }), + ); + }); +}); diff --git a/src/extractors/nestjs.ts b/src/extractors/nestjs.ts index 7e195cc..f8d6856 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,45 @@ 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; +} + export const nestjs: Extractor = { id: "nestjs", detect: { depKeywords: ["@nestjs/core"], markers: [], scope: "root" }, @@ -14,37 +53,124 @@ 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 +186,23 @@ 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 (the lazy regex may have stopped + // short of it, so search a forward window from the match start). + const decoratorsBefore = cls + ? content.slice(cls.start, offset) + : content.slice(0, offset); + const recentDecorators = decoratorsBefore.slice( + Math.max(0, decoratorsBefore.length - 400), + ); + const forwardWindow = content.slice(offset, offset + 400); + const hasRender = + /@Render\s*\(/.test(forwardWindow) || + /@Render\s*\(/.test(recentDecorators); + const kind: EndpointKind = hasRender ? "page" : "api"; + endpoints.push( endpoint({ method: httpMethod, @@ -68,11 +211,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; From 6f09f86ccf0272f9893fa2149e1b669f0b5078c0 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 15:52:15 -0400 Subject: [PATCH 09/14] feat(rails): detect page vs api by controller superclass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes are classified by walking the controller's inheritance chain on disk: ActionController::Base ancestor → kind: "page"; ActionController::API ancestor → kind: "api". Resolution memoizes per file with a cycle guard. Also fixes a pre-existing bug where namespace blocks leaked across the whole routes.rb file — namespaces are now tracked block-by-block. --- src/extractors/__fixtures__/rails/Gemfile | 3 + .../app/controllers/api/base_controller.rb | 2 + .../app/controllers/api/users_controller.rb | 5 + .../app/controllers/application_controller.rb | 2 + .../rails/app/controllers/pages_controller.rb | 4 + .../__fixtures__/rails/config/routes.rb | 7 + src/extractors/rails.test.ts | 21 ++ src/extractors/rails.ts | 186 ++++++++++++++++-- 8 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 src/extractors/__fixtures__/rails/Gemfile create mode 100644 src/extractors/__fixtures__/rails/app/controllers/api/base_controller.rb create mode 100644 src/extractors/__fixtures__/rails/app/controllers/api/users_controller.rb create mode 100644 src/extractors/__fixtures__/rails/app/controllers/application_controller.rb create mode 100644 src/extractors/__fixtures__/rails/app/controllers/pages_controller.rb create mode 100644 src/extractors/__fixtures__/rails/config/routes.rb create mode 100644 src/extractors/rails.test.ts 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/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..30bf265 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,115 @@ 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 +140,77 @@ 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 +219,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 +239,7 @@ export const rails: Extractor = { file: rel, line, framework: "rails", + kind, params: extractPathParams(fullPath), }), ); From 572b09a3a775c2dd1771bc25872560bfc19e8787 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 15:52:20 -0400 Subject: [PATCH 10/14] feat(spring): detect page (@Controller) and websocket (@MessageMapping) kinds Methods on a class annotated with @Controller (without @RestController) emit kind: "page"; @RestController stays kind: "api". @MessageMapping and @SubscribeMapping methods emit a separate websocket endpoint regardless of class kind. Class context is resolved via a back-scan from each mapping match, so inner classes and multi-class files are handled. --- src/extractors/__fixtures__/spring/pom.xml | 25 ++++++ .../java/com/example/demo/ChatController.java | 15 ++++ .../java/com/example/demo/PageController.java | 13 +++ .../com/example/demo/UserApiController.java | 13 +++ src/extractors/spring.test.ts | 32 +++++++ src/extractors/spring.ts | 84 ++++++++++++++++++- 6 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 src/extractors/__fixtures__/spring/pom.xml create mode 100644 src/extractors/__fixtures__/spring/src/main/java/com/example/demo/ChatController.java create mode 100644 src/extractors/__fixtures__/spring/src/main/java/com/example/demo/PageController.java create mode 100644 src/extractors/__fixtures__/spring/src/main/java/com/example/demo/UserApiController.java create mode 100644 src/extractors/spring.test.ts 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/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]!), }), From 090fcf2e8897427a0e6f17058454603b82f9e83e Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 15:53:35 -0400 Subject: [PATCH 11/14] chore: exclude __fixtures__ from tsc and eslint Fixture directories under src/extractors/__fixtures__/ contain synthetic test inputs that mimic external frameworks (e.g. @nestjs/common imports without the package being installed). They're scanned as raw source text by the extractors, never compiled or imported. Excluding them from both the TypeScript program and the ESLint config keeps tooling clean without needing per-file @ts-nocheck escape hatches. --- eslint.config.js | 7 ++++++- src/extractors/__fixtures__/nestjs/events.gateway.ts | 1 - src/extractors/__fixtures__/nestjs/pages.controller.ts | 1 - src/extractors/__fixtures__/nestjs/users.controller.ts | 1 - tsconfig.json | 8 +++++++- 5 files changed, 13 insertions(+), 5 deletions(-) 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/src/extractors/__fixtures__/nestjs/events.gateway.ts b/src/extractors/__fixtures__/nestjs/events.gateway.ts index ccdccf1..fb499de 100644 --- a/src/extractors/__fixtures__/nestjs/events.gateway.ts +++ b/src/extractors/__fixtures__/nestjs/events.gateway.ts @@ -1,4 +1,3 @@ -// @ts-nocheck — fixture: extractor scans source text, no need to typecheck. import { SubscribeMessage, WebSocketGateway } from "@nestjs/websockets"; @WebSocketGateway() diff --git a/src/extractors/__fixtures__/nestjs/pages.controller.ts b/src/extractors/__fixtures__/nestjs/pages.controller.ts index 4c7ab14..b1103f7 100644 --- a/src/extractors/__fixtures__/nestjs/pages.controller.ts +++ b/src/extractors/__fixtures__/nestjs/pages.controller.ts @@ -1,4 +1,3 @@ -// @ts-nocheck — fixture: extractor scans source text, no need to typecheck. import { Controller, Get, Render } from "@nestjs/common"; @Controller() diff --git a/src/extractors/__fixtures__/nestjs/users.controller.ts b/src/extractors/__fixtures__/nestjs/users.controller.ts index 719ed5a..1f05dc6 100644 --- a/src/extractors/__fixtures__/nestjs/users.controller.ts +++ b/src/extractors/__fixtures__/nestjs/users.controller.ts @@ -1,4 +1,3 @@ -// @ts-nocheck — fixture: extractor scans source text, no need to typecheck. import { Controller, Get } from "@nestjs/common"; @Controller("users") 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__" + ] } From 79842140d4e5657c73bd0191fbb765e14027e0ff Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 16:03:35 -0400 Subject: [PATCH 12/14] test(nextjs): port nextjs.test.ts from bun:test to vitest The rest of the test suite uses vitest (introduced for the per-extractor fixture tests). Unify on one runner so 'bun run test' covers all suites. Replaces import.meta.dir (bun-only) with the standard ESM fileURLToPath(import.meta.url) pattern. --- src/extractors/nextjs.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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); From 657ef8b21cf17672731d82e07b1daa9217192bdb Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 16:21:56 -0400 Subject: [PATCH 13/14] chore: apply prettier formatting to nestjs and rails extractors --- src/extractors/nestjs.ts | 3 +-- src/extractors/rails.ts | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/extractors/nestjs.ts b/src/extractors/nestjs.ts index f8d6856..ac3d0c5 100644 --- a/src/extractors/nestjs.ts +++ b/src/extractors/nestjs.ts @@ -53,8 +53,7 @@ export const nestjs: Extractor = { const tsFiles = ctx.iterFiles([".ts"]); const controllerRe = /@Controller\s*\(\s*['"]([^'"]*)['"]\s*\)/; - const wsGatewayRe = - /@WebSocketGateway\s*\(([^)]*)\)|@WebSocketGateway\b/; + 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 diff --git a/src/extractors/rails.ts b/src/extractors/rails.ts index 30bf265..d3d931c 100644 --- a/src/extractors/rails.ts +++ b/src/extractors/rails.ts @@ -27,12 +27,10 @@ interface ControllerResolver { resolve(controllerRef: string): RailsKind; } -function createControllerResolver( - ctx: { - repoPath: string; - readFile(path: string): string | null; - }, -): ControllerResolver { +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. @@ -180,12 +178,17 @@ export const rails: Extractor = { 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) { + while ( + nsStack.length > 0 && + nsStack[nsStack.length - 1]!.depth >= depth + ) { nsStack.pop(); } } - const prefix = nsStack.length ? "/" + nsStack.map((n) => n.name).join("/") : ""; + 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; From 712b7241509daf42f352188fdcfda39eb659cbd4 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 16:43:52 -0400 Subject: [PATCH 14/14] fix(nestjs): scope @Render detection to the current method's decorator stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous heuristic checked a 400-char backward window and a 400-char forward window for @Render. In a controller with multiple methods, that easily included @Render decorators belonging to a sibling method, so a plain @Get sitting near a @Render-decorated handler was misclassified as a page. Replace both windows with a precise line-by-line walk that captures only the contiguous decorator stack of the current method — stopping at any line that ends a previous statement (}, ;) or otherwise looks like code. Add a regression test where a @Get("/data") sibling sits directly below a @Render-decorated method and asserts its kind is "api". --- .../__fixtures__/nestjs/pages.controller.ts | 7 ++ src/extractors/nestjs.test.ts | 11 +++ src/extractors/nestjs.ts | 82 +++++++++++++++---- 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/extractors/__fixtures__/nestjs/pages.controller.ts b/src/extractors/__fixtures__/nestjs/pages.controller.ts index b1103f7..76429a3 100644 --- a/src/extractors/__fixtures__/nestjs/pages.controller.ts +++ b/src/extractors/__fixtures__/nestjs/pages.controller.ts @@ -7,4 +7,11 @@ export class PagesController { 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/nestjs.test.ts b/src/extractors/nestjs.test.ts index 759dec9..ccb1061 100644 --- a/src/extractors/nestjs.test.ts +++ b/src/extractors/nestjs.test.ts @@ -39,4 +39,15 @@ describe("nestjs extractor", () => { }), ); }); + + 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 ac3d0c5..622a858 100644 --- a/src/extractors/nestjs.ts +++ b/src/extractors/nestjs.ts @@ -45,6 +45,67 @@ function findClasses(content: string): ClassRange[] { 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" }, @@ -186,20 +247,13 @@ export const nestjs: Extractor = { } // @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 (the lazy regex may have stopped - // short of it, so search a forward window from the match start). - const decoratorsBefore = cls - ? content.slice(cls.start, offset) - : content.slice(0, offset); - const recentDecorators = decoratorsBefore.slice( - Math.max(0, decoratorsBefore.length - 400), - ); - const forwardWindow = content.slice(offset, offset + 400); - const hasRender = - /@Render\s*\(/.test(forwardWindow) || - /@Render\s*\(/.test(recentDecorators); + // 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(