Skip to content

Commit ea5945c

Browse files
committed
feat: React pattern detection -- useQuery, cn(), custom hooks, canonical hook, shared types
1 parent 5680ac5 commit ea5945c

6 files changed

Lines changed: 222 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!-- SAAR:AUTO-START -->
22
# CLAUDE.md -- saar
33

4-
282 functions, 47 classes.
4+
289 functions, 48 classes.
55
Async adoption: 20%.
66
Type hint coverage: 92%.
77

saar/extractor.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,8 +838,101 @@ def _has_lockfile(name: str) -> bool:
838838
elif "sass" in combined or "node-sass" in combined:
839839
fp.styling = "Sass/SCSS"
840840

841+
# -- React coding patterns from actual source files --
842+
# scan .tsx/.ts/.jsx files to detect HOW the stack is being used,
843+
# not just WHAT is installed
844+
if fp.framework in ("React", "Next.js"):
845+
self._detect_react_patterns(fp, repo_path)
846+
841847
return fp
842848

849+
def _detect_react_patterns(self, fp, repo_path: Path) -> None:
850+
"""Scan React/TS source files to detect coding patterns.
851+
852+
This goes beyond package.json -- we look at actual usage in source
853+
files to generate rules like "use useQuery, never raw fetch in useEffect"
854+
rather than generic advice.
855+
"""
856+
tsx_files = [
857+
p for p in repo_path.rglob("*.tsx")
858+
if not self._should_skip(p, repo_path)
859+
and "node_modules" not in p.parts
860+
]
861+
ts_files = [
862+
p for p in repo_path.rglob("*.ts")
863+
if not self._should_skip(p, repo_path)
864+
and "node_modules" not in p.parts
865+
and not p.name.endswith(".d.ts")
866+
]
867+
all_fe_files = tsx_files + ts_files
868+
869+
if not all_fe_files:
870+
return
871+
872+
use_query_count = 0
873+
fetch_in_effect_count = 0
874+
cn_usage_count = 0
875+
hook_files = []
876+
custom_hook_imports: dict = {}
877+
878+
for file_path in all_fe_files[:150]: # cap for performance
879+
content = self._safe_read_file(file_path)
880+
if not content:
881+
continue
882+
883+
# useQuery / useMutation from tanstack -- means they use React Query
884+
if re.search(r"\buseQuery\b|\buseMutation\b|\buseInfiniteQuery\b", content):
885+
use_query_count += 1
886+
887+
# raw fetch inside useEffect -- anti-pattern in React Query codebases
888+
if re.search(r"useEffect\s*\(", content) and re.search(r"\bfetch\s*\(", content):
889+
fetch_in_effect_count += 1
890+
891+
# cn() utility for conditional class merging
892+
if re.search(r"\bcn\s*\(", content):
893+
cn_usage_count += 1
894+
895+
# custom hooks in hooks/ directory
896+
if "hooks" in file_path.parts and file_path.name.startswith("use"):
897+
hook_files.append(file_path.stem)
898+
899+
# track which custom hooks are imported across components
900+
for match in re.finditer(r"import\s+\{([^}]+)\}\s+from\s+['\"].*?hooks/([^'\"]+)['\"]", content):
901+
imports = [i.strip() for i in match.group(1).split(",")]
902+
for imp in imports:
903+
if imp.startswith("use"):
904+
custom_hook_imports[imp] = custom_hook_imports.get(imp, 0) + 1
905+
906+
# set flags based on counts
907+
if use_query_count >= 2:
908+
fp.uses_react_query = True
909+
910+
# only flag fetch-in-effect as an anti-pattern if they also use React Query
911+
# -- otherwise it might be intentional
912+
if fetch_in_effect_count == 0 and use_query_count >= 2:
913+
fp.avoids_fetch_in_effect = True
914+
915+
if cn_usage_count >= 3:
916+
fp.uses_cn_utility = True
917+
918+
if hook_files:
919+
fp.has_custom_hooks = True
920+
# find the most-imported custom hook -- canonical example
921+
if custom_hook_imports:
922+
fp.canonical_data_hook = max(custom_hook_imports, key=custom_hook_imports.get)
923+
924+
# check for shared types file
925+
for candidate in ["src/types.ts", "src/types/index.ts", "types.ts", "src/types/index.tsx"]:
926+
if (repo_path / candidate).exists():
927+
fp.shared_types_file = candidate
928+
break
929+
# also check frontend/ subdirectory for monorepos
930+
if not fp.shared_types_file:
931+
for candidate in ["frontend/src/types.ts", "frontend/src/types/index.ts"]:
932+
if (repo_path / candidate).exists():
933+
fp.shared_types_file = candidate
934+
break
935+
843936
def _extract_config_patterns(self, files: List[Path], repo_path: Path) -> ConfigPattern:
844937
pattern = ConfigPattern()
845938

