frameworks.test.ts 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180
  1. import { describe, it, expect } from 'vitest';
  2. import type { FrameworkResolver, UnresolvedRef } from '../src/resolution/types';
  3. import type { Node } from '../src/types';
  4. describe('FrameworkResolver.extract interface', () => {
  5. it('extract() returns { nodes, references }', () => {
  6. const resolver: FrameworkResolver = {
  7. name: 'fake',
  8. detect: () => true,
  9. resolve: () => null,
  10. languages: ['python'],
  11. extract: (_filePath: string, _content: string) => ({
  12. nodes: [] as Node[],
  13. references: [] as UnresolvedRef[],
  14. }),
  15. };
  16. const result = resolver.extract!('foo.py', '');
  17. expect(result).toEqual({ nodes: [], references: [] });
  18. });
  19. });
  20. import { getApplicableFrameworks } from '../src/resolution/frameworks';
  21. import type { FrameworkResolver } from '../src/resolution/types';
  22. describe('getApplicableFrameworks', () => {
  23. const pyFw: FrameworkResolver = { name: 'py', languages: ['python'], detect: () => true, resolve: () => null };
  24. const jsFw: FrameworkResolver = { name: 'js', languages: ['javascript', 'typescript'], detect: () => true, resolve: () => null };
  25. const anyFw: FrameworkResolver = { name: 'any', detect: () => true, resolve: () => null };
  26. it('filters by language', () => {
  27. const result = getApplicableFrameworks([pyFw, jsFw, anyFw], 'python');
  28. expect(result.map(r => r.name)).toEqual(['py', 'any']);
  29. });
  30. it('returns anyFw-only when language has no matches', () => {
  31. const result = getApplicableFrameworks([pyFw, jsFw, anyFw], 'rust');
  32. expect(result.map(r => r.name)).toEqual(['any']);
  33. });
  34. });
  35. import { djangoResolver } from '../src/resolution/frameworks/python';
  36. describe('djangoResolver.extract', () => {
  37. it('extracts route node and reference for path() with CBV.as_view()', () => {
  38. const src = `
  39. from django.urls import path
  40. from users.views import UserListView
  41. urlpatterns = [
  42. path('users/', UserListView.as_view(), name='user-list'),
  43. ]
  44. `;
  45. const { nodes, references } = djangoResolver.extract!('users/urls.py', src);
  46. expect(nodes).toHaveLength(1);
  47. expect(nodes[0].kind).toBe('route');
  48. expect(nodes[0].name).toBe('users/');
  49. expect(references).toHaveLength(1);
  50. expect(references[0].referenceName).toBe('UserListView');
  51. expect(references[0].referenceKind).toBe('references');
  52. expect(references[0].fromNodeId).toBe(nodes[0].id);
  53. });
  54. it('extracts route for path() with dotted module.Class.as_view()', () => {
  55. const src = `from django.urls import path\nfrom api.v1 import views as api_v1_views\nurlpatterns = [path('api/', api_v1_views.UserListView.as_view())]\n`;
  56. const { nodes, references } = djangoResolver.extract!('api/urls.py', src);
  57. expect(nodes).toHaveLength(1);
  58. expect(references[0].referenceName).toBe('UserListView');
  59. });
  60. it('extracts route for path() with bare function view', () => {
  61. const src = `from django.urls import path\nurlpatterns = [path('home/', home_view, name='home')]\n`;
  62. const { nodes, references } = djangoResolver.extract!('home/urls.py', src);
  63. expect(references[0].referenceName).toBe('home_view');
  64. });
  65. it('extracts route for path() with include()', () => {
  66. const src = `from django.urls import path, include\nurlpatterns = [path('api/', include('api.urls'))]\n`;
  67. const { nodes, references } = djangoResolver.extract!('root/urls.py', src);
  68. expect(nodes).toHaveLength(1);
  69. expect(nodes[0].kind).toBe('route');
  70. expect(references[0].referenceName).toBe('api.urls');
  71. expect(references[0].referenceKind).toBe('imports');
  72. });
  73. it('extracts routes for re_path and url', () => {
  74. const src = `from django.urls import re_path, url\nurlpatterns = [re_path(r'^users/$', UserView), url(r'^old/$', OldView)]\n`;
  75. const { nodes } = djangoResolver.extract!('legacy/urls.py', src);
  76. expect(nodes).toHaveLength(2);
  77. expect(nodes.map(n => n.name)).toEqual(['^users/$', '^old/$']);
  78. });
  79. it('returns empty result for a non-urls.py python file', () => {
  80. const src = `def foo(): return 1\n`;
  81. const { nodes, references } = djangoResolver.extract!('views.py', src);
  82. expect(nodes).toEqual([]);
  83. expect(references).toEqual([]);
  84. });
  85. });
  86. import { flaskResolver, fastapiResolver } from '../src/resolution/frameworks/python';
  87. describe('flaskResolver.extract', () => {
  88. it('extracts route and reference from @app.route', () => {
  89. const src = `
  90. @app.route('/users')
  91. def list_users():
  92. return []
  93. `;
  94. const { nodes, references } = flaskResolver.extract!('app.py', src);
  95. expect(nodes).toHaveLength(1);
  96. expect(nodes[0].kind).toBe('route');
  97. expect(nodes[0].name).toBe('GET /users');
  98. expect(references[0].referenceName).toBe('list_users');
  99. });
  100. it('extracts blueprint routes', () => {
  101. const src = `
  102. @users_bp.route('/<id>', methods=['POST'])
  103. def create_user(id):
  104. pass
  105. `;
  106. const { nodes, references } = flaskResolver.extract!('routes.py', src);
  107. expect(nodes[0].name).toBe('POST /<id>');
  108. expect(references[0].referenceName).toBe('create_user');
  109. });
  110. it('resolves the handler across an intervening decorator (@login_required)', () => {
  111. const src = `
  112. @bp.route('/profile')
  113. @login_required
  114. def profile():
  115. return render_template('profile.html')
  116. `;
  117. const { nodes, references } = flaskResolver.extract!('routes.py', src);
  118. expect(nodes[0].name).toBe('GET /profile');
  119. expect(references[0].referenceName).toBe('profile');
  120. });
  121. it('extracts stacked @x.route decorators bound to one view', () => {
  122. const src = `
  123. @bp.route('/', methods=['GET', 'POST'])
  124. @bp.route('/index', methods=['GET', 'POST'])
  125. @login_required
  126. def index():
  127. return render_template('index.html')
  128. `;
  129. const { nodes, references } = flaskResolver.extract!('routes.py', src);
  130. expect(nodes.map((n) => n.name)).toEqual(['GET /', 'GET /index']);
  131. expect(references.map((r) => r.referenceName)).toEqual(['index', 'index']);
  132. });
  133. });
  134. describe('fastapiResolver.extract', () => {
  135. it('extracts route and reference from @app.get', () => {
  136. const src = `
  137. @app.get('/users')
  138. async def list_users():
  139. return []
  140. `;
  141. const { nodes, references } = fastapiResolver.extract!('main.py', src);
  142. expect(nodes[0].name).toBe('GET /users');
  143. expect(references[0].referenceName).toBe('list_users');
  144. });
  145. it('extracts route from router.post', () => {
  146. const src = `
  147. @router.post('/items')
  148. def create_item(item: Item):
  149. pass
  150. `;
  151. const { nodes, references } = fastapiResolver.extract!('items.py', src);
  152. expect(nodes[0].name).toBe('POST /items');
  153. expect(references[0].referenceName).toBe('create_item');
  154. });
  155. it('extracts a route mounted at the router/prefix root (empty path)', () => {
  156. const src = `
  157. @router.get("", response_model=ListOfArticles, name="articles:list")
  158. async def list_articles():
  159. return []
  160. `;
  161. const { nodes, references } = fastapiResolver.extract!('articles.py', src);
  162. expect(nodes[0].name).toBe('GET /');
  163. expect(references[0].referenceName).toBe('list_articles');
  164. });
  165. it('extracts a multi-line decorator with an empty path', () => {
  166. const src = `
  167. @router.post(
  168. "",
  169. status_code=201,
  170. response_model=ArticleInResponse,
  171. )
  172. async def create_article():
  173. pass
  174. `;
  175. const { nodes, references } = fastapiResolver.extract!('articles.py', src);
  176. expect(nodes[0].name).toBe('POST /');
  177. expect(references[0].referenceName).toBe('create_article');
  178. });
  179. });
  180. import { expressResolver } from '../src/resolution/frameworks/express';
  181. describe('expressResolver.extract', () => {
  182. it('extracts route with inline handler reference', () => {
  183. const src = `app.get('/users', listUsers);\n`;
  184. const { nodes, references } = expressResolver.extract!('routes.ts', src);
  185. expect(nodes).toHaveLength(1);
  186. expect(nodes[0].name).toBe('GET /users');
  187. expect(references[0].referenceName).toBe('listUsers');
  188. });
  189. it('extracts route with router.post and middleware chain', () => {
  190. const src = `router.post('/items', auth, createItem);\n`;
  191. const { nodes, references } = expressResolver.extract!('items.ts', src);
  192. expect(nodes[0].name).toBe('POST /items');
  193. // Multiple handlers: prefer the LAST one (convention: middleware first, handler last)
  194. expect(references[0].referenceName).toBe('createItem');
  195. });
  196. it('extracts route with controller method reference', () => {
  197. const src = `app.get('/x', userController.list);\n`;
  198. const { nodes, references } = expressResolver.extract!('routes.ts', src);
  199. expect(references[0].referenceName).toBe('list');
  200. });
  201. });
  202. import { nestjsResolver } from '../src/resolution/frameworks/nestjs';
  203. describe('nestjsResolver.extract — HTTP', () => {
  204. it('joins @Controller prefix with @Get and links the handler', () => {
  205. const src = `
  206. @Controller('users')
  207. export class UsersController {
  208. @Get()
  209. findAll() { return []; }
  210. }
  211. `;
  212. const { nodes, references } = nestjsResolver.extract!('users.controller.ts', src);
  213. expect(nodes).toHaveLength(1);
  214. expect(nodes[0].kind).toBe('route');
  215. expect(nodes[0].name).toBe('GET /users');
  216. expect(references[0].referenceName).toBe('findAll');
  217. expect(references[0].referenceKind).toBe('references');
  218. expect(references[0].fromNodeId).toBe(nodes[0].id);
  219. });
  220. it('joins controller prefix with a method-level path param', () => {
  221. const src = `
  222. @Controller('cats')
  223. export class CatsController {
  224. @Get(':id')
  225. findOne(@Param('id') id: string) { return id; }
  226. }
  227. `;
  228. const { nodes, references } = nestjsResolver.extract!('cats.controller.ts', src);
  229. expect(nodes[0].name).toBe('GET /cats/:id');
  230. expect(references[0].referenceName).toBe('findOne');
  231. });
  232. it('handles an empty @Controller() and empty @Post()', () => {
  233. const src = `
  234. @Controller()
  235. export class AppController {
  236. @Post()
  237. create() {}
  238. }
  239. `;
  240. const { nodes, references } = nestjsResolver.extract!('app.controller.ts', src);
  241. expect(nodes[0].name).toBe('POST /');
  242. expect(references[0].referenceName).toBe('create');
  243. });
  244. it('covers HTTP verbs and skips intervening method decorators', () => {
  245. const src = `
  246. @Controller('todos')
  247. export class TodosController {
  248. @Put(':id')
  249. @UseGuards(AuthGuard)
  250. update(@Param('id') id: string) {}
  251. @Delete(':id')
  252. async remove(@Param('id') id: string) {}
  253. }
  254. `;
  255. const { nodes, references } = nestjsResolver.extract!('todos.controller.ts', src);
  256. expect(nodes.map((n) => n.name)).toEqual(['PUT /todos/:id', 'DELETE /todos/:id']);
  257. expect(references.map((r) => r.referenceName)).toEqual(['update', 'remove']);
  258. });
  259. it('attributes methods to the right controller when a file has two', () => {
  260. const src = `
  261. @Controller('a')
  262. export class AController {
  263. @Get('x')
  264. ax() {}
  265. }
  266. @Controller('b')
  267. export class BController {
  268. @Get('y')
  269. by() {}
  270. }
  271. `;
  272. const { nodes } = nestjsResolver.extract!('multi.controller.ts', src);
  273. expect(nodes.map((n) => n.name)).toEqual(['GET /a/x', 'GET /b/y']);
  274. });
  275. });
  276. describe('nestjsResolver.extract — GraphQL', () => {
  277. it('emits QUERY/MUTATION nodes from a resolver, defaulting to the method name', () => {
  278. const src = `
  279. @Resolver(() => User)
  280. export class UsersResolver {
  281. @Query(() => [User])
  282. users() { return []; }
  283. @Mutation(() => User)
  284. createUser(@Args('input') input: CreateUserInput) {}
  285. }
  286. `;
  287. const { nodes, references } = nestjsResolver.extract!('users.resolver.ts', src);
  288. expect(nodes.map((n) => n.name)).toEqual(['QUERY users', 'MUTATION createUser']);
  289. expect(references.map((r) => r.referenceName)).toEqual(['users', 'createUser']);
  290. });
  291. it('uses an explicit operation name when given', () => {
  292. const src = `
  293. @Resolver()
  294. export class CatsResolver {
  295. @Query(() => Cat, { name: 'cat' })
  296. getCat() {}
  297. }
  298. `;
  299. const { nodes } = nestjsResolver.extract!('cats.resolver.ts', src);
  300. expect(nodes[0].name).toBe('QUERY cat');
  301. });
  302. it('does NOT treat the REST @Query() parameter decorator as a GraphQL op', () => {
  303. const src = `
  304. @Controller('search')
  305. export class SearchController {
  306. @Get()
  307. search(@Query() query: SearchDto) { return query; }
  308. }
  309. `;
  310. const { nodes } = nestjsResolver.extract!('search.controller.ts', src);
  311. // Only the HTTP route — the @Query() param decorator must be ignored.
  312. expect(nodes.map((n) => n.name)).toEqual(['GET /search']);
  313. });
  314. });
  315. describe('nestjsResolver.extract — microservices & websockets', () => {
  316. it('extracts @MessagePattern and @EventPattern handlers', () => {
  317. const src = `
  318. @Controller()
  319. export class MathController {
  320. @MessagePattern({ cmd: 'sum' })
  321. accumulate(data: number[]) {}
  322. @EventPattern('user.created')
  323. handleUserCreated(data: any) {}
  324. }
  325. `;
  326. const { nodes, references } = nestjsResolver.extract!('math.controller.ts', src);
  327. expect(nodes.map((n) => n.name)).toEqual(['MESSAGE sum', 'EVENT user.created']);
  328. expect(references.map((r) => r.referenceName)).toEqual(['accumulate', 'handleUserCreated']);
  329. });
  330. it('extracts @SubscribeMessage handlers with the gateway namespace', () => {
  331. const src = `
  332. @WebSocketGateway({ namespace: 'chat' })
  333. export class ChatGateway {
  334. @SubscribeMessage('message')
  335. handleMessage(@MessageBody() data: string) {}
  336. }
  337. `;
  338. const { nodes, references } = nestjsResolver.extract!('chat.gateway.ts', src);
  339. expect(nodes[0].name).toBe('WS chat:message');
  340. expect(references[0].referenceName).toBe('handleMessage');
  341. });
  342. it('extracts @SubscribeMessage without a namespace', () => {
  343. const src = `
  344. @WebSocketGateway()
  345. export class EventsGateway {
  346. @SubscribeMessage('events')
  347. onEvent() {}
  348. }
  349. `;
  350. const { nodes } = nestjsResolver.extract!('events.gateway.ts', src);
  351. expect(nodes[0].name).toBe('WS events');
  352. });
  353. it('returns empty for a non-JS/TS file', () => {
  354. const { nodes, references } = nestjsResolver.extract!('thing.py', '@Controller("x")');
  355. expect(nodes).toEqual([]);
  356. expect(references).toEqual([]);
  357. });
  358. });
  359. describe('nestjsResolver.detect', () => {
  360. const baseContext = {
  361. getNodesInFile: () => [],
  362. getNodesByName: () => [],
  363. getNodesByQualifiedName: () => [],
  364. getNodesByKind: () => [],
  365. fileExists: () => false,
  366. getProjectRoot: () => '/test',
  367. getAllFiles: () => [],
  368. getNodesByLowerName: () => [],
  369. getImportMappings: () => [],
  370. };
  371. it('detects @nestjs/* in package.json', () => {
  372. const context = {
  373. ...baseContext,
  374. readFile: (p: string) =>
  375. p === 'package.json'
  376. ? JSON.stringify({ dependencies: { '@nestjs/common': '^10.0.0' } })
  377. : null,
  378. };
  379. expect(nestjsResolver.detect(context as any)).toBe(true);
  380. });
  381. it('detects @Controller in a *.controller.ts file when package.json is absent', () => {
  382. const context = {
  383. ...baseContext,
  384. getAllFiles: () => ['src/users.controller.ts'],
  385. readFile: (p: string) =>
  386. p === 'src/users.controller.ts'
  387. ? `@Controller('users')\nexport class UsersController {}`
  388. : null,
  389. };
  390. expect(nestjsResolver.detect(context as any)).toBe(true);
  391. });
  392. it('returns false for a non-Nest project', () => {
  393. const context = {
  394. ...baseContext,
  395. readFile: (p: string) =>
  396. p === 'package.json' ? JSON.stringify({ dependencies: { express: '^4' } }) : null,
  397. };
  398. expect(nestjsResolver.detect(context as any)).toBe(false);
  399. });
  400. });
  401. describe('nestjsResolver.resolve', () => {
  402. const baseContext = {
  403. getNodesInFile: () => [],
  404. getNodesByName: () => [],
  405. getNodesByQualifiedName: () => [],
  406. getNodesByKind: () => [],
  407. fileExists: () => false,
  408. readFile: () => null,
  409. getProjectRoot: () => '/test',
  410. getAllFiles: () => [],
  411. getNodesByLowerName: () => [],
  412. getImportMappings: () => [],
  413. };
  414. it('resolves an injected *Service reference to the class in a *.service.ts file', () => {
  415. const svcNode: Node = {
  416. id: 'class:src/users/users.service.ts:UsersService:3',
  417. kind: 'class',
  418. name: 'UsersService',
  419. qualifiedName: 'src/users/users.service.ts::UsersService',
  420. filePath: 'src/users/users.service.ts',
  421. language: 'typescript',
  422. startLine: 3,
  423. endLine: 3,
  424. startColumn: 0,
  425. endColumn: 0,
  426. updatedAt: Date.now(),
  427. };
  428. const context = {
  429. ...baseContext,
  430. getNodesByName: (n: string) => (n === 'UsersService' ? [svcNode] : []),
  431. };
  432. const ref = {
  433. fromNodeId: 'class:src/users/users.controller.ts:UsersController:5',
  434. referenceName: 'UsersService',
  435. referenceKind: 'references' as const,
  436. line: 6,
  437. column: 4,
  438. filePath: 'src/users/users.controller.ts',
  439. language: 'typescript' as const,
  440. };
  441. const result = nestjsResolver.resolve(ref, context as any);
  442. expect(result?.targetNodeId).toBe(svcNode.id);
  443. expect(result?.resolvedBy).toBe('framework');
  444. expect(result?.confidence).toBeGreaterThanOrEqual(0.85);
  445. });
  446. it('returns null for a name without a provider suffix', () => {
  447. const ref = {
  448. fromNodeId: 'x',
  449. referenceName: 'doThing',
  450. referenceKind: 'references' as const,
  451. line: 1,
  452. column: 1,
  453. filePath: 'a.ts',
  454. language: 'typescript' as const,
  455. };
  456. expect(nestjsResolver.resolve(ref, baseContext as any)).toBeNull();
  457. });
  458. });
  459. import { laravelResolver } from '../src/resolution/frameworks/laravel';
  460. describe('laravelResolver.extract', () => {
  461. it('extracts route with controller tuple syntax', () => {
  462. const src = `Route::get('/users', [UserController::class, 'index']);\n`;
  463. const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
  464. expect(nodes[0].name).toBe('GET /users');
  465. expect(references[0].referenceName).toBe('UserController@index');
  466. });
  467. it('extracts route with Controller@action syntax', () => {
  468. const src = `Route::post('/users', 'UserController@store');\n`;
  469. const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
  470. expect(references[0].referenceName).toBe('UserController@store');
  471. });
  472. it('extracts resource route', () => {
  473. const src = `Route::resource('users', UserController::class);\n`;
  474. const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
  475. expect(nodes[0].kind).toBe('route');
  476. expect(references[0].referenceName).toBe('UserController');
  477. });
  478. });
  479. import { railsResolver } from '../src/resolution/frameworks/ruby';
  480. describe('railsResolver.extract', () => {
  481. it('extracts route with controller#action syntax', () => {
  482. const src = `get '/users', to: 'users#index'\n`;
  483. const { nodes, references } = railsResolver.extract!('config/routes.rb', src);
  484. expect(nodes[0].name).toBe('GET /users');
  485. expect(references[0].referenceName).toBe('users#index');
  486. });
  487. it('extracts route without to: keyword', () => {
  488. const src = `post '/items' => 'items#create'\n`;
  489. const { nodes, references } = railsResolver.extract!('config/routes.rb', src);
  490. expect(references[0].referenceName).toBe('items#create');
  491. });
  492. });
  493. import { springResolver } from '../src/resolution/frameworks/java';
  494. describe('springResolver.extract', () => {
  495. it('extracts route with @GetMapping and next method', () => {
  496. const src = `
  497. @GetMapping("/users")
  498. public List<User> listUsers() {
  499. return users;
  500. }
  501. `;
  502. const { nodes, references } = springResolver.extract!('UserController.java', src);
  503. expect(nodes[0].name).toBe('GET /users');
  504. expect(references[0].referenceName).toBe('listUsers');
  505. });
  506. });
  507. import { goResolver } from '../src/resolution/frameworks/go';
  508. describe('goResolver.extract', () => {
  509. it('extracts route from r.GET', () => {
  510. const src = `r.GET("/users", listUsers)\n`;
  511. const { nodes, references } = goResolver.extract!('main.go', src);
  512. expect(nodes[0].name).toBe('GET /users');
  513. expect(references[0].referenceName).toBe('listUsers');
  514. });
  515. it('extracts route from router.HandleFunc', () => {
  516. const src = `router.HandleFunc("/items", createItem)\n`;
  517. const { nodes, references } = goResolver.extract!('main.go', src);
  518. expect(references[0].referenceName).toBe('createItem');
  519. });
  520. });
  521. import { rustResolver } from '../src/resolution/frameworks/rust';
  522. describe('rustResolver.extract', () => {
  523. it('extracts route from axum .route with get()', () => {
  524. const src = `let app = Router::new().route("/users", get(list_users));\n`;
  525. const { nodes, references } = rustResolver.extract!('main.rs', src);
  526. expect(nodes[0].name).toBe('GET /users');
  527. expect(references[0].referenceName).toBe('list_users');
  528. });
  529. it('extracts every method from a chained axum .route (get().put())', () => {
  530. const src = `let app = Router::new().route("/user", get(get_current_user).put(update_user));\n`;
  531. const { nodes, references } = rustResolver.extract!('main.rs', src);
  532. expect(nodes.map((n) => n.name)).toEqual(['GET /user', 'PUT /user']);
  533. expect(references.map((r) => r.referenceName)).toEqual([
  534. 'get_current_user',
  535. 'update_user',
  536. ]);
  537. });
  538. it('extracts a multi-line axum .route with a namespaced handler', () => {
  539. const src = `
  540. let app = Router::new()
  541. .route(
  542. "/articles/feed",
  543. get(listing::feed_articles),
  544. );
  545. `;
  546. const { nodes, references } = rustResolver.extract!('main.rs', src);
  547. expect(nodes[0].name).toBe('GET /articles/feed');
  548. expect(references[0].referenceName).toBe('feed_articles');
  549. });
  550. });
  551. describe('rustResolver.resolve cargo workspace crates', () => {
  552. it('resolves crate name from workspace member lib.rs', () => {
  553. const workspaceCargo = `
  554. [workspace]
  555. members = ["crates/mytool-core", "crates/mytool-fetcher"]
  556. `;
  557. const coreCargo = `
  558. [package]
  559. name = "mytool-core"
  560. version = "0.1.0"
  561. `;
  562. const libNode: Node = {
  563. id: 'module:crates/mytool-core/src/lib.rs:mytool_core:1',
  564. kind: 'module',
  565. name: 'mytool_core',
  566. qualifiedName: 'crates/mytool-core/src/lib.rs::mytool_core',
  567. filePath: 'crates/mytool-core/src/lib.rs',
  568. language: 'rust',
  569. startLine: 1,
  570. endLine: 1,
  571. startColumn: 0,
  572. endColumn: 0,
  573. updatedAt: Date.now(),
  574. };
  575. const context = {
  576. getNodesInFile: (fp: string) => (fp === 'crates/mytool-core/src/lib.rs' ? [libNode] : []),
  577. getNodesByName: () => [],
  578. getNodesByQualifiedName: () => [],
  579. getNodesByKind: () => [],
  580. fileExists: (p: string) => (
  581. p === 'Cargo.toml' ||
  582. p === 'crates/mytool-core/Cargo.toml' ||
  583. p === 'crates/mytool-core/src/lib.rs'
  584. ),
  585. readFile: (p: string) => {
  586. if (p === 'Cargo.toml') return workspaceCargo;
  587. if (p === 'crates/mytool-core/Cargo.toml') return coreCargo;
  588. return null;
  589. },
  590. getProjectRoot: () => '/test',
  591. getAllFiles: () => [
  592. 'Cargo.toml',
  593. 'crates/mytool-core/Cargo.toml',
  594. 'crates/mytool-core/src/lib.rs',
  595. ],
  596. getNodesByLowerName: () => [],
  597. getImportMappings: () => [],
  598. };
  599. const ref = {
  600. fromNodeId: 'fn:crates/mytool-fetcher/src/main.rs:main:1',
  601. referenceName: 'mytool_core',
  602. referenceKind: 'references' as const,
  603. line: 1,
  604. column: 1,
  605. filePath: 'crates/mytool-fetcher/src/main.rs',
  606. language: 'rust' as const,
  607. };
  608. const result = rustResolver.resolve(ref, context);
  609. expect(result?.targetNodeId).toBe(libNode.id);
  610. expect(result?.resolvedBy).toBe('framework');
  611. // Workspace-manifest hits are unambiguous and must beat name-matcher's
  612. // self-file matches (0.7) so cross-crate `imports` edges materialize.
  613. expect(result?.confidence).toBeGreaterThanOrEqual(0.9);
  614. });
  615. it('resolves crate name from workspace member main.rs when lib.rs is absent', () => {
  616. const workspaceCargo = `
  617. [workspace]
  618. members = [
  619. "crates/mytool-runner",
  620. ]
  621. `;
  622. const runnerCargo = `
  623. [package]
  624. name = "mytool-runner"
  625. version = "0.1.0"
  626. `;
  627. const mainNode: Node = {
  628. id: 'module:crates/mytool-runner/src/main.rs:mytool_runner:1',
  629. kind: 'module',
  630. name: 'mytool_runner',
  631. qualifiedName: 'crates/mytool-runner/src/main.rs::mytool_runner',
  632. filePath: 'crates/mytool-runner/src/main.rs',
  633. language: 'rust',
  634. startLine: 1,
  635. endLine: 1,
  636. startColumn: 0,
  637. endColumn: 0,
  638. updatedAt: Date.now(),
  639. };
  640. const context = {
  641. getNodesInFile: (fp: string) => (fp === 'crates/mytool-runner/src/main.rs' ? [mainNode] : []),
  642. getNodesByName: () => [],
  643. getNodesByQualifiedName: () => [],
  644. getNodesByKind: () => [],
  645. fileExists: (p: string) => (
  646. p === 'Cargo.toml' ||
  647. p === 'crates/mytool-runner/Cargo.toml' ||
  648. p === 'crates/mytool-runner/src/main.rs'
  649. ),
  650. readFile: (p: string) => {
  651. if (p === 'Cargo.toml') return workspaceCargo;
  652. if (p === 'crates/mytool-runner/Cargo.toml') return runnerCargo;
  653. return null;
  654. },
  655. getProjectRoot: () => '/test',
  656. getAllFiles: () => [
  657. 'Cargo.toml',
  658. 'crates/mytool-runner/Cargo.toml',
  659. 'crates/mytool-runner/src/main.rs',
  660. ],
  661. getNodesByLowerName: () => [],
  662. getImportMappings: () => [],
  663. };
  664. const ref = {
  665. fromNodeId: 'fn:crates/mytool-runner/src/main.rs:main:1',
  666. referenceName: 'mytool_runner',
  667. referenceKind: 'references' as const,
  668. line: 1,
  669. column: 1,
  670. filePath: 'crates/mytool-runner/src/main.rs',
  671. language: 'rust' as const,
  672. };
  673. const result = rustResolver.resolve(ref, context);
  674. expect(result?.targetNodeId).toBe(mainNode.id);
  675. expect(result?.resolvedBy).toBe('framework');
  676. });
  677. it('resolves crate name when members uses a glob (crates/*)', () => {
  678. const workspaceCargo = `
  679. [workspace]
  680. members = ["crates/*"]
  681. `;
  682. const fooCargo = `
  683. [package]
  684. name = "mytool-foo"
  685. version = "0.1.0"
  686. `;
  687. const barCargo = `
  688. [package]
  689. name = "mytool-bar"
  690. version = "0.1.0"
  691. `;
  692. const fooLib: Node = {
  693. id: 'module:crates/mytool-foo/src/lib.rs:mytool_foo:1',
  694. kind: 'module',
  695. name: 'mytool_foo',
  696. qualifiedName: 'crates/mytool-foo/src/lib.rs::mytool_foo',
  697. filePath: 'crates/mytool-foo/src/lib.rs',
  698. language: 'rust',
  699. startLine: 1,
  700. endLine: 1,
  701. startColumn: 0,
  702. endColumn: 0,
  703. updatedAt: Date.now(),
  704. };
  705. const barLib: Node = {
  706. id: 'module:crates/mytool-bar/src/lib.rs:mytool_bar:1',
  707. kind: 'module',
  708. name: 'mytool_bar',
  709. qualifiedName: 'crates/mytool-bar/src/lib.rs::mytool_bar',
  710. filePath: 'crates/mytool-bar/src/lib.rs',
  711. language: 'rust',
  712. startLine: 1,
  713. endLine: 1,
  714. startColumn: 0,
  715. endColumn: 0,
  716. updatedAt: Date.now(),
  717. };
  718. const filesByPath: Record<string, string> = {
  719. 'Cargo.toml': workspaceCargo,
  720. 'crates/mytool-foo/Cargo.toml': fooCargo,
  721. 'crates/mytool-bar/Cargo.toml': barCargo,
  722. };
  723. const nodesByFile: Record<string, Node[]> = {
  724. 'crates/mytool-foo/src/lib.rs': [fooLib],
  725. 'crates/mytool-bar/src/lib.rs': [barLib],
  726. };
  727. const dirsByPath: Record<string, string[]> = {
  728. '.': ['crates'],
  729. crates: ['mytool-foo', 'mytool-bar'],
  730. 'crates/mytool-foo': ['src'],
  731. 'crates/mytool-bar': ['src'],
  732. };
  733. const context = {
  734. getNodesInFile: (fp: string) => nodesByFile[fp] ?? [],
  735. getNodesByName: () => [],
  736. getNodesByQualifiedName: () => [],
  737. getNodesByKind: () => [],
  738. fileExists: (p: string) => (
  739. Object.prototype.hasOwnProperty.call(filesByPath, p) ||
  740. Object.prototype.hasOwnProperty.call(nodesByFile, p)
  741. ),
  742. readFile: (p: string) => filesByPath[p] ?? null,
  743. getProjectRoot: () => '/test',
  744. getAllFiles: () => [
  745. 'Cargo.toml',
  746. ...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'),
  747. ...Object.keys(nodesByFile),
  748. ],
  749. getNodesByLowerName: () => [],
  750. getImportMappings: () => [],
  751. listDirectories: (rel: string) => dirsByPath[rel] ?? [],
  752. };
  753. const fooRef = {
  754. fromNodeId: 'fn:crates/mytool-bar/src/lib.rs:other:1',
  755. referenceName: 'mytool_foo',
  756. referenceKind: 'references' as const,
  757. line: 1,
  758. column: 1,
  759. filePath: 'crates/mytool-bar/src/lib.rs',
  760. language: 'rust' as const,
  761. };
  762. const barRef = {
  763. fromNodeId: 'fn:crates/mytool-foo/src/lib.rs:other:1',
  764. referenceName: 'mytool_bar',
  765. referenceKind: 'references' as const,
  766. line: 1,
  767. column: 1,
  768. filePath: 'crates/mytool-foo/src/lib.rs',
  769. language: 'rust' as const,
  770. };
  771. expect(rustResolver.resolve(fooRef, context)?.targetNodeId).toBe(fooLib.id);
  772. expect(rustResolver.resolve(barRef, context)?.targetNodeId).toBe(barLib.id);
  773. });
  774. it('resolves crate name when members uses a name glob at root (helix-*)', () => {
  775. const workspaceCargo = `
  776. [workspace]
  777. members = ["helix-*"]
  778. `;
  779. const coreCargo = `
  780. [package]
  781. name = "helix-core"
  782. version = "0.1.0"
  783. `;
  784. const coreLib: Node = {
  785. id: 'module:helix-core/src/lib.rs:helix_core:1',
  786. kind: 'module',
  787. name: 'helix_core',
  788. qualifiedName: 'helix-core/src/lib.rs::helix_core',
  789. filePath: 'helix-core/src/lib.rs',
  790. language: 'rust',
  791. startLine: 1,
  792. endLine: 1,
  793. startColumn: 0,
  794. endColumn: 0,
  795. updatedAt: Date.now(),
  796. };
  797. const filesByPath: Record<string, string> = {
  798. 'Cargo.toml': workspaceCargo,
  799. 'helix-core/Cargo.toml': coreCargo,
  800. };
  801. const nodesByFile: Record<string, Node[]> = {
  802. 'helix-core/src/lib.rs': [coreLib],
  803. };
  804. const dirsByPath: Record<string, string[]> = {
  805. '.': ['helix-core', 'docs', 'target'],
  806. 'helix-core': ['src'],
  807. };
  808. const context = {
  809. getNodesInFile: (fp: string) => nodesByFile[fp] ?? [],
  810. getNodesByName: () => [],
  811. getNodesByQualifiedName: () => [],
  812. getNodesByKind: () => [],
  813. fileExists: (p: string) => (
  814. Object.prototype.hasOwnProperty.call(filesByPath, p) ||
  815. Object.prototype.hasOwnProperty.call(nodesByFile, p)
  816. ),
  817. readFile: (p: string) => filesByPath[p] ?? null,
  818. getProjectRoot: () => '/test',
  819. getAllFiles: () => [
  820. 'Cargo.toml',
  821. ...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'),
  822. ...Object.keys(nodesByFile),
  823. ],
  824. getNodesByLowerName: () => [],
  825. getImportMappings: () => [],
  826. listDirectories: (rel: string) => dirsByPath[rel] ?? [],
  827. };
  828. const ref = {
  829. fromNodeId: 'fn:helix-core/src/lib.rs:other:1',
  830. referenceName: 'helix_core',
  831. referenceKind: 'references' as const,
  832. line: 1,
  833. column: 1,
  834. filePath: 'helix-core/src/lib.rs',
  835. language: 'rust' as const,
  836. };
  837. expect(rustResolver.resolve(ref, context)?.targetNodeId).toBe(coreLib.id);
  838. });
  839. });
  840. import { aspnetResolver } from '../src/resolution/frameworks/csharp';
  841. describe('aspnetResolver.extract', () => {
  842. it('extracts route from [HttpGet] attribute', () => {
  843. const src = `
  844. [HttpGet("/users")]
  845. public IActionResult ListUsers()
  846. {
  847. return Ok();
  848. }
  849. `;
  850. const { nodes, references } = aspnetResolver.extract!('UserController.cs', src);
  851. expect(nodes[0].name).toBe('GET /users');
  852. expect(references[0].referenceName).toBe('ListUsers');
  853. });
  854. });
  855. import { vaporResolver } from '../src/resolution/frameworks/swift';
  856. describe('vaporResolver.extract', () => {
  857. it('extracts route from app.get with use:', () => {
  858. const src = `app.get("users", use: listUsers)\n`;
  859. const { nodes, references } = vaporResolver.extract!('routes.swift', src);
  860. expect(nodes[0].name).toBe('GET /users');
  861. expect(references[0].referenceName).toBe('listUsers');
  862. });
  863. it('extracts grouped RouteCollection routes with the group prefix and no path arg', () => {
  864. const src = `
  865. func boot(routes: RoutesBuilder) throws {
  866. let todos = routes.grouped("todos")
  867. todos.get(use: index)
  868. todos.post(use: create)
  869. todos.group(":todoID") { todo in
  870. todo.delete(use: delete)
  871. }
  872. }
  873. `;
  874. const { nodes, references } = vaporResolver.extract!('TodoController.swift', src);
  875. expect(nodes.map((n) => n.name).sort()).toEqual([
  876. 'DELETE /todos/:todoID',
  877. 'GET /todos',
  878. 'POST /todos',
  879. ]);
  880. expect(references.map((r) => r.referenceName).sort()).toEqual([
  881. 'create',
  882. 'delete',
  883. 'index',
  884. ]);
  885. });
  886. it('handles use: self.handler and non-string path segments', () => {
  887. const src = `router.get("users", User.parameter, "edit", use: self.editUserHandler)\n`;
  888. const { nodes, references } = vaporResolver.extract!('UserController.swift', src);
  889. expect(nodes[0].name).toBe('GET /users/edit');
  890. expect(references[0].referenceName).toBe('editUserHandler');
  891. });
  892. it('ignores non-route .get calls that lack use: (e.g. Environment.get)', () => {
  893. const src = `let host = Environment.get("DATABASE_HOST") ?? "localhost"\n`;
  894. const { nodes } = vaporResolver.extract!('configure.swift', src);
  895. expect(nodes).toHaveLength(0);
  896. });
  897. });
  898. import { reactResolver } from '../src/resolution/frameworks/react';
  899. import { svelteResolver } from '../src/resolution/frameworks/svelte';
  900. describe('reactResolver.extract (smoke)', () => {
  901. it('returns { nodes, references } shape', () => {
  902. const src = `<Route path="/users" element={<UsersPage/>}/>`;
  903. const result = reactResolver.extract!('App.tsx', src);
  904. expect(result).toHaveProperty('nodes');
  905. expect(result).toHaveProperty('references');
  906. expect(Array.isArray(result.nodes)).toBe(true);
  907. expect(Array.isArray(result.references)).toBe(true);
  908. });
  909. });
  910. describe('svelteResolver.extract (smoke)', () => {
  911. it('returns { nodes, references } shape', () => {
  912. const result = svelteResolver.extract!('+page.svelte', '');
  913. expect(result).toHaveProperty('nodes');
  914. expect(result).toHaveProperty('references');
  915. });
  916. });
  917. // Regression tests: commented-out and docstring route examples must NOT
  918. // surface as phantom route nodes. These would have failed before the
  919. // strip-comments wiring (the regex would happily scan comments/docstrings).
  920. describe('framework extractors ignore commented-out routes', () => {
  921. it('django: skips line-comment and docstring routes', () => {
  922. const src = `
  923. # urls.py example:
  924. # path('/admin/', AdminPanel.as_view())
  925. """
  926. Other routing example:
  927. path('/users/', UserListView.as_view())
  928. """
  929. urlpatterns = [path('/real/', RealView.as_view())]
  930. `;
  931. const result = djangoResolver.extract!('app/urls.py', src);
  932. const urls = result.nodes.map((n) => n.name);
  933. expect(urls).toEqual(['/real/']);
  934. });
  935. it('flask: skips commented-out @app.route', () => {
  936. const src = `
  937. # @app.route('/fake')
  938. # def fake_view():
  939. # return ''
  940. @app.route('/real')
  941. def real_view():
  942. return ''
  943. `;
  944. const { nodes, references } = flaskResolver.extract!('app.py', src);
  945. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  946. expect(references.map((r) => r.referenceName)).toEqual(['real_view']);
  947. });
  948. it('fastapi: skips docstring example routes', () => {
  949. const src = `
  950. """
  951. Example:
  952. @app.get('/in-docstring')
  953. async def doc():
  954. pass
  955. """
  956. @app.get('/real')
  957. async def real_handler():
  958. return {}
  959. `;
  960. const { nodes, references } = fastapiResolver.extract!('main.py', src);
  961. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  962. expect(references.map((r) => r.referenceName)).toEqual(['real_handler']);
  963. });
  964. it('express: skips // and /* */ commented routes', () => {
  965. const src = `
  966. // app.get('/fake', fakeHandler);
  967. /* router.post('/also-fake', otherHandler); */
  968. app.get('/real', realHandler);
  969. `;
  970. const { nodes, references } = expressResolver.extract!('routes.ts', src);
  971. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  972. expect(references.map((r) => r.referenceName)).toEqual(['realHandler']);
  973. });
  974. it('laravel: skips // # and /* */ commented Route::* calls', () => {
  975. const src = `<?php
  976. // Route::get('/fake', [FakeController::class, 'index']);
  977. # Route::get('/also-fake', 'FakeController@show');
  978. /* Route::post('/another-fake', [X::class, 'y']); */
  979. Route::get('/real', [RealController::class, 'index']);
  980. `;
  981. const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
  982. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  983. expect(references.map((r) => r.referenceName)).toEqual(['RealController@index']);
  984. });
  985. it('rails: skips =begin/=end and # commented routes', () => {
  986. const src = `
  987. # get '/fake', to: 'fake#index'
  988. =begin
  989. get '/also-fake', to: 'fake#show'
  990. =end
  991. get '/real', to: 'real#index'
  992. `;
  993. const { nodes, references } = railsResolver.extract!('config/routes.rb', src);
  994. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  995. expect(references.map((r) => r.referenceName)).toEqual(['real#index']);
  996. });
  997. it('spring: skips // and /* */ commented @GetMapping', () => {
  998. const src = `
  999. // @GetMapping("/fake")
  1000. // public List<X> fake() { return null; }
  1001. /* @PostMapping("/also-fake")
  1002. public void alsoFake() {} */
  1003. @GetMapping("/real")
  1004. public List<User> listUsers() { return users; }
  1005. `;
  1006. const { nodes, references } = springResolver.extract!('UserController.java', src);
  1007. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1008. expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
  1009. });
  1010. it('go: skips // and /* */ commented router.METHOD calls', () => {
  1011. const src = `
  1012. // r.GET("/fake", fakeHandler)
  1013. /* r.POST("/also-fake", anotherHandler) */
  1014. r.GET("/real", listUsers)
  1015. `;
  1016. const { nodes, references } = goResolver.extract!('main.go', src);
  1017. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1018. expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
  1019. });
  1020. it('rust: skips // and nested /* */ commented .route() calls', () => {
  1021. const src = `
  1022. // .route("/fake", get(fake_handler))
  1023. /* outer /* inner .route("/inner-fake", get(x)) */ still .route("/outer-fake", get(y)) */
  1024. let app = Router::new().route("/real", get(list_users));
  1025. `;
  1026. const { nodes, references } = rustResolver.extract!('main.rs', src);
  1027. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1028. expect(references.map((r) => r.referenceName)).toEqual(['list_users']);
  1029. });
  1030. it('aspnet: skips // and /* */ commented [HttpGet] attributes', () => {
  1031. const src = `
  1032. // [HttpGet("/fake")]
  1033. // public IActionResult Fake() { return Ok(); }
  1034. /* [HttpPost("/also-fake")]
  1035. public IActionResult AlsoFake() { return Ok(); } */
  1036. [HttpGet("/real")]
  1037. public IActionResult ListUsers() { return Ok(); }
  1038. `;
  1039. const { nodes, references } = aspnetResolver.extract!('UserController.cs', src);
  1040. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1041. expect(references.map((r) => r.referenceName)).toEqual(['ListUsers']);
  1042. });
  1043. it('vapor: skips // and /* */ commented app.METHOD calls', () => {
  1044. const src = `
  1045. // app.get("fake", use: fakeHandler)
  1046. /* app.post("also-fake", use: anotherHandler) */
  1047. app.get("real", use: listUsers)
  1048. `;
  1049. const { nodes, references } = vaporResolver.extract!('routes.swift', src);
  1050. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1051. expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
  1052. });
  1053. it('nestjs: skips // and /* */ commented decorators', () => {
  1054. const src = `
  1055. @Controller('users')
  1056. export class UsersController {
  1057. // @Get('fake')
  1058. // fake() {}
  1059. /* @Post('also-fake')
  1060. alsoFake() {} */
  1061. @Get('real')
  1062. real() {}
  1063. }
  1064. `;
  1065. const { nodes, references } = nestjsResolver.extract!('users.controller.ts', src);
  1066. expect(nodes.map((n) => n.name)).toEqual(['GET /users/real']);
  1067. expect(references.map((r) => r.referenceName)).toEqual(['real']);
  1068. });
  1069. });