Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions saar/commands/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,11 +289,13 @@ def _build_summary_rows(dna) -> list[tuple[str, str]]:
auth = []
for m in dna.auth_patterns.middleware_used[:2]:
if m not in seen:
auth.append(m); seen.add(m)
auth.append(m)
seen.add(m)
for d in dna.auth_patterns.auth_decorators[:3]:
name = d.split("(")[1].rstrip(")") if "(" in d else d
if name and name not in seen:
auth.append(name); seen.add(name)
auth.append(name)
seen.add(name)
if auth:
rows.append(("Auth", " ".join(auth)))

Expand Down
6 changes: 4 additions & 2 deletions saar/commands/quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,10 @@ def cmd_stats(
console.print()

def _pts_fmt(earned: int, max_pts: int) -> str:
if earned == max_pts: return f"[green]{earned}[/green]"
if earned >= max_pts * 0.6: return f"[yellow]{earned}[/yellow]"
if earned == max_pts:
return f"[green]{earned}[/green]"
if earned >= max_pts * 0.6:
return f"[yellow]{earned}[/yellow]"
return f"[red]{earned}[/red]"

table = Table(show_header=True, box=box.SIMPLE, padding=(0, 2))
Expand Down
69 changes: 46 additions & 23 deletions saar/extractors/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,30 +188,41 @@ def extract_database_patterns(files: List[Path], repo_path: Path, read_file: Rea
continue

if file_path.suffix == ".sql":
if "gen_random_uuid()" in content: pattern.id_type = "UUID (gen_random_uuid())"
elif "SERIAL" in content: pattern.id_type = "SERIAL"
if "TIMESTAMPTZ" in content: pattern.timestamp_type = "TIMESTAMPTZ"
elif "TIMESTAMP" in content: pattern.timestamp_type = "TIMESTAMP"
if "ENABLE ROW LEVEL SECURITY" in content: pattern.has_rls = True
if "ON DELETE CASCADE" in content: pattern.cascade_deletes = True
if "gen_random_uuid()" in content:
pattern.id_type = "UUID (gen_random_uuid())"
elif "SERIAL" in content:
pattern.id_type = "SERIAL"
if "TIMESTAMPTZ" in content:
pattern.timestamp_type = "TIMESTAMPTZ"
elif "TIMESTAMP" in content:
pattern.timestamp_type = "TIMESTAMP"
if "ENABLE ROW LEVEL SECURITY" in content:
pattern.has_rls = True
if "ON DELETE CASCADE" in content:
pattern.cascade_deletes = True
continue

if file_path.suffix != ".py":
continue

if re.search(r"^from supabase\b|^import supabase\b", content, re.MULTILINE):
if not pattern.orm_used: pattern.orm_used = "Supabase"
if not pattern.orm_used:
pattern.orm_used = "Supabase"
if "get_supabase_service()" in content:
pattern.connection_pattern = "Singleton: get_supabase_service()"
elif "create_client(" in content and not pattern.connection_pattern:
pattern.connection_pattern = "Direct: create_client()"

if re.search(r"^from django\.db import models", content, re.MULTILINE):
pattern.orm_used = "Django ORM"
if "models.UUIDField" in content: pattern.id_type = "UUID (Django UUIDField)"
elif "models.AutoField" in content or "models.BigAutoField" in content: pattern.id_type = "AutoField (Django)"
if "models.DateTimeField" in content: pattern.timestamp_type = "DateTimeField (Django)"
if "on_delete=models.CASCADE" in content: pattern.cascade_deletes = True
if "models.UUIDField" in content:
pattern.id_type = "UUID (Django UUIDField)"
elif "models.AutoField" in content or "models.BigAutoField" in content:
pattern.id_type = "AutoField (Django)"
if "models.DateTimeField" in content:
pattern.timestamp_type = "DateTimeField (Django)"
if "on_delete=models.CASCADE" in content:
pattern.cascade_deletes = True

if re.search(r"^from django", content, re.MULTILINE) or re.search(r"^import django\b", content, re.MULTILINE):
if "DATABASES" in content and not pattern.connection_pattern:
Expand All @@ -220,25 +231,34 @@ def extract_database_patterns(files: List[Path], repo_path: Path, read_file: Rea
pattern.orm_used = "Django ORM"

if re.search(r"^from sqlalchemy\b", content, re.MULTILINE):
if not pattern.orm_used: pattern.orm_used = "SQLAlchemy"
if "UUID" in content: pattern.id_type = "UUID (SQLAlchemy)"
if "DateTime" in content: pattern.timestamp_type = "DateTime (SQLAlchemy)"
if "create_engine(" in content: pattern.connection_pattern = "SQLAlchemy: create_engine()"
if not pattern.orm_used:
pattern.orm_used = "SQLAlchemy"
if "UUID" in content:
pattern.id_type = "UUID (SQLAlchemy)"
if "DateTime" in content:
pattern.timestamp_type = "DateTime (SQLAlchemy)"
if "create_engine(" in content:
pattern.connection_pattern = "SQLAlchemy: create_engine()"

if re.search(r"^from tortoise\b|^from tortoise\.models\b", content, re.MULTILINE):
if not pattern.orm_used: pattern.orm_used = "Tortoise ORM"
if not pattern.orm_used:
pattern.orm_used = "Tortoise ORM"

if re.search(r"^from mongoengine\b|^import mongoengine\b", content, re.MULTILINE):
if not pattern.orm_used: pattern.orm_used = "MongoEngine"
if not pattern.orm_used:
pattern.orm_used = "MongoEngine"

if re.search(r"^from motor\b|^import motor\b", content, re.MULTILINE):
if not pattern.orm_used: pattern.orm_used = "Motor (async MongoDB)"
if not pattern.orm_used:
pattern.orm_used = "Motor (async MongoDB)"

for file_path in files:
if file_path.suffix not in (".js", ".ts", ".tsx", ".jsx"): continue
if file_path.suffix not in (".js", ".ts", ".tsx", ".jsx"):
continue
content = read_file(file_path)
if content and re.search(r"^import\b.*@prisma/client", content, re.MULTILINE):
if not pattern.orm_used: pattern.orm_used = "Prisma"
if not pattern.orm_used:
pattern.orm_used = "Prisma"
break

return pattern
Expand All @@ -249,11 +269,14 @@ def extract_middleware_patterns(files: List[Path], framework: Optional[str], rea
patterns: List[str] = []
for file_path in files:
content = read_file(file_path)
if not content: continue
if not content:
continue
for match in re.finditer(r"^class\s+(\w*Middleware\w*)", content, re.MULTILINE):
patterns.append(match.group(1))
if "app.add_middleware" in content: patterns.append("app.add_middleware()")
if "app.use(" in content: patterns.append("app.use(middleware)")
if "app.add_middleware" in content:
patterns.append("app.add_middleware()")
if "app.use(" in content:
patterns.append("app.use(middleware)")
if "MIDDLEWARE" in content and "django" in content.lower():
for mw in re.findall(r"['\"][\w.]*Middleware[\w.]*['\"]", content)[:3]:
patterns.append(mw.strip("'\"").split(".")[-1])
Expand Down
60 changes: 40 additions & 20 deletions saar/extractors/conventions.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,42 @@ def extract_naming_conventions(files: List[Path], read_file: ReadFile) -> Naming
continue
if file_path.suffix == ".py":
for func in re.findall(r"^def\s+(\w+)\s*\(", content, re.MULTILINE):
if func.startswith("_"): continue
if "_" in func: func_styles["snake_case"] += 1
elif func[0].islower() and any(c.isupper() for c in func): func_styles["camelCase"] += 1
if func.startswith("_"):
continue
if "_" in func:
func_styles["snake_case"] += 1
elif func[0].islower() and any(c.isupper() for c in func):
func_styles["camelCase"] += 1
for cls in re.findall(r"^class\s+(\w+)", content, re.MULTILINE):
if cls[0].isupper() and "_" not in cls: class_styles["PascalCase"] += 1
if cls[0].isupper() and "_" not in cls:
class_styles["PascalCase"] += 1
if "_" in file_path.stem and file_path.stem.islower():
file_styles["snake_case"] += 1
elif file_path.suffix in (".js", ".jsx", ".ts", ".tsx"):
for func in re.findall(r"(?:^|\s)(?:function|const|let|var)\s+(\w+)\s*(?:=\s*(?:async\s*)?\(|[\(<])", content, re.MULTILINE):
if not func or func[0].isupper(): continue
if func[0].islower() and any(c.isupper() for c in func): func_styles["camelCase"] += 1
elif "_" in func: func_styles["snake_case"] += 1
if not func or func[0].isupper():
continue
if func[0].islower() and any(c.isupper() for c in func):
func_styles["camelCase"] += 1
elif "_" in func:
func_styles["snake_case"] += 1
for cls in re.findall(r"(?:^|\s)(?:class|interface|type)\s+(\w+)", content, re.MULTILINE):
if cls[0].isupper() and "_" not in cls: class_styles["PascalCase"] += 1
if cls[0].isupper() and "_" not in cls:
class_styles["PascalCase"] += 1
stem = file_path.stem.replace(".test", "").replace(".spec", "")
if "-" in stem: file_styles["kebab-case"] += 1
elif stem[0].isupper(): file_styles["PascalCase"] += 1
elif any(c.isupper() for c in stem): file_styles["camelCase"] += 1
if "-" in stem:
file_styles["kebab-case"] += 1
elif stem[0].isupper():
file_styles["PascalCase"] += 1
elif any(c.isupper() for c in stem):
file_styles["camelCase"] += 1

if func_styles: conventions.function_style = func_styles.most_common(1)[0][0]
if class_styles: conventions.class_style = class_styles.most_common(1)[0][0]
if file_styles: conventions.file_style = file_styles.most_common(1)[0][0]
if func_styles:
conventions.function_style = func_styles.most_common(1)[0][0]
if class_styles:
conventions.class_style = class_styles.most_common(1)[0][0]
if file_styles:
conventions.file_style = file_styles.most_common(1)[0][0]
conventions.constant_style = "UPPER_SNAKE_CASE"
return conventions

Expand All @@ -54,12 +68,15 @@ def extract_common_imports(files: List[Path], read_file: ReadFile) -> List[str]:
"""Find the most frequently used import statements (Python only)."""
counter: Counter = Counter()
for file_path in files:
if file_path.suffix != ".py": continue
if file_path.suffix != ".py":
continue
content = read_file(file_path)
if not content: continue
if not content:
continue
for imp in re.findall(r"^((?:from\s+[\w.]+\s+)?import\s+[\w., ]+)$", content, re.MULTILINE):
imp = imp.strip()
if imp.endswith("(") or imp.startswith("#") or "from ." in imp: continue
if imp.endswith("(") or imp.startswith("#") or "from ." in imp:
continue
counter[imp] += 1
return [imp for imp, count in counter.most_common(20) if count >= 2]

Expand Down Expand Up @@ -92,13 +109,16 @@ def extract_test_patterns(app_files: List[Path], test_files: List[Path], repo_pa
pattern.has_conftest = bool(list(repo_path.rglob("conftest.py")))
for file_path in app_files + test_files:
content = read_file(file_path)
if not content: continue
if not content:
continue
if "import pytest" in content or "@pytest" in content:
pattern.framework = "pytest"
if "@pytest.fixture" in content: pattern.fixture_style = "pytest fixtures"
if "@pytest.fixture" in content:
pattern.fixture_style = "pytest fixtures"
elif "from unittest" in content and not pattern.framework:
pattern.framework = "unittest"
if "def setUp(" in content: pattern.fixture_style = "setUp/tearDown"
if "def setUp(" in content:
pattern.fixture_style = "setUp/tearDown"
if "from unittest.mock import" in content or "@patch(" in content:
pattern.mock_library = "unittest.mock"
elif "pytest_mock" in content or "mocker" in content:
Expand Down
105 changes: 70 additions & 35 deletions saar/extractors/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,27 +48,40 @@ def _has_lockfile(name: str) -> bool:
if p.is_dir() and not should_skip(p, repo_path)
)

if _has_lockfile("bun.lock") or _has_lockfile("bun.lockb"): fp.package_manager = "bun"
elif _has_lockfile("pnpm-lock.yaml"): fp.package_manager = "pnpm"
elif _has_lockfile("yarn.lock"): fp.package_manager = "yarn"
else: fp.package_manager = "npm"
if _has_lockfile("bun.lock") or _has_lockfile("bun.lockb"):
fp.package_manager = "bun"
elif _has_lockfile("pnpm-lock.yaml"):
fp.package_manager = "pnpm"
elif _has_lockfile("yarn.lock"):
fp.package_manager = "yarn"
else:
fp.package_manager = "npm"

fp.language = "TypeScript" if ("typescript" in combined or any(k.startswith("@types/") for k in combined)) else "JavaScript"

# UI framework
if "next" in combined: fp.framework = "Next.js"
elif "nuxt" in combined or "nuxt3" in combined: fp.framework = "Nuxt"
if "next" in combined:
fp.framework = "Next.js"
elif "nuxt" in combined or "nuxt3" in combined:
fp.framework = "Nuxt"
elif "@sveltejs/kit" in combined or "svelte" in combined:
fp.framework = "SvelteKit" if "@sveltejs/kit" in combined else "Svelte"
elif "astro" in combined: fp.framework = "Astro"
elif "@angular/core" in combined: fp.framework = "Angular"
elif "react" in combined or "react-dom" in combined: fp.framework = "React"
elif "vue" in combined: fp.framework = "Vue"
elif "astro" in combined:
fp.framework = "Astro"
elif "@angular/core" in combined:
fp.framework = "Angular"
elif "react" in combined or "react-dom" in combined:
fp.framework = "React"
elif "vue" in combined:
fp.framework = "Vue"

# build tool
if "vite" in combined or "@vitejs/plugin-react" in combined: fp.build_tool = "Vite"
elif "turbopack" in combined or ("next" in combined and "webpack" not in combined): fp.build_tool = "Turbopack"
elif "webpack" in combined: fp.build_tool = "webpack"
if "vite" in combined or "@vitejs/plugin-react" in combined:
fp.build_tool = "Vite"
elif "turbopack" in combined or ("next" in combined and "webpack" not in combined):
fp.build_tool = "Turbopack"
elif "webpack" in combined:
fp.build_tool = "webpack"

# test framework
if "vitest" in combined:
Expand All @@ -78,34 +91,53 @@ def _has_lockfile(name: str) -> bool:
pm = fp.package_manager or "npm"
fp.test_command = f"{pm} run test"
elif "jest" in combined or "@jest/core" in combined:
fp.test_framework = "Jest"; fp.test_command = "jest"
elif "@playwright/test" in combined: fp.test_framework = "Playwright"
elif "cypress" in combined: fp.test_framework = "Cypress"
elif "mocha" in combined: fp.test_framework = "Mocha"
fp.test_framework = "Jest"
fp.test_command = "jest"
elif "@playwright/test" in combined:
fp.test_framework = "Playwright"
elif "cypress" in combined:
fp.test_framework = "Cypress"
elif "mocha" in combined:
fp.test_framework = "Mocha"

# component library
radix_count = sum(1 for k in combined if k.startswith("@radix-ui/"))
if radix_count >= 3: fp.component_library = "shadcn/ui"
elif "@mui/material" in combined or "@material-ui/core" in combined: fp.component_library = "Material UI"
elif "@chakra-ui/react" in combined: fp.component_library = "Chakra UI"
elif "antd" in combined: fp.component_library = "Ant Design"
elif "react-bootstrap" in combined: fp.component_library = "React Bootstrap"
elif "@mantine/core" in combined: fp.component_library = "Mantine"
if radix_count >= 3:
fp.component_library = "shadcn/ui"
elif "@mui/material" in combined or "@material-ui/core" in combined:
fp.component_library = "Material UI"
elif "@chakra-ui/react" in combined:
fp.component_library = "Chakra UI"
elif "antd" in combined:
fp.component_library = "Ant Design"
elif "react-bootstrap" in combined:
fp.component_library = "React Bootstrap"
elif "@mantine/core" in combined:
fp.component_library = "Mantine"

# state management
if "@tanstack/react-query" in combined or "react-query" in combined: fp.state_management = "TanStack Query"
elif "zustand" in combined: fp.state_management = "Zustand"
if "@tanstack/react-query" in combined or "react-query" in combined:
fp.state_management = "TanStack Query"
elif "zustand" in combined:
fp.state_management = "Zustand"
elif "@reduxjs/toolkit" in combined or "redux" in combined:
fp.state_management = "Redux Toolkit" if "@reduxjs/toolkit" in combined else "Redux"
elif "jotai" in combined: fp.state_management = "Jotai"
elif "valtio" in combined: fp.state_management = "Valtio"
elif "recoil" in combined: fp.state_management = "Recoil"
elif "jotai" in combined:
fp.state_management = "Jotai"
elif "valtio" in combined:
fp.state_management = "Valtio"
elif "recoil" in combined:
fp.state_management = "Recoil"

# styling
if "tailwindcss" in combined: fp.styling = "Tailwind CSS"
elif "styled-components" in combined: fp.styling = "styled-components"
elif "@emotion/react" in combined or "@emotion/styled" in combined: fp.styling = "Emotion"
elif "sass" in combined or "node-sass" in combined: fp.styling = "Sass/SCSS"
if "tailwindcss" in combined:
fp.styling = "Tailwind CSS"
elif "styled-components" in combined:
fp.styling = "styled-components"
elif "@emotion/react" in combined or "@emotion/styled" in combined:
fp.styling = "Emotion"
elif "sass" in combined or "node-sass" in combined:
fp.styling = "Sass/SCSS"

if fp.framework in ("React", "Next.js"):
_detect_react_patterns(fp, repo_path, should_skip)
Expand Down Expand Up @@ -152,9 +184,12 @@ def _detect_react_patterns(fp: FrontendPattern, repo_path: Path, should_skip: Sh
if imp.startswith("use"):
custom_hook_imports[imp] = custom_hook_imports.get(imp, 0) + 1

if use_query_count >= 2: fp.uses_react_query = True
if fetch_in_effect_count == 0 and use_query_count >= 2: fp.avoids_fetch_in_effect = True
if cn_usage_count >= 3: fp.uses_cn_utility = True
if use_query_count >= 2:
fp.uses_react_query = True
if fetch_in_effect_count == 0 and use_query_count >= 2:
fp.avoids_fetch_in_effect = True
if cn_usage_count >= 3:
fp.uses_cn_utility = True
if hook_files:
fp.has_custom_hooks = True
if custom_hook_imports:
Expand Down
Loading
Loading