react.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. /**
  2. * React Framework Resolver
  3. *
  4. * Handles React and Next.js patterns.
  5. */
  6. import { Node } from '../../types';
  7. import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
  8. import { dependsOn } from './package-deps';
  9. export const reactResolver: FrameworkResolver = {
  10. name: 'react',
  11. // Includes 'tsx'/'jsx' so route extraction runs on JSX files (where
  12. // `<Route element={<X/>}>` routes live) — without them the .tsx/.jsx grammars
  13. // were filtered out of the extract pass and those routes were never indexed.
  14. // (resolve() is unaffected — it runs for every detected framework regardless
  15. // of language; only the extract pass filters on `languages`.)
  16. languages: ['javascript', 'typescript', 'tsx', 'jsx'],
  17. detect(context: ResolutionContext): boolean {
  18. // React in a package.json — the root's, or a workspace's (`frontend/`, `apps/web/`).
  19. if (dependsOn(context, 'react', 'next', 'react-native')) return true;
  20. // Check for .jsx/.tsx files
  21. const allFiles = context.getAllFiles();
  22. return allFiles.some((f) => f.endsWith('.jsx') || f.endsWith('.tsx'));
  23. },
  24. resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
  25. // Pattern 1: Component references (PascalCase). Only from JSX-capable
  26. // files — a component is USED in markup, which only parses in .tsx/.jsx.
  27. // Without this gate, every PascalCase TYPE reference in plain .ts files
  28. // went through component resolution: in a monorepo with same-named
  29. // classes per package (#764, amplication), a `.ts` GraphQL-types file's
  30. // own `Account` type alias lost to an arbitrary `Account` CLASS in
  31. // another package (the framework's 0.8 outranked the name-matcher's
  32. // proximity-correct 0.7).
  33. if (
  34. (ref.language === 'tsx' || ref.language === 'jsx') &&
  35. isPascalCase(ref.referenceName) &&
  36. !isBuiltInType(ref.referenceName)
  37. ) {
  38. const result = resolveComponent(ref.referenceName, ref.filePath, context);
  39. if (result) {
  40. return {
  41. original: ref,
  42. targetNodeId: result,
  43. confidence: 0.8,
  44. resolvedBy: 'framework',
  45. };
  46. }
  47. }
  48. // Pattern 2: Hook references (use*)
  49. if (ref.referenceName.startsWith('use') && ref.referenceName.length > 3) {
  50. const result = resolveHook(ref.referenceName, context);
  51. if (result) {
  52. return {
  53. original: ref,
  54. targetNodeId: result,
  55. confidence: 0.85,
  56. resolvedBy: 'framework',
  57. };
  58. }
  59. }
  60. // Pattern 3: Context references
  61. if (ref.referenceName.endsWith('Context') || ref.referenceName.endsWith('Provider')) {
  62. const result = resolveContext(ref.referenceName, context);
  63. if (result) {
  64. return {
  65. original: ref,
  66. targetNodeId: result,
  67. confidence: 0.8,
  68. resolvedBy: 'framework',
  69. };
  70. }
  71. }
  72. return null;
  73. },
  74. extract(filePath, content) {
  75. const nodes: Node[] = [];
  76. const references: UnresolvedRef[] = [];
  77. const now = Date.now();
  78. // Components and custom hooks are NOT extracted here. The tree-sitter
  79. // extractor already emits them natively across .ts/.tsx/.js/.jsx — function
  80. // and arrow components as `function` nodes, HOC-wrapped components
  81. // (`forwardRef`/`memo`/`styled`) as `component` nodes (#841), and `useX`
  82. // hooks as `function` nodes. Re-deriving them here with regex only ran on
  83. // .ts/.js anyway (this resolver's `languages` didn't include the 'tsx'/'jsx'
  84. // grammars), and it DUPLICATED those tree-sitter nodes (e.g. a `useAuth`
  85. // ended up as two `function` nodes). This `extract` now contributes only
  86. // what tree-sitter can't: route nodes (React Router + Next.js conventions),
  87. // which is why 'tsx'/'jsx' are now in `languages` — `<Route>`/`element={<X/>}`
  88. // routes live in JSX files and were previously skipped entirely.
  89. // React Router: <Route path="/x" component={Comp}/> (v5) or
  90. // <Route path="/x" element={<Comp/>}/> (v6). Attributes appear in any order,
  91. // and element={...} contains a nested `>`, so scan a window after each
  92. // <Route rather than trying to match the whole (possibly multi-line) tag.
  93. const routeTagRegex = /<Route\b/g;
  94. let routeMatch: RegExpExecArray | null;
  95. while ((routeMatch = routeTagRegex.exec(content)) !== null) {
  96. const window = content.slice(routeMatch.index, routeMatch.index + 400);
  97. const pathMatch = window.match(/\bpath\s*=\s*["']([^"']+)["']/);
  98. if (!pathMatch) continue; // index/layout routes without a path
  99. const routePath = pathMatch[1]!;
  100. const compMatch =
  101. window.match(/\bcomponent\s*=\s*\{\s*([A-Z][A-Za-z0-9_]*)/) ||
  102. window.match(/\belement\s*=\s*\{\s*<\s*([A-Z][A-Za-z0-9_]*)/);
  103. const line = content.slice(0, routeMatch.index).split('\n').length;
  104. const routeNode: Node = {
  105. id: `route:${filePath}:${line}:${routePath}`,
  106. kind: 'route',
  107. name: routePath,
  108. qualifiedName: `${filePath}::route:${routePath}`,
  109. filePath,
  110. startLine: line,
  111. endLine: line,
  112. startColumn: 0,
  113. endColumn: 0,
  114. language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
  115. updatedAt: now,
  116. };
  117. nodes.push(routeNode);
  118. if (compMatch) {
  119. references.push({
  120. fromNodeId: routeNode.id,
  121. referenceName: compMatch[1]!,
  122. referenceKind: 'references',
  123. line,
  124. column: 0,
  125. filePath,
  126. language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
  127. });
  128. }
  129. }
  130. // React Router data-router (v6.4+): createBrowserRouter([{ path, element }]).
  131. // Only scan files that use the data-router API, then pull each route object's
  132. // `path` + `element={<Comp/>}` / `Component: Comp` (a forward window confirms
  133. // it's a route object, not a stray `path:` field).
  134. if (/\b(?:createBrowserRouter|createHashRouter|createMemoryRouter|createRoutesFromElements)\b/.test(content)) {
  135. const objPathRe = /\bpath\s*:\s*['"]([^'"]*)['"]/g;
  136. let om: RegExpExecArray | null;
  137. while ((om = objPathRe.exec(content)) !== null) {
  138. const win = content.slice(om.index, om.index + 300);
  139. const compMatch =
  140. win.match(/\belement\s*:\s*<\s*([A-Z][A-Za-z0-9_]*)/) ||
  141. win.match(/\bComponent\s*:\s*([A-Z][A-Za-z0-9_]*)/);
  142. if (!compMatch) continue; // require a component → it's a real route object
  143. const routePath = om[1] || '/';
  144. const line = content.slice(0, om.index).split('\n').length;
  145. const routeNode: Node = {
  146. id: `route:${filePath}:${line}:${routePath}`,
  147. kind: 'route',
  148. name: routePath,
  149. qualifiedName: `${filePath}::route:${routePath}`,
  150. filePath,
  151. startLine: line,
  152. endLine: line,
  153. startColumn: 0,
  154. endColumn: 0,
  155. language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
  156. updatedAt: now,
  157. };
  158. nodes.push(routeNode);
  159. references.push({
  160. fromNodeId: routeNode.id,
  161. referenceName: compMatch[1]!,
  162. referenceKind: 'references',
  163. line,
  164. column: 0,
  165. filePath,
  166. language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
  167. });
  168. }
  169. }
  170. // Extract Next.js pages/routes (pages directory convention)
  171. if (filePath.includes('pages/') || filePath.includes('app/')) {
  172. // Default export in pages becomes a route
  173. if (content.includes('export default')) {
  174. const routePath = filePathToRoute(filePath);
  175. if (routePath) {
  176. const line = content.indexOf('export default');
  177. const lineNum = content.slice(0, line).split('\n').length;
  178. nodes.push({
  179. id: `route:${filePath}:${routePath}:${lineNum}`,
  180. kind: 'route',
  181. name: routePath,
  182. qualifiedName: `${filePath}::route:${routePath}`,
  183. filePath,
  184. startLine: lineNum,
  185. endLine: lineNum,
  186. startColumn: 0,
  187. endColumn: 0,
  188. language: filePath.endsWith('.tsx') ? 'tsx' : filePath.endsWith('.ts') ? 'typescript' : 'javascript',
  189. updatedAt: now,
  190. });
  191. }
  192. }
  193. }
  194. return { nodes, references };
  195. },
  196. };
  197. /**
  198. * Check if string is PascalCase
  199. */
  200. function isPascalCase(str: string): boolean {
  201. return /^[A-Z][a-zA-Z0-9]*$/.test(str);
  202. }
  203. /**
  204. * Check if name is a built-in type
  205. */
  206. function isBuiltInType(name: string): boolean {
  207. return BUILT_IN_TYPES.has(name);
  208. }
  209. const BUILT_IN_TYPES = new Set([
  210. 'Array', 'Boolean', 'Date', 'Error', 'Function', 'JSON', 'Math', 'Number',
  211. 'Object', 'Promise', 'RegExp', 'String', 'Symbol', 'Map', 'Set', 'WeakMap', 'WeakSet',
  212. 'React', 'Component', 'Fragment', 'Suspense', 'StrictMode',
  213. ]);
  214. const COMPONENT_KINDS = new Set(['component', 'function', 'class']);
  215. /**
  216. * Resolve a component reference using name-based lookup
  217. */
  218. function resolveComponent(
  219. name: string,
  220. fromFile: string,
  221. context: ResolutionContext
  222. ): string | null {
  223. const candidates = context.getNodesByName(name);
  224. if (candidates.length === 0) return null;
  225. const components = candidates.filter((n) => COMPONENT_KINDS.has(n.kind));
  226. if (components.length === 0) return null;
  227. // Prefer same directory
  228. const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/'));
  229. const sameDir = components.filter((n) => n.filePath.startsWith(fromDir));
  230. if (sameDir.length > 0) return sameDir[0]!.id;
  231. // Prefer component directories
  232. const COMPONENT_DIRS = ['/components/', '/src/components/', '/app/components/', '/pages/', '/src/pages/', '/views/', '/src/views/'];
  233. const preferred = components.filter((n) =>
  234. COMPONENT_DIRS.some((d) => n.filePath.includes(d))
  235. );
  236. if (preferred.length > 0) return preferred[0]!.id;
  237. // No positional signal: only an UNAMBIGUOUS name may resolve. Returning
  238. // components[0] here picked an arbitrary same-named class anywhere in the
  239. // repo (#764) — let the name-matcher's proximity scoring decide instead.
  240. return components.length === 1 ? components[0]!.id : null;
  241. }
  242. /**
  243. * Resolve a custom hook reference using name-based lookup
  244. */
  245. function resolveHook(name: string, context: ResolutionContext): string | null {
  246. const candidates = context.getNodesByName(name);
  247. if (candidates.length === 0) return null;
  248. const hooks = candidates.filter((n) => n.kind === 'function' && n.name.startsWith('use'));
  249. if (hooks.length === 0) return null;
  250. // Prefer hooks directories
  251. const HOOK_DIRS = ['/hooks/', '/src/hooks/', '/lib/hooks/', '/utils/hooks/'];
  252. const preferred = hooks.filter((n) =>
  253. HOOK_DIRS.some((d) => n.filePath.includes(d))
  254. );
  255. if (preferred.length > 0) return preferred[0]!.id;
  256. return hooks[0]!.id;
  257. }
  258. /**
  259. * Resolve a context reference using name-based lookup
  260. */
  261. function resolveContext(name: string, context: ResolutionContext): string | null {
  262. const candidates = context.getNodesByName(name);
  263. if (candidates.length === 0) {
  264. // Try without Context/Provider suffix
  265. const baseName = name.replace(/Context$|Provider$/, '');
  266. if (baseName !== name) {
  267. const baseCandidates = context.getNodesByName(baseName);
  268. if (baseCandidates.length > 0) return baseCandidates[0]!.id;
  269. }
  270. return null;
  271. }
  272. // Prefer context directories
  273. const CONTEXT_DIRS = ['/context/', '/contexts/', '/src/context/', '/src/contexts/', '/providers/', '/src/providers/'];
  274. const preferred = candidates.filter((n) =>
  275. CONTEXT_DIRS.some((d) => n.filePath.includes(d))
  276. );
  277. if (preferred.length > 0) return preferred[0]!.id;
  278. return candidates[0]!.id;
  279. }
  280. /**
  281. * Convert file path to Next.js route
  282. */
  283. function filePathToRoute(filePath: string): string | null {
  284. // pages/index.tsx -> /
  285. // pages/about.tsx -> /about
  286. // pages/blog/[slug].tsx -> /blog/:slug
  287. // app/page.tsx -> /
  288. // app/about/page.tsx -> /about
  289. // Only real page-component files are routes. Exclude non-page extensions
  290. // (.mjs/.json/.cjs), config files (next.config.ts, vite.config.ts…), and
  291. // Next.js special files (_app/_document). This also stops a `*.config.mjs`
  292. // with `export default` in a dir like `nextjs-pages/` from being a "route".
  293. const base = filePath.split('/').pop() ?? '';
  294. if (!/\.(tsx?|jsx?)$/.test(base)) return null;
  295. if (base.startsWith('_') || /\.config\.[a-z]+$/.test(base)) return null;
  296. // Match pages/ and app/ as PATH SEGMENTS (not a substring — `nextjs-pages/`
  297. // must not count as a `pages/` router dir).
  298. if (/(?:^|\/)pages\//.test(filePath)) {
  299. let route = filePath
  300. .replace(/^.*pages\//, '/')
  301. .replace(/\/index\.(tsx?|jsx?)$/, '')
  302. .replace(/\.(tsx?|jsx?)$/, '')
  303. .replace(/\[([^\]]+)\]/g, ':$1');
  304. if (route === '') route = '/';
  305. return route;
  306. }
  307. if (/(?:^|\/)app\//.test(filePath)) {
  308. // App router - only page.tsx files are routes
  309. if (!filePath.includes('page.')) {
  310. return null;
  311. }
  312. let route = filePath
  313. .replace(/^.*app\//, '/')
  314. .replace(/\/page\.(tsx?|jsx?)$/, '')
  315. .replace(/\[([^\]]+)\]/g, ':$1');
  316. if (route === '') route = '/';
  317. return route;
  318. }
  319. return null;
  320. }