frameworks.test.ts 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120
  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. });
  530. describe('rustResolver.resolve cargo workspace crates', () => {
  531. it('resolves crate name from workspace member lib.rs', () => {
  532. const workspaceCargo = `
  533. [workspace]
  534. members = ["crates/mytool-core", "crates/mytool-fetcher"]
  535. `;
  536. const coreCargo = `
  537. [package]
  538. name = "mytool-core"
  539. version = "0.1.0"
  540. `;
  541. const libNode: Node = {
  542. id: 'module:crates/mytool-core/src/lib.rs:mytool_core:1',
  543. kind: 'module',
  544. name: 'mytool_core',
  545. qualifiedName: 'crates/mytool-core/src/lib.rs::mytool_core',
  546. filePath: 'crates/mytool-core/src/lib.rs',
  547. language: 'rust',
  548. startLine: 1,
  549. endLine: 1,
  550. startColumn: 0,
  551. endColumn: 0,
  552. updatedAt: Date.now(),
  553. };
  554. const context = {
  555. getNodesInFile: (fp: string) => (fp === 'crates/mytool-core/src/lib.rs' ? [libNode] : []),
  556. getNodesByName: () => [],
  557. getNodesByQualifiedName: () => [],
  558. getNodesByKind: () => [],
  559. fileExists: (p: string) => (
  560. p === 'Cargo.toml' ||
  561. p === 'crates/mytool-core/Cargo.toml' ||
  562. p === 'crates/mytool-core/src/lib.rs'
  563. ),
  564. readFile: (p: string) => {
  565. if (p === 'Cargo.toml') return workspaceCargo;
  566. if (p === 'crates/mytool-core/Cargo.toml') return coreCargo;
  567. return null;
  568. },
  569. getProjectRoot: () => '/test',
  570. getAllFiles: () => [
  571. 'Cargo.toml',
  572. 'crates/mytool-core/Cargo.toml',
  573. 'crates/mytool-core/src/lib.rs',
  574. ],
  575. getNodesByLowerName: () => [],
  576. getImportMappings: () => [],
  577. };
  578. const ref = {
  579. fromNodeId: 'fn:crates/mytool-fetcher/src/main.rs:main:1',
  580. referenceName: 'mytool_core',
  581. referenceKind: 'references' as const,
  582. line: 1,
  583. column: 1,
  584. filePath: 'crates/mytool-fetcher/src/main.rs',
  585. language: 'rust' as const,
  586. };
  587. const result = rustResolver.resolve(ref, context);
  588. expect(result?.targetNodeId).toBe(libNode.id);
  589. expect(result?.resolvedBy).toBe('framework');
  590. // Workspace-manifest hits are unambiguous and must beat name-matcher's
  591. // self-file matches (0.7) so cross-crate `imports` edges materialize.
  592. expect(result?.confidence).toBeGreaterThanOrEqual(0.9);
  593. });
  594. it('resolves crate name from workspace member main.rs when lib.rs is absent', () => {
  595. const workspaceCargo = `
  596. [workspace]
  597. members = [
  598. "crates/mytool-runner",
  599. ]
  600. `;
  601. const runnerCargo = `
  602. [package]
  603. name = "mytool-runner"
  604. version = "0.1.0"
  605. `;
  606. const mainNode: Node = {
  607. id: 'module:crates/mytool-runner/src/main.rs:mytool_runner:1',
  608. kind: 'module',
  609. name: 'mytool_runner',
  610. qualifiedName: 'crates/mytool-runner/src/main.rs::mytool_runner',
  611. filePath: 'crates/mytool-runner/src/main.rs',
  612. language: 'rust',
  613. startLine: 1,
  614. endLine: 1,
  615. startColumn: 0,
  616. endColumn: 0,
  617. updatedAt: Date.now(),
  618. };
  619. const context = {
  620. getNodesInFile: (fp: string) => (fp === 'crates/mytool-runner/src/main.rs' ? [mainNode] : []),
  621. getNodesByName: () => [],
  622. getNodesByQualifiedName: () => [],
  623. getNodesByKind: () => [],
  624. fileExists: (p: string) => (
  625. p === 'Cargo.toml' ||
  626. p === 'crates/mytool-runner/Cargo.toml' ||
  627. p === 'crates/mytool-runner/src/main.rs'
  628. ),
  629. readFile: (p: string) => {
  630. if (p === 'Cargo.toml') return workspaceCargo;
  631. if (p === 'crates/mytool-runner/Cargo.toml') return runnerCargo;
  632. return null;
  633. },
  634. getProjectRoot: () => '/test',
  635. getAllFiles: () => [
  636. 'Cargo.toml',
  637. 'crates/mytool-runner/Cargo.toml',
  638. 'crates/mytool-runner/src/main.rs',
  639. ],
  640. getNodesByLowerName: () => [],
  641. getImportMappings: () => [],
  642. };
  643. const ref = {
  644. fromNodeId: 'fn:crates/mytool-runner/src/main.rs:main:1',
  645. referenceName: 'mytool_runner',
  646. referenceKind: 'references' as const,
  647. line: 1,
  648. column: 1,
  649. filePath: 'crates/mytool-runner/src/main.rs',
  650. language: 'rust' as const,
  651. };
  652. const result = rustResolver.resolve(ref, context);
  653. expect(result?.targetNodeId).toBe(mainNode.id);
  654. expect(result?.resolvedBy).toBe('framework');
  655. });
  656. it('resolves crate name when members uses a glob (crates/*)', () => {
  657. const workspaceCargo = `
  658. [workspace]
  659. members = ["crates/*"]
  660. `;
  661. const fooCargo = `
  662. [package]
  663. name = "mytool-foo"
  664. version = "0.1.0"
  665. `;
  666. const barCargo = `
  667. [package]
  668. name = "mytool-bar"
  669. version = "0.1.0"
  670. `;
  671. const fooLib: Node = {
  672. id: 'module:crates/mytool-foo/src/lib.rs:mytool_foo:1',
  673. kind: 'module',
  674. name: 'mytool_foo',
  675. qualifiedName: 'crates/mytool-foo/src/lib.rs::mytool_foo',
  676. filePath: 'crates/mytool-foo/src/lib.rs',
  677. language: 'rust',
  678. startLine: 1,
  679. endLine: 1,
  680. startColumn: 0,
  681. endColumn: 0,
  682. updatedAt: Date.now(),
  683. };
  684. const barLib: Node = {
  685. id: 'module:crates/mytool-bar/src/lib.rs:mytool_bar:1',
  686. kind: 'module',
  687. name: 'mytool_bar',
  688. qualifiedName: 'crates/mytool-bar/src/lib.rs::mytool_bar',
  689. filePath: 'crates/mytool-bar/src/lib.rs',
  690. language: 'rust',
  691. startLine: 1,
  692. endLine: 1,
  693. startColumn: 0,
  694. endColumn: 0,
  695. updatedAt: Date.now(),
  696. };
  697. const filesByPath: Record<string, string> = {
  698. 'Cargo.toml': workspaceCargo,
  699. 'crates/mytool-foo/Cargo.toml': fooCargo,
  700. 'crates/mytool-bar/Cargo.toml': barCargo,
  701. };
  702. const nodesByFile: Record<string, Node[]> = {
  703. 'crates/mytool-foo/src/lib.rs': [fooLib],
  704. 'crates/mytool-bar/src/lib.rs': [barLib],
  705. };
  706. const dirsByPath: Record<string, string[]> = {
  707. '.': ['crates'],
  708. crates: ['mytool-foo', 'mytool-bar'],
  709. 'crates/mytool-foo': ['src'],
  710. 'crates/mytool-bar': ['src'],
  711. };
  712. const context = {
  713. getNodesInFile: (fp: string) => nodesByFile[fp] ?? [],
  714. getNodesByName: () => [],
  715. getNodesByQualifiedName: () => [],
  716. getNodesByKind: () => [],
  717. fileExists: (p: string) => (
  718. Object.prototype.hasOwnProperty.call(filesByPath, p) ||
  719. Object.prototype.hasOwnProperty.call(nodesByFile, p)
  720. ),
  721. readFile: (p: string) => filesByPath[p] ?? null,
  722. getProjectRoot: () => '/test',
  723. getAllFiles: () => [
  724. 'Cargo.toml',
  725. ...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'),
  726. ...Object.keys(nodesByFile),
  727. ],
  728. getNodesByLowerName: () => [],
  729. getImportMappings: () => [],
  730. listDirectories: (rel: string) => dirsByPath[rel] ?? [],
  731. };
  732. const fooRef = {
  733. fromNodeId: 'fn:crates/mytool-bar/src/lib.rs:other:1',
  734. referenceName: 'mytool_foo',
  735. referenceKind: 'references' as const,
  736. line: 1,
  737. column: 1,
  738. filePath: 'crates/mytool-bar/src/lib.rs',
  739. language: 'rust' as const,
  740. };
  741. const barRef = {
  742. fromNodeId: 'fn:crates/mytool-foo/src/lib.rs:other:1',
  743. referenceName: 'mytool_bar',
  744. referenceKind: 'references' as const,
  745. line: 1,
  746. column: 1,
  747. filePath: 'crates/mytool-foo/src/lib.rs',
  748. language: 'rust' as const,
  749. };
  750. expect(rustResolver.resolve(fooRef, context)?.targetNodeId).toBe(fooLib.id);
  751. expect(rustResolver.resolve(barRef, context)?.targetNodeId).toBe(barLib.id);
  752. });
  753. it('resolves crate name when members uses a name glob at root (helix-*)', () => {
  754. const workspaceCargo = `
  755. [workspace]
  756. members = ["helix-*"]
  757. `;
  758. const coreCargo = `
  759. [package]
  760. name = "helix-core"
  761. version = "0.1.0"
  762. `;
  763. const coreLib: Node = {
  764. id: 'module:helix-core/src/lib.rs:helix_core:1',
  765. kind: 'module',
  766. name: 'helix_core',
  767. qualifiedName: 'helix-core/src/lib.rs::helix_core',
  768. filePath: 'helix-core/src/lib.rs',
  769. language: 'rust',
  770. startLine: 1,
  771. endLine: 1,
  772. startColumn: 0,
  773. endColumn: 0,
  774. updatedAt: Date.now(),
  775. };
  776. const filesByPath: Record<string, string> = {
  777. 'Cargo.toml': workspaceCargo,
  778. 'helix-core/Cargo.toml': coreCargo,
  779. };
  780. const nodesByFile: Record<string, Node[]> = {
  781. 'helix-core/src/lib.rs': [coreLib],
  782. };
  783. const dirsByPath: Record<string, string[]> = {
  784. '.': ['helix-core', 'docs', 'target'],
  785. 'helix-core': ['src'],
  786. };
  787. const context = {
  788. getNodesInFile: (fp: string) => nodesByFile[fp] ?? [],
  789. getNodesByName: () => [],
  790. getNodesByQualifiedName: () => [],
  791. getNodesByKind: () => [],
  792. fileExists: (p: string) => (
  793. Object.prototype.hasOwnProperty.call(filesByPath, p) ||
  794. Object.prototype.hasOwnProperty.call(nodesByFile, p)
  795. ),
  796. readFile: (p: string) => filesByPath[p] ?? null,
  797. getProjectRoot: () => '/test',
  798. getAllFiles: () => [
  799. 'Cargo.toml',
  800. ...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'),
  801. ...Object.keys(nodesByFile),
  802. ],
  803. getNodesByLowerName: () => [],
  804. getImportMappings: () => [],
  805. listDirectories: (rel: string) => dirsByPath[rel] ?? [],
  806. };
  807. const ref = {
  808. fromNodeId: 'fn:helix-core/src/lib.rs:other:1',
  809. referenceName: 'helix_core',
  810. referenceKind: 'references' as const,
  811. line: 1,
  812. column: 1,
  813. filePath: 'helix-core/src/lib.rs',
  814. language: 'rust' as const,
  815. };
  816. expect(rustResolver.resolve(ref, context)?.targetNodeId).toBe(coreLib.id);
  817. });
  818. });
  819. import { aspnetResolver } from '../src/resolution/frameworks/csharp';
  820. describe('aspnetResolver.extract', () => {
  821. it('extracts route from [HttpGet] attribute', () => {
  822. const src = `
  823. [HttpGet("/users")]
  824. public IActionResult ListUsers()
  825. {
  826. return Ok();
  827. }
  828. `;
  829. const { nodes, references } = aspnetResolver.extract!('UserController.cs', src);
  830. expect(nodes[0].name).toBe('GET /users');
  831. expect(references[0].referenceName).toBe('ListUsers');
  832. });
  833. });
  834. import { vaporResolver } from '../src/resolution/frameworks/swift';
  835. describe('vaporResolver.extract', () => {
  836. it('extracts route from app.get with use:', () => {
  837. const src = `app.get("users", use: listUsers)\n`;
  838. const { nodes, references } = vaporResolver.extract!('routes.swift', src);
  839. expect(nodes[0].name).toBe('GET users');
  840. expect(references[0].referenceName).toBe('listUsers');
  841. });
  842. });
  843. import { reactResolver } from '../src/resolution/frameworks/react';
  844. import { svelteResolver } from '../src/resolution/frameworks/svelte';
  845. describe('reactResolver.extract (smoke)', () => {
  846. it('returns { nodes, references } shape', () => {
  847. const src = `<Route path="/users" element={<UsersPage/>}/>`;
  848. const result = reactResolver.extract!('App.tsx', src);
  849. expect(result).toHaveProperty('nodes');
  850. expect(result).toHaveProperty('references');
  851. expect(Array.isArray(result.nodes)).toBe(true);
  852. expect(Array.isArray(result.references)).toBe(true);
  853. });
  854. });
  855. describe('svelteResolver.extract (smoke)', () => {
  856. it('returns { nodes, references } shape', () => {
  857. const result = svelteResolver.extract!('+page.svelte', '');
  858. expect(result).toHaveProperty('nodes');
  859. expect(result).toHaveProperty('references');
  860. });
  861. });
  862. // Regression tests: commented-out and docstring route examples must NOT
  863. // surface as phantom route nodes. These would have failed before the
  864. // strip-comments wiring (the regex would happily scan comments/docstrings).
  865. describe('framework extractors ignore commented-out routes', () => {
  866. it('django: skips line-comment and docstring routes', () => {
  867. const src = `
  868. # urls.py example:
  869. # path('/admin/', AdminPanel.as_view())
  870. """
  871. Other routing example:
  872. path('/users/', UserListView.as_view())
  873. """
  874. urlpatterns = [path('/real/', RealView.as_view())]
  875. `;
  876. const result = djangoResolver.extract!('app/urls.py', src);
  877. const urls = result.nodes.map((n) => n.name);
  878. expect(urls).toEqual(['/real/']);
  879. });
  880. it('flask: skips commented-out @app.route', () => {
  881. const src = `
  882. # @app.route('/fake')
  883. # def fake_view():
  884. # return ''
  885. @app.route('/real')
  886. def real_view():
  887. return ''
  888. `;
  889. const { nodes, references } = flaskResolver.extract!('app.py', src);
  890. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  891. expect(references.map((r) => r.referenceName)).toEqual(['real_view']);
  892. });
  893. it('fastapi: skips docstring example routes', () => {
  894. const src = `
  895. """
  896. Example:
  897. @app.get('/in-docstring')
  898. async def doc():
  899. pass
  900. """
  901. @app.get('/real')
  902. async def real_handler():
  903. return {}
  904. `;
  905. const { nodes, references } = fastapiResolver.extract!('main.py', src);
  906. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  907. expect(references.map((r) => r.referenceName)).toEqual(['real_handler']);
  908. });
  909. it('express: skips // and /* */ commented routes', () => {
  910. const src = `
  911. // app.get('/fake', fakeHandler);
  912. /* router.post('/also-fake', otherHandler); */
  913. app.get('/real', realHandler);
  914. `;
  915. const { nodes, references } = expressResolver.extract!('routes.ts', src);
  916. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  917. expect(references.map((r) => r.referenceName)).toEqual(['realHandler']);
  918. });
  919. it('laravel: skips // # and /* */ commented Route::* calls', () => {
  920. const src = `<?php
  921. // Route::get('/fake', [FakeController::class, 'index']);
  922. # Route::get('/also-fake', 'FakeController@show');
  923. /* Route::post('/another-fake', [X::class, 'y']); */
  924. Route::get('/real', [RealController::class, 'index']);
  925. `;
  926. const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
  927. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  928. expect(references.map((r) => r.referenceName)).toEqual(['RealController@index']);
  929. });
  930. it('rails: skips =begin/=end and # commented routes', () => {
  931. const src = `
  932. # get '/fake', to: 'fake#index'
  933. =begin
  934. get '/also-fake', to: 'fake#show'
  935. =end
  936. get '/real', to: 'real#index'
  937. `;
  938. const { nodes, references } = railsResolver.extract!('config/routes.rb', src);
  939. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  940. expect(references.map((r) => r.referenceName)).toEqual(['real#index']);
  941. });
  942. it('spring: skips // and /* */ commented @GetMapping', () => {
  943. const src = `
  944. // @GetMapping("/fake")
  945. // public List<X> fake() { return null; }
  946. /* @PostMapping("/also-fake")
  947. public void alsoFake() {} */
  948. @GetMapping("/real")
  949. public List<User> listUsers() { return users; }
  950. `;
  951. const { nodes, references } = springResolver.extract!('UserController.java', src);
  952. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  953. expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
  954. });
  955. it('go: skips // and /* */ commented router.METHOD calls', () => {
  956. const src = `
  957. // r.GET("/fake", fakeHandler)
  958. /* r.POST("/also-fake", anotherHandler) */
  959. r.GET("/real", listUsers)
  960. `;
  961. const { nodes, references } = goResolver.extract!('main.go', src);
  962. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  963. expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
  964. });
  965. it('rust: skips // and nested /* */ commented .route() calls', () => {
  966. const src = `
  967. // .route("/fake", get(fake_handler))
  968. /* outer /* inner .route("/inner-fake", get(x)) */ still .route("/outer-fake", get(y)) */
  969. let app = Router::new().route("/real", get(list_users));
  970. `;
  971. const { nodes, references } = rustResolver.extract!('main.rs', src);
  972. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  973. expect(references.map((r) => r.referenceName)).toEqual(['list_users']);
  974. });
  975. it('aspnet: skips // and /* */ commented [HttpGet] attributes', () => {
  976. const src = `
  977. // [HttpGet("/fake")]
  978. // public IActionResult Fake() { return Ok(); }
  979. /* [HttpPost("/also-fake")]
  980. public IActionResult AlsoFake() { return Ok(); } */
  981. [HttpGet("/real")]
  982. public IActionResult ListUsers() { return Ok(); }
  983. `;
  984. const { nodes, references } = aspnetResolver.extract!('UserController.cs', src);
  985. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  986. expect(references.map((r) => r.referenceName)).toEqual(['ListUsers']);
  987. });
  988. it('vapor: skips // and /* */ commented app.METHOD calls', () => {
  989. const src = `
  990. // app.get("fake", use: fakeHandler)
  991. /* app.post("also-fake", use: anotherHandler) */
  992. app.get("real", use: listUsers)
  993. `;
  994. const { nodes, references } = vaporResolver.extract!('routes.swift', src);
  995. expect(nodes.map((n) => n.name)).toEqual(['GET real']);
  996. expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
  997. });
  998. it('nestjs: skips // and /* */ commented decorators', () => {
  999. const src = `
  1000. @Controller('users')
  1001. export class UsersController {
  1002. // @Get('fake')
  1003. // fake() {}
  1004. /* @Post('also-fake')
  1005. alsoFake() {} */
  1006. @Get('real')
  1007. real() {}
  1008. }
  1009. `;
  1010. const { nodes, references } = nestjsResolver.extract!('users.controller.ts', src);
  1011. expect(nodes.map((n) => n.name)).toEqual(['GET /users/real']);
  1012. expect(references.map((r) => r.referenceName)).toEqual(['real']);
  1013. });
  1014. });