frameworks.test.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143
  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. });
  864. import { reactResolver } from '../src/resolution/frameworks/react';
  865. import { svelteResolver } from '../src/resolution/frameworks/svelte';
  866. describe('reactResolver.extract (smoke)', () => {
  867. it('returns { nodes, references } shape', () => {
  868. const src = `<Route path="/users" element={<UsersPage/>}/>`;
  869. const result = reactResolver.extract!('App.tsx', src);
  870. expect(result).toHaveProperty('nodes');
  871. expect(result).toHaveProperty('references');
  872. expect(Array.isArray(result.nodes)).toBe(true);
  873. expect(Array.isArray(result.references)).toBe(true);
  874. });
  875. });
  876. describe('svelteResolver.extract (smoke)', () => {
  877. it('returns { nodes, references } shape', () => {
  878. const result = svelteResolver.extract!('+page.svelte', '');
  879. expect(result).toHaveProperty('nodes');
  880. expect(result).toHaveProperty('references');
  881. });
  882. });
  883. // Regression tests: commented-out and docstring route examples must NOT
  884. // surface as phantom route nodes. These would have failed before the
  885. // strip-comments wiring (the regex would happily scan comments/docstrings).
  886. describe('framework extractors ignore commented-out routes', () => {
  887. it('django: skips line-comment and docstring routes', () => {
  888. const src = `
  889. # urls.py example:
  890. # path('/admin/', AdminPanel.as_view())
  891. """
  892. Other routing example:
  893. path('/users/', UserListView.as_view())
  894. """
  895. urlpatterns = [path('/real/', RealView.as_view())]
  896. `;
  897. const result = djangoResolver.extract!('app/urls.py', src);
  898. const urls = result.nodes.map((n) => n.name);
  899. expect(urls).toEqual(['/real/']);
  900. });
  901. it('flask: skips commented-out @app.route', () => {
  902. const src = `
  903. # @app.route('/fake')
  904. # def fake_view():
  905. # return ''
  906. @app.route('/real')
  907. def real_view():
  908. return ''
  909. `;
  910. const { nodes, references } = flaskResolver.extract!('app.py', src);
  911. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  912. expect(references.map((r) => r.referenceName)).toEqual(['real_view']);
  913. });
  914. it('fastapi: skips docstring example routes', () => {
  915. const src = `
  916. """
  917. Example:
  918. @app.get('/in-docstring')
  919. async def doc():
  920. pass
  921. """
  922. @app.get('/real')
  923. async def real_handler():
  924. return {}
  925. `;
  926. const { nodes, references } = fastapiResolver.extract!('main.py', src);
  927. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  928. expect(references.map((r) => r.referenceName)).toEqual(['real_handler']);
  929. });
  930. it('express: skips // and /* */ commented routes', () => {
  931. const src = `
  932. // app.get('/fake', fakeHandler);
  933. /* router.post('/also-fake', otherHandler); */
  934. app.get('/real', realHandler);
  935. `;
  936. const { nodes, references } = expressResolver.extract!('routes.ts', src);
  937. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  938. expect(references.map((r) => r.referenceName)).toEqual(['realHandler']);
  939. });
  940. it('laravel: skips // # and /* */ commented Route::* calls', () => {
  941. const src = `<?php
  942. // Route::get('/fake', [FakeController::class, 'index']);
  943. # Route::get('/also-fake', 'FakeController@show');
  944. /* Route::post('/another-fake', [X::class, 'y']); */
  945. Route::get('/real', [RealController::class, 'index']);
  946. `;
  947. const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
  948. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  949. expect(references.map((r) => r.referenceName)).toEqual(['RealController@index']);
  950. });
  951. it('rails: skips =begin/=end and # commented routes', () => {
  952. const src = `
  953. # get '/fake', to: 'fake#index'
  954. =begin
  955. get '/also-fake', to: 'fake#show'
  956. =end
  957. get '/real', to: 'real#index'
  958. `;
  959. const { nodes, references } = railsResolver.extract!('config/routes.rb', src);
  960. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  961. expect(references.map((r) => r.referenceName)).toEqual(['real#index']);
  962. });
  963. it('spring: skips // and /* */ commented @GetMapping', () => {
  964. const src = `
  965. // @GetMapping("/fake")
  966. // public List<X> fake() { return null; }
  967. /* @PostMapping("/also-fake")
  968. public void alsoFake() {} */
  969. @GetMapping("/real")
  970. public List<User> listUsers() { return users; }
  971. `;
  972. const { nodes, references } = springResolver.extract!('UserController.java', src);
  973. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  974. expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
  975. });
  976. it('go: skips // and /* */ commented router.METHOD calls', () => {
  977. const src = `
  978. // r.GET("/fake", fakeHandler)
  979. /* r.POST("/also-fake", anotherHandler) */
  980. r.GET("/real", listUsers)
  981. `;
  982. const { nodes, references } = goResolver.extract!('main.go', src);
  983. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  984. expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
  985. });
  986. it('rust: skips // and nested /* */ commented .route() calls', () => {
  987. const src = `
  988. // .route("/fake", get(fake_handler))
  989. /* outer /* inner .route("/inner-fake", get(x)) */ still .route("/outer-fake", get(y)) */
  990. let app = Router::new().route("/real", get(list_users));
  991. `;
  992. const { nodes, references } = rustResolver.extract!('main.rs', src);
  993. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  994. expect(references.map((r) => r.referenceName)).toEqual(['list_users']);
  995. });
  996. it('aspnet: skips // and /* */ commented [HttpGet] attributes', () => {
  997. const src = `
  998. // [HttpGet("/fake")]
  999. // public IActionResult Fake() { return Ok(); }
  1000. /* [HttpPost("/also-fake")]
  1001. public IActionResult AlsoFake() { return Ok(); } */
  1002. [HttpGet("/real")]
  1003. public IActionResult ListUsers() { return Ok(); }
  1004. `;
  1005. const { nodes, references } = aspnetResolver.extract!('UserController.cs', src);
  1006. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1007. expect(references.map((r) => r.referenceName)).toEqual(['ListUsers']);
  1008. });
  1009. it('vapor: skips // and /* */ commented app.METHOD calls', () => {
  1010. const src = `
  1011. // app.get("fake", use: fakeHandler)
  1012. /* app.post("also-fake", use: anotherHandler) */
  1013. app.get("real", use: listUsers)
  1014. `;
  1015. const { nodes, references } = vaporResolver.extract!('routes.swift', src);
  1016. expect(nodes.map((n) => n.name)).toEqual(['GET real']);
  1017. expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
  1018. });
  1019. it('nestjs: skips // and /* */ commented decorators', () => {
  1020. const src = `
  1021. @Controller('users')
  1022. export class UsersController {
  1023. // @Get('fake')
  1024. // fake() {}
  1025. /* @Post('also-fake')
  1026. alsoFake() {} */
  1027. @Get('real')
  1028. real() {}
  1029. }
  1030. `;
  1031. const { nodes, references } = nestjsResolver.extract!('users.controller.ts', src);
  1032. expect(nodes.map((n) => n.name)).toEqual(['GET /users/real']);
  1033. expect(references.map((r) => r.referenceName)).toEqual(['real']);
  1034. });
  1035. });