saar/formatters/agents_md.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,18 @@ def render_agents_md(dna: CodebaseDNA) -> str:
115115
test_line += f" (`{fp.test_command}`)"
116116
lines.append(test_line)
117117

118+
# React-specific coding patterns detected from source files
119+
if fp.uses_react_query:
120+
lines.append("- Data fetching: use `useQuery`/`useMutation` -- never raw `fetch` in `useEffect`")
121+
if fp.uses_cn_utility:
122+
lines.append("- Class merging: use `cn()` from `@/lib/utils` for conditional Tailwind classes")
123+
if fp.has_custom_hooks and fp.canonical_data_hook:
124+
lines.append(f"- Custom hooks in `hooks/` -- canonical example: `{fp.canonical_data_hook}`")
125+
elif fp.has_custom_hooks:
126+
lines.append("- Custom hooks live in `hooks/` with `use` prefix")
127+
if fp.shared_types_file:
128+
lines.append(f"- Shared TypeScript interfaces in `{fp.shared_types_file}` -- don't duplicate types inline")
129+
118130
# -- coding conventions --
119131
lines.append("## Coding Conventions\n")
120132
nc = dna.naming_conventions

saar/formatters/claude_md.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ def render_claude_md(dna: CodebaseDNA) -> str:
5353
test_line += f" (`{fp.test_command}`)"
5454
lines.append(test_line)
5555

56+
# React-specific coding patterns detected from source files
57+
if fp.uses_react_query:
58+
lines.append("- Data fetching: use `useQuery`/`useMutation` -- never raw `fetch` in `useEffect`")
59+
if fp.uses_cn_utility:
60+
lines.append("- Class merging: use `cn()` from `@/lib/utils` for conditional Tailwind classes")
61+
if fp.has_custom_hooks and fp.canonical_data_hook:
62+
lines.append(f"- Custom hooks in `hooks/` -- canonical example: `{fp.canonical_data_hook}`")
63+
elif fp.has_custom_hooks:
64+
lines.append("- Custom hooks live in `hooks/` with `use` prefix")
65+
if fp.shared_types_file:
66+
lines.append(f"- Shared TypeScript interfaces in `{fp.shared_types_file}` -- don't duplicate inline")
67+
5668
# -- coding conventions as imperative rules --
5769
lines.append("## Coding Conventions\n")
5870
nc = dna.naming_conventions

saar/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ class FrontendPattern:
107107
package_manager: Optional[str] = None # bun, pnpm, yarn, npm
108108
build_tool: Optional[str] = None # vite, webpack, turbopack
109109
language: Optional[str] = None # typescript, javascript
110+
# React-specific coding patterns (detected from .tsx/.ts source files)
111+
uses_react_query: bool = False # useQuery/useMutation detected in source
112+
avoids_fetch_in_effect: bool = False # no raw fetch inside useEffect
113+
uses_cn_utility: bool = False # cn() from @/lib/utils for class merging
114+
canonical_data_hook: Optional[str] = None # most-used custom data hook
115+
has_custom_hooks: bool = False # hooks/ directory with use* files
116+
shared_types_file: Optional[str] = None # types.ts / types/ directory found
110117

111118

112119
@dataclass

