frameworks.test.ts 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334
  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. it('extracts the method from a tuple methods=(...) (not just a list)', () => {
  134. const src = `
  135. @blueprint.route('/api/articles', methods=('POST',))
  136. def make_article():
  137. pass
  138. `;
  139. const { nodes, references } = flaskResolver.extract!('views.py', src);
  140. expect(nodes[0].name).toBe('POST /api/articles');
  141. expect(references[0].referenceName).toBe('make_article');
  142. });
  143. it('extracts Flask-RESTful api.add_resource(Resource, paths) → the Resource class', () => {
  144. const src = `
  145. api.add_resource(TodoResource, '/todos/<id>')
  146. api.add_org_resource(AlertResource, '/api/alerts/<id>', endpoint='alert')
  147. `;
  148. const { nodes, references } = flaskResolver.extract!('api.py', src);
  149. expect(nodes.map((n) => n.name)).toEqual(['ANY /todos/<id>', 'ANY /api/alerts/<id>']);
  150. expect(references.map((r) => r.referenceName)).toEqual(['TodoResource', 'AlertResource']);
  151. });
  152. });
  153. describe('fastapiResolver.extract', () => {
  154. it('extracts route and reference from @app.get', () => {
  155. const src = `
  156. @app.get('/users')
  157. async def list_users():
  158. return []
  159. `;
  160. const { nodes, references } = fastapiResolver.extract!('main.py', src);
  161. expect(nodes[0].name).toBe('GET /users');
  162. expect(references[0].referenceName).toBe('list_users');
  163. });
  164. it('extracts route from router.post', () => {
  165. const src = `
  166. @router.post('/items')
  167. def create_item(item: Item):
  168. pass
  169. `;
  170. const { nodes, references } = fastapiResolver.extract!('items.py', src);
  171. expect(nodes[0].name).toBe('POST /items');
  172. expect(references[0].referenceName).toBe('create_item');
  173. });
  174. it('extracts a route mounted at the router/prefix root (empty path)', () => {
  175. const src = `
  176. @router.get("", response_model=ListOfArticles, name="articles:list")
  177. async def list_articles():
  178. return []
  179. `;
  180. const { nodes, references } = fastapiResolver.extract!('articles.py', src);
  181. expect(nodes[0].name).toBe('GET /');
  182. expect(references[0].referenceName).toBe('list_articles');
  183. });
  184. it('extracts a multi-line decorator with an empty path', () => {
  185. const src = `
  186. @router.post(
  187. "",
  188. status_code=201,
  189. response_model=ArticleInResponse,
  190. )
  191. async def create_article():
  192. pass
  193. `;
  194. const { nodes, references } = fastapiResolver.extract!('articles.py', src);
  195. expect(nodes[0].name).toBe('POST /');
  196. expect(references[0].referenceName).toBe('create_article');
  197. });
  198. });
  199. import { expressResolver } from '../src/resolution/frameworks/express';
  200. describe('expressResolver.extract', () => {
  201. it('extracts route with inline handler reference', () => {
  202. const src = `app.get('/users', listUsers);\n`;
  203. const { nodes, references } = expressResolver.extract!('routes.ts', src);
  204. expect(nodes).toHaveLength(1);
  205. expect(nodes[0].name).toBe('GET /users');
  206. expect(references[0].referenceName).toBe('listUsers');
  207. });
  208. it('extracts route with router.post and middleware chain', () => {
  209. const src = `router.post('/items', auth, createItem);\n`;
  210. const { nodes, references } = expressResolver.extract!('items.ts', src);
  211. expect(nodes[0].name).toBe('POST /items');
  212. // Multiple handlers: prefer the LAST one (convention: middleware first, handler last)
  213. expect(references[0].referenceName).toBe('createItem');
  214. });
  215. it('extracts route with controller method reference', () => {
  216. const src = `app.get('/x', userController.list);\n`;
  217. const { nodes, references } = expressResolver.extract!('routes.ts', src);
  218. expect(references[0].referenceName).toBe('list');
  219. });
  220. });
  221. import { nestjsResolver } from '../src/resolution/frameworks/nestjs';
  222. describe('nestjsResolver.extract — HTTP', () => {
  223. it('joins @Controller prefix with @Get and links the handler', () => {
  224. const src = `
  225. @Controller('users')
  226. export class UsersController {
  227. @Get()
  228. findAll() { return []; }
  229. }
  230. `;
  231. const { nodes, references } = nestjsResolver.extract!('users.controller.ts', src);
  232. expect(nodes).toHaveLength(1);
  233. expect(nodes[0].kind).toBe('route');
  234. expect(nodes[0].name).toBe('GET /users');
  235. expect(references[0].referenceName).toBe('findAll');
  236. expect(references[0].referenceKind).toBe('references');
  237. expect(references[0].fromNodeId).toBe(nodes[0].id);
  238. });
  239. it('joins controller prefix with a method-level path param', () => {
  240. const src = `
  241. @Controller('cats')
  242. export class CatsController {
  243. @Get(':id')
  244. findOne(@Param('id') id: string) { return id; }
  245. }
  246. `;
  247. const { nodes, references } = nestjsResolver.extract!('cats.controller.ts', src);
  248. expect(nodes[0].name).toBe('GET /cats/:id');
  249. expect(references[0].referenceName).toBe('findOne');
  250. });
  251. it('handles an empty @Controller() and empty @Post()', () => {
  252. const src = `
  253. @Controller()
  254. export class AppController {
  255. @Post()
  256. create() {}
  257. }
  258. `;
  259. const { nodes, references } = nestjsResolver.extract!('app.controller.ts', src);
  260. expect(nodes[0].name).toBe('POST /');
  261. expect(references[0].referenceName).toBe('create');
  262. });
  263. it('covers HTTP verbs and skips intervening method decorators', () => {
  264. const src = `
  265. @Controller('todos')
  266. export class TodosController {
  267. @Put(':id')
  268. @UseGuards(AuthGuard)
  269. update(@Param('id') id: string) {}
  270. @Delete(':id')
  271. async remove(@Param('id') id: string) {}
  272. }
  273. `;
  274. const { nodes, references } = nestjsResolver.extract!('todos.controller.ts', src);
  275. expect(nodes.map((n) => n.name)).toEqual(['PUT /todos/:id', 'DELETE /todos/:id']);
  276. expect(references.map((r) => r.referenceName)).toEqual(['update', 'remove']);
  277. });
  278. it('attributes methods to the right controller when a file has two', () => {
  279. const src = `
  280. @Controller('a')
  281. export class AController {
  282. @Get('x')
  283. ax() {}
  284. }
  285. @Controller('b')
  286. export class BController {
  287. @Get('y')
  288. by() {}
  289. }
  290. `;
  291. const { nodes } = nestjsResolver.extract!('multi.controller.ts', src);
  292. expect(nodes.map((n) => n.name)).toEqual(['GET /a/x', 'GET /b/y']);
  293. });
  294. });
  295. describe('nestjsResolver.extract — GraphQL', () => {
  296. it('emits QUERY/MUTATION nodes from a resolver, defaulting to the method name', () => {
  297. const src = `
  298. @Resolver(() => User)
  299. export class UsersResolver {
  300. @Query(() => [User])
  301. users() { return []; }
  302. @Mutation(() => User)
  303. createUser(@Args('input') input: CreateUserInput) {}
  304. }
  305. `;
  306. const { nodes, references } = nestjsResolver.extract!('users.resolver.ts', src);
  307. expect(nodes.map((n) => n.name)).toEqual(['QUERY users', 'MUTATION createUser']);
  308. expect(references.map((r) => r.referenceName)).toEqual(['users', 'createUser']);
  309. });
  310. it('uses an explicit operation name when given', () => {
  311. const src = `
  312. @Resolver()
  313. export class CatsResolver {
  314. @Query(() => Cat, { name: 'cat' })
  315. getCat() {}
  316. }
  317. `;
  318. const { nodes } = nestjsResolver.extract!('cats.resolver.ts', src);
  319. expect(nodes[0].name).toBe('QUERY cat');
  320. });
  321. it('does NOT treat the REST @Query() parameter decorator as a GraphQL op', () => {
  322. const src = `
  323. @Controller('search')
  324. export class SearchController {
  325. @Get()
  326. search(@Query() query: SearchDto) { return query; }
  327. }
  328. `;
  329. const { nodes } = nestjsResolver.extract!('search.controller.ts', src);
  330. // Only the HTTP route — the @Query() param decorator must be ignored.
  331. expect(nodes.map((n) => n.name)).toEqual(['GET /search']);
  332. });
  333. });
  334. describe('nestjsResolver.extract — microservices & websockets', () => {
  335. it('extracts @MessagePattern and @EventPattern handlers', () => {
  336. const src = `
  337. @Controller()
  338. export class MathController {
  339. @MessagePattern({ cmd: 'sum' })
  340. accumulate(data: number[]) {}
  341. @EventPattern('user.created')
  342. handleUserCreated(data: any) {}
  343. }
  344. `;
  345. const { nodes, references } = nestjsResolver.extract!('math.controller.ts', src);
  346. expect(nodes.map((n) => n.name)).toEqual(['MESSAGE sum', 'EVENT user.created']);
  347. expect(references.map((r) => r.referenceName)).toEqual(['accumulate', 'handleUserCreated']);
  348. });
  349. it('extracts @SubscribeMessage handlers with the gateway namespace', () => {
  350. const src = `
  351. @WebSocketGateway({ namespace: 'chat' })
  352. export class ChatGateway {
  353. @SubscribeMessage('message')
  354. handleMessage(@MessageBody() data: string) {}
  355. }
  356. `;
  357. const { nodes, references } = nestjsResolver.extract!('chat.gateway.ts', src);
  358. expect(nodes[0].name).toBe('WS chat:message');
  359. expect(references[0].referenceName).toBe('handleMessage');
  360. });
  361. it('extracts @SubscribeMessage without a namespace', () => {
  362. const src = `
  363. @WebSocketGateway()
  364. export class EventsGateway {
  365. @SubscribeMessage('events')
  366. onEvent() {}
  367. }
  368. `;
  369. const { nodes } = nestjsResolver.extract!('events.gateway.ts', src);
  370. expect(nodes[0].name).toBe('WS events');
  371. });
  372. it('returns empty for a non-JS/TS file', () => {
  373. const { nodes, references } = nestjsResolver.extract!('thing.py', '@Controller("x")');
  374. expect(nodes).toEqual([]);
  375. expect(references).toEqual([]);
  376. });
  377. });
  378. describe('nestjsResolver.detect', () => {
  379. const baseContext = {
  380. getNodesInFile: () => [],
  381. getNodesByName: () => [],
  382. getNodesByQualifiedName: () => [],
  383. getNodesByKind: () => [],
  384. fileExists: () => false,
  385. getProjectRoot: () => '/test',
  386. getAllFiles: () => [],
  387. getNodesByLowerName: () => [],
  388. getImportMappings: () => [],
  389. };
  390. it('detects @nestjs/* in package.json', () => {
  391. const context = {
  392. ...baseContext,
  393. readFile: (p: string) =>
  394. p === 'package.json'
  395. ? JSON.stringify({ dependencies: { '@nestjs/common': '^10.0.0' } })
  396. : null,
  397. };
  398. expect(nestjsResolver.detect(context as any)).toBe(true);
  399. });
  400. it('detects @Controller in a *.controller.ts file when package.json is absent', () => {
  401. const context = {
  402. ...baseContext,
  403. getAllFiles: () => ['src/users.controller.ts'],
  404. readFile: (p: string) =>
  405. p === 'src/users.controller.ts'
  406. ? `@Controller('users')\nexport class UsersController {}`
  407. : null,
  408. };
  409. expect(nestjsResolver.detect(context as any)).toBe(true);
  410. });
  411. it('returns false for a non-Nest project', () => {
  412. const context = {
  413. ...baseContext,
  414. readFile: (p: string) =>
  415. p === 'package.json' ? JSON.stringify({ dependencies: { express: '^4' } }) : null,
  416. };
  417. expect(nestjsResolver.detect(context as any)).toBe(false);
  418. });
  419. });
  420. describe('nestjsResolver.resolve', () => {
  421. const baseContext = {
  422. getNodesInFile: () => [],
  423. getNodesByName: () => [],
  424. getNodesByQualifiedName: () => [],
  425. getNodesByKind: () => [],
  426. fileExists: () => false,
  427. readFile: () => null,
  428. getProjectRoot: () => '/test',
  429. getAllFiles: () => [],
  430. getNodesByLowerName: () => [],
  431. getImportMappings: () => [],
  432. };
  433. it('resolves an injected *Service reference to the class in a *.service.ts file', () => {
  434. const svcNode: Node = {
  435. id: 'class:src/users/users.service.ts:UsersService:3',
  436. kind: 'class',
  437. name: 'UsersService',
  438. qualifiedName: 'src/users/users.service.ts::UsersService',
  439. filePath: 'src/users/users.service.ts',
  440. language: 'typescript',
  441. startLine: 3,
  442. endLine: 3,
  443. startColumn: 0,
  444. endColumn: 0,
  445. updatedAt: Date.now(),
  446. };
  447. const context = {
  448. ...baseContext,
  449. getNodesByName: (n: string) => (n === 'UsersService' ? [svcNode] : []),
  450. };
  451. const ref = {
  452. fromNodeId: 'class:src/users/users.controller.ts:UsersController:5',
  453. referenceName: 'UsersService',
  454. referenceKind: 'references' as const,
  455. line: 6,
  456. column: 4,
  457. filePath: 'src/users/users.controller.ts',
  458. language: 'typescript' as const,
  459. };
  460. const result = nestjsResolver.resolve(ref, context as any);
  461. expect(result?.targetNodeId).toBe(svcNode.id);
  462. expect(result?.resolvedBy).toBe('framework');
  463. expect(result?.confidence).toBeGreaterThanOrEqual(0.85);
  464. });
  465. it('returns null for a name without a provider suffix', () => {
  466. const ref = {
  467. fromNodeId: 'x',
  468. referenceName: 'doThing',
  469. referenceKind: 'references' as const,
  470. line: 1,
  471. column: 1,
  472. filePath: 'a.ts',
  473. language: 'typescript' as const,
  474. };
  475. expect(nestjsResolver.resolve(ref, baseContext as any)).toBeNull();
  476. });
  477. });
  478. import { laravelResolver } from '../src/resolution/frameworks/laravel';
  479. describe('laravelResolver.extract', () => {
  480. it('extracts route with controller tuple syntax', () => {
  481. const src = `Route::get('/users', [UserController::class, 'index']);\n`;
  482. const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
  483. expect(nodes[0].name).toBe('GET /users');
  484. expect(references[0].referenceName).toBe('UserController@index');
  485. });
  486. it('extracts route with Controller@action syntax', () => {
  487. const src = `Route::post('/users', 'UserController@store');\n`;
  488. const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
  489. expect(references[0].referenceName).toBe('UserController@store');
  490. });
  491. it('extracts resource route', () => {
  492. const src = `Route::resource('users', UserController::class);\n`;
  493. const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
  494. expect(nodes[0].kind).toBe('route');
  495. expect(references[0].referenceName).toBe('UserController');
  496. });
  497. });
  498. import { railsResolver } from '../src/resolution/frameworks/ruby';
  499. describe('railsResolver.extract', () => {
  500. it('extracts route with controller#action syntax', () => {
  501. const src = `get '/users', to: 'users#index'\n`;
  502. const { nodes, references } = railsResolver.extract!('config/routes.rb', src);
  503. expect(nodes[0].name).toBe('GET /users');
  504. expect(references[0].referenceName).toBe('users#index');
  505. });
  506. it('extracts route without to: keyword', () => {
  507. const src = `post '/items' => 'items#create'\n`;
  508. const { nodes, references } = railsResolver.extract!('config/routes.rb', src);
  509. expect(references[0].referenceName).toBe('items#create');
  510. });
  511. });
  512. import { springResolver } from '../src/resolution/frameworks/java';
  513. describe('springResolver.extract', () => {
  514. it('extracts route with @GetMapping and next method', () => {
  515. const src = `
  516. @GetMapping("/users")
  517. public List<User> listUsers() {
  518. return users;
  519. }
  520. `;
  521. const { nodes, references } = springResolver.extract!('UserController.java', src);
  522. expect(nodes[0].name).toBe('GET /users');
  523. expect(references[0].referenceName).toBe('listUsers');
  524. });
  525. it('extracts a Kotlin @GetMapping with a fun handler', () => {
  526. const src = `
  527. @GetMapping("/vets")
  528. fun showVetList(model: MutableMap<String, Any>): String {
  529. return "vets"
  530. }
  531. `;
  532. const { nodes, references } = springResolver.extract!('VetController.kt', src);
  533. expect(nodes[0].name).toBe('GET /vets');
  534. expect(references[0].referenceName).toBe('showVetList');
  535. expect(nodes[0].language).toBe('kotlin');
  536. });
  537. it('joins a Kotlin class @RequestMapping prefix and skips a stacked annotation', () => {
  538. const src = `
  539. @RestController
  540. @RequestMapping("/owners")
  541. class OwnerController {
  542. @GetMapping("/{ownerId}")
  543. @ResponseBody
  544. fun showOwner(@PathVariable ownerId: Int): String {
  545. return "owner"
  546. }
  547. }
  548. `;
  549. const { nodes, references } = springResolver.extract!('OwnerController.kt', src);
  550. expect(nodes[0].name).toBe('GET /owners/{ownerId}');
  551. expect(references[0].referenceName).toBe('showOwner');
  552. });
  553. });
  554. import { playResolver } from '../src/resolution/frameworks/play';
  555. import { isSourceFile, isPlayRoutesFile } from '../src/extraction/grammars';
  556. describe('playResolver.extract (conf/routes)', () => {
  557. it('extracts METHOD /path Controller.action routes, dropping the package + args', () => {
  558. const src = `# Routes
  559. GET / controllers.Application.index
  560. GET /computers controllers.Application.list(p: Int ?= 0, s: Int ?= 2)
  561. POST /computers controllers.Application.save
  562. -> /v1/posts v1.post.PostRouter
  563. `;
  564. const { nodes, references } = playResolver.extract!('conf/routes', src);
  565. expect(nodes.map((n) => n.name)).toEqual([
  566. 'GET /',
  567. 'GET /computers',
  568. 'POST /computers',
  569. ]); // the `->` include is skipped
  570. expect(references.map((r) => r.referenceName)).toEqual([
  571. 'Application.index',
  572. 'Application.list',
  573. 'Application.save',
  574. ]);
  575. });
  576. it('only runs on Play routes files', () => {
  577. expect(playResolver.extract!('app/Foo.scala', 'GET / controllers.X.y').nodes).toHaveLength(0);
  578. });
  579. });
  580. describe('Play routes file detection', () => {
  581. it('recognizes conf/routes (extensionless) and *.routes as source files', () => {
  582. expect(isPlayRoutesFile('conf/routes')).toBe(true);
  583. expect(isPlayRoutesFile('myapp/conf/routes')).toBe(true);
  584. expect(isPlayRoutesFile('conf/admin.routes')).toBe(true);
  585. expect(isSourceFile('conf/routes')).toBe(true);
  586. expect(isPlayRoutesFile('src/routes.ts')).toBe(false);
  587. });
  588. });
  589. import { goResolver } from '../src/resolution/frameworks/go';
  590. describe('goResolver.extract', () => {
  591. it('extracts route from r.GET', () => {
  592. const src = `r.GET("/users", listUsers)\n`;
  593. const { nodes, references } = goResolver.extract!('main.go', src);
  594. expect(nodes[0].name).toBe('GET /users');
  595. expect(references[0].referenceName).toBe('listUsers');
  596. });
  597. it('extracts route from router.HandleFunc', () => {
  598. const src = `router.HandleFunc("/items", createItem)\n`;
  599. const { nodes, references } = goResolver.extract!('main.go', src);
  600. expect(references[0].referenceName).toBe('createItem');
  601. });
  602. it('extracts gorilla/mux HandleFunc on a subrouter var, ignoring chained .Methods()', () => {
  603. // `s` is a PathPrefix().Subrouter() var — any receiver is matched; the
  604. // trailing .Methods("GET") doesn't break the handler capture.
  605. const src = `s.HandleFunc("/users/{id}", listUsers).Methods("GET")\n`;
  606. const { references } = goResolver.extract!('routes.go', src);
  607. expect(references[0].referenceName).toBe('listUsers');
  608. });
  609. });
  610. import { rustResolver } from '../src/resolution/frameworks/rust';
  611. describe('rustResolver.extract', () => {
  612. it('extracts route from axum .route with get()', () => {
  613. const src = `let app = Router::new().route("/users", get(list_users));\n`;
  614. const { nodes, references } = rustResolver.extract!('main.rs', src);
  615. expect(nodes[0].name).toBe('GET /users');
  616. expect(references[0].referenceName).toBe('list_users');
  617. });
  618. it('extracts every method from a chained axum .route (get().put())', () => {
  619. const src = `let app = Router::new().route("/user", get(get_current_user).put(update_user));\n`;
  620. const { nodes, references } = rustResolver.extract!('main.rs', src);
  621. expect(nodes.map((n) => n.name)).toEqual(['GET /user', 'PUT /user']);
  622. expect(references.map((r) => r.referenceName)).toEqual([
  623. 'get_current_user',
  624. 'update_user',
  625. ]);
  626. });
  627. it('extracts a multi-line axum .route with a namespaced handler', () => {
  628. const src = `
  629. let app = Router::new()
  630. .route(
  631. "/articles/feed",
  632. get(listing::feed_articles),
  633. );
  634. `;
  635. const { nodes, references } = rustResolver.extract!('main.rs', src);
  636. expect(nodes[0].name).toBe('GET /articles/feed');
  637. expect(references[0].referenceName).toBe('feed_articles');
  638. });
  639. it('extracts actix web::resource().route(web::METHOD().to(handler))', () => {
  640. const src = `App::new().service(web::resource("/user/{id}").route(web::get().to(get_user)))\n`;
  641. const { nodes, references } = rustResolver.extract!('main.rs', src);
  642. expect(nodes[0].name).toBe('GET /user/{id}');
  643. expect(references[0].referenceName).toBe('get_user');
  644. });
  645. it('extracts actix web::resource("/").to(handler) (all methods)', () => {
  646. const src = `App::new().service(web::resource("/").to(index))\n`;
  647. const { nodes, references } = rustResolver.extract!('main.rs', src);
  648. expect(nodes[0].name).toBe('ANY /');
  649. expect(references[0].referenceName).toBe('index');
  650. });
  651. it('extracts actix App-level .route("/path", web::METHOD().to(handler))', () => {
  652. const src = `App::new().route("/health", web::get().to(health_check))\n`;
  653. const { nodes, references } = rustResolver.extract!('main.rs', src);
  654. expect(nodes[0].name).toBe('GET /health');
  655. expect(references[0].referenceName).toBe('health_check');
  656. });
  657. });
  658. describe('rustResolver.resolve cargo workspace crates', () => {
  659. it('resolves crate name from workspace member lib.rs', () => {
  660. const workspaceCargo = `
  661. [workspace]
  662. members = ["crates/mytool-core", "crates/mytool-fetcher"]
  663. `;
  664. const coreCargo = `
  665. [package]
  666. name = "mytool-core"
  667. version = "0.1.0"
  668. `;
  669. const libNode: Node = {
  670. id: 'module:crates/mytool-core/src/lib.rs:mytool_core:1',
  671. kind: 'module',
  672. name: 'mytool_core',
  673. qualifiedName: 'crates/mytool-core/src/lib.rs::mytool_core',
  674. filePath: 'crates/mytool-core/src/lib.rs',
  675. language: 'rust',
  676. startLine: 1,
  677. endLine: 1,
  678. startColumn: 0,
  679. endColumn: 0,
  680. updatedAt: Date.now(),
  681. };
  682. const context = {
  683. getNodesInFile: (fp: string) => (fp === 'crates/mytool-core/src/lib.rs' ? [libNode] : []),
  684. getNodesByName: () => [],
  685. getNodesByQualifiedName: () => [],
  686. getNodesByKind: () => [],
  687. fileExists: (p: string) => (
  688. p === 'Cargo.toml' ||
  689. p === 'crates/mytool-core/Cargo.toml' ||
  690. p === 'crates/mytool-core/src/lib.rs'
  691. ),
  692. readFile: (p: string) => {
  693. if (p === 'Cargo.toml') return workspaceCargo;
  694. if (p === 'crates/mytool-core/Cargo.toml') return coreCargo;
  695. return null;
  696. },
  697. getProjectRoot: () => '/test',
  698. getAllFiles: () => [
  699. 'Cargo.toml',
  700. 'crates/mytool-core/Cargo.toml',
  701. 'crates/mytool-core/src/lib.rs',
  702. ],
  703. getNodesByLowerName: () => [],
  704. getImportMappings: () => [],
  705. };
  706. const ref = {
  707. fromNodeId: 'fn:crates/mytool-fetcher/src/main.rs:main:1',
  708. referenceName: 'mytool_core',
  709. referenceKind: 'references' as const,
  710. line: 1,
  711. column: 1,
  712. filePath: 'crates/mytool-fetcher/src/main.rs',
  713. language: 'rust' as const,
  714. };
  715. const result = rustResolver.resolve(ref, context);
  716. expect(result?.targetNodeId).toBe(libNode.id);
  717. expect(result?.resolvedBy).toBe('framework');
  718. // Workspace-manifest hits are unambiguous and must beat name-matcher's
  719. // self-file matches (0.7) so cross-crate `imports` edges materialize.
  720. expect(result?.confidence).toBeGreaterThanOrEqual(0.9);
  721. });
  722. it('resolves crate name from workspace member main.rs when lib.rs is absent', () => {
  723. const workspaceCargo = `
  724. [workspace]
  725. members = [
  726. "crates/mytool-runner",
  727. ]
  728. `;
  729. const runnerCargo = `
  730. [package]
  731. name = "mytool-runner"
  732. version = "0.1.0"
  733. `;
  734. const mainNode: Node = {
  735. id: 'module:crates/mytool-runner/src/main.rs:mytool_runner:1',
  736. kind: 'module',
  737. name: 'mytool_runner',
  738. qualifiedName: 'crates/mytool-runner/src/main.rs::mytool_runner',
  739. filePath: 'crates/mytool-runner/src/main.rs',
  740. language: 'rust',
  741. startLine: 1,
  742. endLine: 1,
  743. startColumn: 0,
  744. endColumn: 0,
  745. updatedAt: Date.now(),
  746. };
  747. const context = {
  748. getNodesInFile: (fp: string) => (fp === 'crates/mytool-runner/src/main.rs' ? [mainNode] : []),
  749. getNodesByName: () => [],
  750. getNodesByQualifiedName: () => [],
  751. getNodesByKind: () => [],
  752. fileExists: (p: string) => (
  753. p === 'Cargo.toml' ||
  754. p === 'crates/mytool-runner/Cargo.toml' ||
  755. p === 'crates/mytool-runner/src/main.rs'
  756. ),
  757. readFile: (p: string) => {
  758. if (p === 'Cargo.toml') return workspaceCargo;
  759. if (p === 'crates/mytool-runner/Cargo.toml') return runnerCargo;
  760. return null;
  761. },
  762. getProjectRoot: () => '/test',
  763. getAllFiles: () => [
  764. 'Cargo.toml',
  765. 'crates/mytool-runner/Cargo.toml',
  766. 'crates/mytool-runner/src/main.rs',
  767. ],
  768. getNodesByLowerName: () => [],
  769. getImportMappings: () => [],
  770. };
  771. const ref = {
  772. fromNodeId: 'fn:crates/mytool-runner/src/main.rs:main:1',
  773. referenceName: 'mytool_runner',
  774. referenceKind: 'references' as const,
  775. line: 1,
  776. column: 1,
  777. filePath: 'crates/mytool-runner/src/main.rs',
  778. language: 'rust' as const,
  779. };
  780. const result = rustResolver.resolve(ref, context);
  781. expect(result?.targetNodeId).toBe(mainNode.id);
  782. expect(result?.resolvedBy).toBe('framework');
  783. });
  784. it('resolves crate name when members uses a glob (crates/*)', () => {
  785. const workspaceCargo = `
  786. [workspace]
  787. members = ["crates/*"]
  788. `;
  789. const fooCargo = `
  790. [package]
  791. name = "mytool-foo"
  792. version = "0.1.0"
  793. `;
  794. const barCargo = `
  795. [package]
  796. name = "mytool-bar"
  797. version = "0.1.0"
  798. `;
  799. const fooLib: Node = {
  800. id: 'module:crates/mytool-foo/src/lib.rs:mytool_foo:1',
  801. kind: 'module',
  802. name: 'mytool_foo',
  803. qualifiedName: 'crates/mytool-foo/src/lib.rs::mytool_foo',
  804. filePath: 'crates/mytool-foo/src/lib.rs',
  805. language: 'rust',
  806. startLine: 1,
  807. endLine: 1,
  808. startColumn: 0,
  809. endColumn: 0,
  810. updatedAt: Date.now(),
  811. };
  812. const barLib: Node = {
  813. id: 'module:crates/mytool-bar/src/lib.rs:mytool_bar:1',
  814. kind: 'module',
  815. name: 'mytool_bar',
  816. qualifiedName: 'crates/mytool-bar/src/lib.rs::mytool_bar',
  817. filePath: 'crates/mytool-bar/src/lib.rs',
  818. language: 'rust',
  819. startLine: 1,
  820. endLine: 1,
  821. startColumn: 0,
  822. endColumn: 0,
  823. updatedAt: Date.now(),
  824. };
  825. const filesByPath: Record<string, string> = {
  826. 'Cargo.toml': workspaceCargo,
  827. 'crates/mytool-foo/Cargo.toml': fooCargo,
  828. 'crates/mytool-bar/Cargo.toml': barCargo,
  829. };
  830. const nodesByFile: Record<string, Node[]> = {
  831. 'crates/mytool-foo/src/lib.rs': [fooLib],
  832. 'crates/mytool-bar/src/lib.rs': [barLib],
  833. };
  834. const dirsByPath: Record<string, string[]> = {
  835. '.': ['crates'],
  836. crates: ['mytool-foo', 'mytool-bar'],
  837. 'crates/mytool-foo': ['src'],
  838. 'crates/mytool-bar': ['src'],
  839. };
  840. const context = {
  841. getNodesInFile: (fp: string) => nodesByFile[fp] ?? [],
  842. getNodesByName: () => [],
  843. getNodesByQualifiedName: () => [],
  844. getNodesByKind: () => [],
  845. fileExists: (p: string) => (
  846. Object.prototype.hasOwnProperty.call(filesByPath, p) ||
  847. Object.prototype.hasOwnProperty.call(nodesByFile, p)
  848. ),
  849. readFile: (p: string) => filesByPath[p] ?? null,
  850. getProjectRoot: () => '/test',
  851. getAllFiles: () => [
  852. 'Cargo.toml',
  853. ...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'),
  854. ...Object.keys(nodesByFile),
  855. ],
  856. getNodesByLowerName: () => [],
  857. getImportMappings: () => [],
  858. listDirectories: (rel: string) => dirsByPath[rel] ?? [],
  859. };
  860. const fooRef = {
  861. fromNodeId: 'fn:crates/mytool-bar/src/lib.rs:other:1',
  862. referenceName: 'mytool_foo',
  863. referenceKind: 'references' as const,
  864. line: 1,
  865. column: 1,
  866. filePath: 'crates/mytool-bar/src/lib.rs',
  867. language: 'rust' as const,
  868. };
  869. const barRef = {
  870. fromNodeId: 'fn:crates/mytool-foo/src/lib.rs:other:1',
  871. referenceName: 'mytool_bar',
  872. referenceKind: 'references' as const,
  873. line: 1,
  874. column: 1,
  875. filePath: 'crates/mytool-foo/src/lib.rs',
  876. language: 'rust' as const,
  877. };
  878. expect(rustResolver.resolve(fooRef, context)?.targetNodeId).toBe(fooLib.id);
  879. expect(rustResolver.resolve(barRef, context)?.targetNodeId).toBe(barLib.id);
  880. });
  881. it('resolves crate name when members uses a name glob at root (helix-*)', () => {
  882. const workspaceCargo = `
  883. [workspace]
  884. members = ["helix-*"]
  885. `;
  886. const coreCargo = `
  887. [package]
  888. name = "helix-core"
  889. version = "0.1.0"
  890. `;
  891. const coreLib: Node = {
  892. id: 'module:helix-core/src/lib.rs:helix_core:1',
  893. kind: 'module',
  894. name: 'helix_core',
  895. qualifiedName: 'helix-core/src/lib.rs::helix_core',
  896. filePath: 'helix-core/src/lib.rs',
  897. language: 'rust',
  898. startLine: 1,
  899. endLine: 1,
  900. startColumn: 0,
  901. endColumn: 0,
  902. updatedAt: Date.now(),
  903. };
  904. const filesByPath: Record<string, string> = {
  905. 'Cargo.toml': workspaceCargo,
  906. 'helix-core/Cargo.toml': coreCargo,
  907. };
  908. const nodesByFile: Record<string, Node[]> = {
  909. 'helix-core/src/lib.rs': [coreLib],
  910. };
  911. const dirsByPath: Record<string, string[]> = {
  912. '.': ['helix-core', 'docs', 'target'],
  913. 'helix-core': ['src'],
  914. };
  915. const context = {
  916. getNodesInFile: (fp: string) => nodesByFile[fp] ?? [],
  917. getNodesByName: () => [],
  918. getNodesByQualifiedName: () => [],
  919. getNodesByKind: () => [],
  920. fileExists: (p: string) => (
  921. Object.prototype.hasOwnProperty.call(filesByPath, p) ||
  922. Object.prototype.hasOwnProperty.call(nodesByFile, p)
  923. ),
  924. readFile: (p: string) => filesByPath[p] ?? null,
  925. getProjectRoot: () => '/test',
  926. getAllFiles: () => [
  927. 'Cargo.toml',
  928. ...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'),
  929. ...Object.keys(nodesByFile),
  930. ],
  931. getNodesByLowerName: () => [],
  932. getImportMappings: () => [],
  933. listDirectories: (rel: string) => dirsByPath[rel] ?? [],
  934. };
  935. const ref = {
  936. fromNodeId: 'fn:helix-core/src/lib.rs:other:1',
  937. referenceName: 'helix_core',
  938. referenceKind: 'references' as const,
  939. line: 1,
  940. column: 1,
  941. filePath: 'helix-core/src/lib.rs',
  942. language: 'rust' as const,
  943. };
  944. expect(rustResolver.resolve(ref, context)?.targetNodeId).toBe(coreLib.id);
  945. });
  946. });
  947. import { aspnetResolver } from '../src/resolution/frameworks/csharp';
  948. describe('aspnetResolver.extract', () => {
  949. it('extracts route from [HttpGet] attribute', () => {
  950. const src = `
  951. [HttpGet("/users")]
  952. public IActionResult ListUsers()
  953. {
  954. return Ok();
  955. }
  956. `;
  957. const { nodes, references } = aspnetResolver.extract!('UserController.cs', src);
  958. expect(nodes[0].name).toBe('GET /users');
  959. expect(references[0].referenceName).toBe('ListUsers');
  960. });
  961. });
  962. import { vaporResolver } from '../src/resolution/frameworks/swift';
  963. describe('vaporResolver.extract', () => {
  964. it('extracts route from app.get with use:', () => {
  965. const src = `app.get("users", use: listUsers)\n`;
  966. const { nodes, references } = vaporResolver.extract!('routes.swift', src);
  967. expect(nodes[0].name).toBe('GET /users');
  968. expect(references[0].referenceName).toBe('listUsers');
  969. });
  970. it('extracts grouped RouteCollection routes with the group prefix and no path arg', () => {
  971. const src = `
  972. func boot(routes: RoutesBuilder) throws {
  973. let todos = routes.grouped("todos")
  974. todos.get(use: index)
  975. todos.post(use: create)
  976. todos.group(":todoID") { todo in
  977. todo.delete(use: delete)
  978. }
  979. }
  980. `;
  981. const { nodes, references } = vaporResolver.extract!('TodoController.swift', src);
  982. expect(nodes.map((n) => n.name).sort()).toEqual([
  983. 'DELETE /todos/:todoID',
  984. 'GET /todos',
  985. 'POST /todos',
  986. ]);
  987. expect(references.map((r) => r.referenceName).sort()).toEqual([
  988. 'create',
  989. 'delete',
  990. 'index',
  991. ]);
  992. });
  993. it('handles use: self.handler and non-string path segments', () => {
  994. const src = `router.get("users", User.parameter, "edit", use: self.editUserHandler)\n`;
  995. const { nodes, references } = vaporResolver.extract!('UserController.swift', src);
  996. expect(nodes[0].name).toBe('GET /users/edit');
  997. expect(references[0].referenceName).toBe('editUserHandler');
  998. });
  999. it('ignores non-route .get calls that lack use: (e.g. Environment.get)', () => {
  1000. const src = `let host = Environment.get("DATABASE_HOST") ?? "localhost"\n`;
  1001. const { nodes } = vaporResolver.extract!('configure.swift', src);
  1002. expect(nodes).toHaveLength(0);
  1003. });
  1004. });
  1005. import { reactResolver } from '../src/resolution/frameworks/react';
  1006. import { svelteResolver } from '../src/resolution/frameworks/svelte';
  1007. describe('reactResolver.extract — React Router', () => {
  1008. it('extracts a v6 <Route path element={<Comp/>}>', () => {
  1009. const src = `<Route path="/users" element={<UsersPage/>}/>`;
  1010. const { nodes, references } = reactResolver.extract!('App.tsx', src);
  1011. const route = nodes.find((n) => n.kind === 'route');
  1012. expect(route?.name).toBe('/users');
  1013. expect(references[0]?.referenceName).toBe('UsersPage');
  1014. });
  1015. it('extracts a v5 <Route path component={Comp}> with attributes in any order', () => {
  1016. const src = `<Route exact path="/login" component={Login} />`;
  1017. const { nodes, references } = reactResolver.extract!('App.jsx', src);
  1018. const route = nodes.find((n) => n.kind === 'route');
  1019. expect(route?.name).toBe('/login');
  1020. expect(references[0]?.referenceName).toBe('Login');
  1021. });
  1022. it('does not treat the <Routes> container as a route', () => {
  1023. const src = `<Routes><Route path="/x" element={<X/>}/></Routes>`;
  1024. const routes = reactResolver.extract!('App.tsx', src).nodes.filter((n) => n.kind === 'route');
  1025. expect(routes).toHaveLength(1);
  1026. expect(routes[0]?.name).toBe('/x');
  1027. });
  1028. it('extracts createBrowserRouter object routes ({ path, element/Component })', () => {
  1029. const src = `const router = createBrowserRouter([
  1030. { path: "/dashboard", element: <Dashboard /> },
  1031. { path: "/login", Component: Login },
  1032. ]);`;
  1033. const { nodes, references } = reactResolver.extract!('router.tsx', src);
  1034. const routes = nodes.filter((n) => n.kind === 'route');
  1035. expect(routes.map((n) => n.name).sort()).toEqual(['/dashboard', '/login']);
  1036. expect(references.map((r) => r.referenceName).sort()).toEqual(['Dashboard', 'Login']);
  1037. });
  1038. it('does not treat config files or a nextjs-pages dir as Next.js routes', () => {
  1039. const cfg = reactResolver.extract!('apps/nextjs-pages/next.config.mjs', 'export default {}');
  1040. expect(cfg.nodes.filter((n) => n.kind === 'route')).toHaveLength(0);
  1041. const vite = reactResolver.extract!('src/pages/vite.config.ts', 'export default {}');
  1042. expect(vite.nodes.filter((n) => n.kind === 'route')).toHaveLength(0);
  1043. // a real page still works
  1044. const page = reactResolver.extract!('src/pages/about.tsx', 'export default function About(){return null}');
  1045. expect(page.nodes.filter((n) => n.kind === 'route').map((n) => n.name)).toEqual(['/about']);
  1046. });
  1047. });
  1048. describe('svelteResolver.extract (smoke)', () => {
  1049. it('returns { nodes, references } shape', () => {
  1050. const result = svelteResolver.extract!('+page.svelte', '');
  1051. expect(result).toHaveProperty('nodes');
  1052. expect(result).toHaveProperty('references');
  1053. });
  1054. });
  1055. // Regression tests: commented-out and docstring route examples must NOT
  1056. // surface as phantom route nodes. These would have failed before the
  1057. // strip-comments wiring (the regex would happily scan comments/docstrings).
  1058. describe('framework extractors ignore commented-out routes', () => {
  1059. it('django: skips line-comment and docstring routes', () => {
  1060. const src = `
  1061. # urls.py example:
  1062. # path('/admin/', AdminPanel.as_view())
  1063. """
  1064. Other routing example:
  1065. path('/users/', UserListView.as_view())
  1066. """
  1067. urlpatterns = [path('/real/', RealView.as_view())]
  1068. `;
  1069. const result = djangoResolver.extract!('app/urls.py', src);
  1070. const urls = result.nodes.map((n) => n.name);
  1071. expect(urls).toEqual(['/real/']);
  1072. });
  1073. it('flask: skips commented-out @app.route', () => {
  1074. const src = `
  1075. # @app.route('/fake')
  1076. # def fake_view():
  1077. # return ''
  1078. @app.route('/real')
  1079. def real_view():
  1080. return ''
  1081. `;
  1082. const { nodes, references } = flaskResolver.extract!('app.py', src);
  1083. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1084. expect(references.map((r) => r.referenceName)).toEqual(['real_view']);
  1085. });
  1086. it('fastapi: skips docstring example routes', () => {
  1087. const src = `
  1088. """
  1089. Example:
  1090. @app.get('/in-docstring')
  1091. async def doc():
  1092. pass
  1093. """
  1094. @app.get('/real')
  1095. async def real_handler():
  1096. return {}
  1097. `;
  1098. const { nodes, references } = fastapiResolver.extract!('main.py', src);
  1099. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1100. expect(references.map((r) => r.referenceName)).toEqual(['real_handler']);
  1101. });
  1102. it('express: skips // and /* */ commented routes', () => {
  1103. const src = `
  1104. // app.get('/fake', fakeHandler);
  1105. /* router.post('/also-fake', otherHandler); */
  1106. app.get('/real', realHandler);
  1107. `;
  1108. const { nodes, references } = expressResolver.extract!('routes.ts', src);
  1109. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1110. expect(references.map((r) => r.referenceName)).toEqual(['realHandler']);
  1111. });
  1112. it('laravel: skips // # and /* */ commented Route::* calls', () => {
  1113. const src = `<?php
  1114. // Route::get('/fake', [FakeController::class, 'index']);
  1115. # Route::get('/also-fake', 'FakeController@show');
  1116. /* Route::post('/another-fake', [X::class, 'y']); */
  1117. Route::get('/real', [RealController::class, 'index']);
  1118. `;
  1119. const { nodes, references } = laravelResolver.extract!('routes/web.php', src);
  1120. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1121. expect(references.map((r) => r.referenceName)).toEqual(['RealController@index']);
  1122. });
  1123. it('rails: skips =begin/=end and # commented routes', () => {
  1124. const src = `
  1125. # get '/fake', to: 'fake#index'
  1126. =begin
  1127. get '/also-fake', to: 'fake#show'
  1128. =end
  1129. get '/real', to: 'real#index'
  1130. `;
  1131. const { nodes, references } = railsResolver.extract!('config/routes.rb', src);
  1132. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1133. expect(references.map((r) => r.referenceName)).toEqual(['real#index']);
  1134. });
  1135. it('spring: skips // and /* */ commented @GetMapping', () => {
  1136. const src = `
  1137. // @GetMapping("/fake")
  1138. // public List<X> fake() { return null; }
  1139. /* @PostMapping("/also-fake")
  1140. public void alsoFake() {} */
  1141. @GetMapping("/real")
  1142. public List<User> listUsers() { return users; }
  1143. `;
  1144. const { nodes, references } = springResolver.extract!('UserController.java', src);
  1145. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1146. expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
  1147. });
  1148. it('go: skips // and /* */ commented router.METHOD calls', () => {
  1149. const src = `
  1150. // r.GET("/fake", fakeHandler)
  1151. /* r.POST("/also-fake", anotherHandler) */
  1152. r.GET("/real", listUsers)
  1153. `;
  1154. const { nodes, references } = goResolver.extract!('main.go', src);
  1155. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1156. expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
  1157. });
  1158. it('rust: skips // and nested /* */ commented .route() calls', () => {
  1159. const src = `
  1160. // .route("/fake", get(fake_handler))
  1161. /* outer /* inner .route("/inner-fake", get(x)) */ still .route("/outer-fake", get(y)) */
  1162. let app = Router::new().route("/real", get(list_users));
  1163. `;
  1164. const { nodes, references } = rustResolver.extract!('main.rs', src);
  1165. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1166. expect(references.map((r) => r.referenceName)).toEqual(['list_users']);
  1167. });
  1168. it('aspnet: skips // and /* */ commented [HttpGet] attributes', () => {
  1169. const src = `
  1170. // [HttpGet("/fake")]
  1171. // public IActionResult Fake() { return Ok(); }
  1172. /* [HttpPost("/also-fake")]
  1173. public IActionResult AlsoFake() { return Ok(); } */
  1174. [HttpGet("/real")]
  1175. public IActionResult ListUsers() { return Ok(); }
  1176. `;
  1177. const { nodes, references } = aspnetResolver.extract!('UserController.cs', src);
  1178. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1179. expect(references.map((r) => r.referenceName)).toEqual(['ListUsers']);
  1180. });
  1181. it('vapor: skips // and /* */ commented app.METHOD calls', () => {
  1182. const src = `
  1183. // app.get("fake", use: fakeHandler)
  1184. /* app.post("also-fake", use: anotherHandler) */
  1185. app.get("real", use: listUsers)
  1186. `;
  1187. const { nodes, references } = vaporResolver.extract!('routes.swift', src);
  1188. expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
  1189. expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
  1190. });
  1191. it('nestjs: skips // and /* */ commented decorators', () => {
  1192. const src = `
  1193. @Controller('users')
  1194. export class UsersController {
  1195. // @Get('fake')
  1196. // fake() {}
  1197. /* @Post('also-fake')
  1198. alsoFake() {} */
  1199. @Get('real')
  1200. real() {}
  1201. }
  1202. `;
  1203. const { nodes, references } = nestjsResolver.extract!('users.controller.ts', src);
  1204. expect(nodes.map((n) => n.name)).toEqual(['GET /users/real']);
  1205. expect(references.map((r) => r.referenceName)).toEqual(['real']);
  1206. });
  1207. });