1
0

frameworks.test.ts 35 KB

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