tests/test_frontend_detector.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,100 @@ def test_no_frontend_section_for_pure_python(self):
287287
dna = CodebaseDNA(repo_name="python-only")
288288
out = render_agents_md(dna)
289289
assert "## Frontend" not in out
290+
291+
292+
class TestReactPatternDetection:
293+
294+
def test_detects_react_query_usage(self, tmp_path: Path):
295+
_write_pkg(tmp_path, {"react": "^18", "@tanstack/react-query": "^5"})
296+
# need 2+ files with useQuery to pass the threshold
297+
(tmp_path / "useUser.tsx").write_text(
298+
"import { useQuery } from '@tanstack/react-query';\n"
299+
"export const useUser = (id) => useQuery({ queryKey: ['user', id] });\n"
300+
)
301+
(tmp_path / "useRepos.tsx").write_text(
302+
"import { useQuery } from '@tanstack/react-query';\n"
303+
"export const useRepos = () => useQuery({ queryKey: ['repos'] });\n"
304+
)
305+
extractor = DNAExtractor()
306+
dna = extractor.extract(str(tmp_path))
307+
assert dna.frontend_patterns.uses_react_query is True
308+
309+
def test_detects_cn_utility(self, tmp_path: Path):
310+
_write_pkg(tmp_path, {"react": "^18"})
311+
(tmp_path / "Button.tsx").write_text(
312+
"import { cn } from '@/lib/utils';\n"
313+
"export const Button = ({ className }) => (\n"
314+
" <button className={cn('btn', className)}>click</button>\n"
315+
");\n"
316+
)
317+
(tmp_path / "Card.tsx").write_text(
318+
"import { cn } from '@/lib/utils';\n"
319+
"export const Card = ({ cls }) => <div className={cn('card', cls)} />;\n"
320+
)
321+
(tmp_path / "Input.tsx").write_text(
322+
"import { cn } from '@/lib/utils';\n"
323+
"export const Input = ({ cls }) => <input className={cn('input', cls)} />;\n"
324+
)
325+
extractor = DNAExtractor()
326+
dna = extractor.extract(str(tmp_path))
327+
assert dna.frontend_patterns.uses_cn_utility is True
328+
329+
def test_detects_custom_hooks(self, tmp_path: Path):
330+
_write_pkg(tmp_path, {"react": "^18"})
331+
hooks_dir = tmp_path / "hooks"
332+
hooks_dir.mkdir()
333+
(hooks_dir / "useAuth.ts").write_text("export const useAuth = () => ({});")
334+
(hooks_dir / "useRepos.ts").write_text("export const useRepos = () => ({});")
335+
(tmp_path / "app.tsx").write_text("const x = 1;")
336+
extractor = DNAExtractor()
337+
dna = extractor.extract(str(tmp_path))
338+
assert dna.frontend_patterns.has_custom_hooks is True
339+
340+
def test_detects_canonical_hook(self, tmp_path: Path):
341+
_write_pkg(tmp_path, {"react": "^18", "@tanstack/react-query": "^5"})
342+
hooks_dir = tmp_path / "hooks"
343+
hooks_dir.mkdir()
344+
(hooks_dir / "useCachedQuery.ts").write_text("export const useCachedQuery = () => ({});")
345+
# import useCachedQuery in 3 different components
346+
for i in range(3):
347+
(tmp_path / f"Component{i}.tsx").write_text(
348+
"import { useCachedQuery } from '../hooks/useCachedQuery';\n"
349+
"export const C = () => { const d = useCachedQuery(); return null; };\n"
350+
)
351+
extractor = DNAExtractor()
352+
dna = extractor.extract(str(tmp_path))
353+
assert dna.frontend_patterns.has_custom_hooks is True
354+
assert dna.frontend_patterns.canonical_data_hook == "useCachedQuery"
355+
356+
def test_detects_shared_types_file(self, tmp_path: Path):
357+
_write_pkg(tmp_path, {"react": "^18"})
358+
src = tmp_path / "src"
359+
src.mkdir()
360+
(src / "types.ts").write_text("export interface User { id: string; }")
361+
(tmp_path / "app.tsx").write_text("const x = 1;")
362+
extractor = DNAExtractor()
363+
dna = extractor.extract(str(tmp_path))
364+
assert dna.frontend_patterns.shared_types_file == "src/types.ts"
365+
366+
def test_react_patterns_rendered_in_agents_md(self, tmp_path: Path):
367+
from saar.formatters.agents_md import render_agents_md
368+
from saar.models import CodebaseDNA, FrontendPattern
369+
370+
dna = CodebaseDNA(
371+
repo_name="test",
372+
frontend_patterns=FrontendPattern(
373+
framework="React",
374+
uses_react_query=True,
375+
uses_cn_utility=True,
376+
has_custom_hooks=True,
377+
canonical_data_hook="useCachedQuery",
378+
shared_types_file="src/types.ts",
379+
)
380+
)
381+
out = render_agents_md(dna)
382+
assert "useQuery" in out
383+
assert "useEffect" in out
384+
assert "cn()" in out
385+
assert "useCachedQuery" in out
386+
assert "src/types.ts" in out

0 commit comments

Comments
 (0)