diff --git a/saar/commands/extract.py b/saar/commands/extract.py index c1ed660..60703b1 100644 --- a/saar/commands/extract.py +++ b/saar/commands/extract.py @@ -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))) diff --git a/saar/commands/quality.py b/saar/commands/quality.py index 1ef91a0..be8bbbf 100644 --- a/saar/commands/quality.py +++ b/saar/commands/quality.py @@ -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)) diff --git a/saar/extractors/backend.py b/saar/extractors/backend.py index cb916b4..a5df0cd 100644 --- a/saar/extractors/backend.py +++ b/saar/extractors/backend.py @@ -188,19 +188,26 @@ 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: @@ -208,10 +215,14 @@ def extract_database_patterns(files: List[Path], repo_path: Path, read_file: Rea 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: @@ -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 @@ -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]) diff --git a/saar/extractors/conventions.py b/saar/extractors/conventions.py index 7f2c1d4..8702fa0 100644 --- a/saar/extractors/conventions.py +++ b/saar/extractors/conventions.py @@ -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 @@ -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] @@ -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: diff --git a/saar/extractors/frontend.py b/saar/extractors/frontend.py index c37614e..91adec2 100644 --- a/saar/extractors/frontend.py +++ b/saar/extractors/frontend.py @@ -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: @@ -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) @@ -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: diff --git a/saar/extractors/project.py b/saar/extractors/project.py index b26aebd..018002d 100644 --- a/saar/extractors/project.py +++ b/saar/extractors/project.py @@ -37,7 +37,8 @@ def extract_config_patterns(files: List[Path], repo_path: Path, read_file: ReadF pattern = ConfigPattern() for file_path in files: content = read_file(file_path) - if not content: continue + if not content: + continue if re.search(r"^from dotenv import|^load_dotenv\b", content, re.MULTILINE): pattern.env_loading = "python-dotenv" elif re.search(r"^from decouple import", content, re.MULTILINE): @@ -98,22 +99,29 @@ def extract_verify_workflow(repo_path: Path, read_file: ReadFile) -> Optional[st for candidate in [repo_path / "package.json", repo_path / "frontend" / "package.json", repo_path / "web" / "package.json", repo_path / "app" / "package.json", repo_path / "apps" / "web" / "package.json"]: - if not candidate.exists(): continue + if not candidate.exists(): + continue try: data = _json.loads(candidate.read_text(encoding="utf-8")) except Exception: break scripts = data.get("scripts", {}) parent = candidate.parent - if (parent / "bun.lock").exists() or (parent / "bun.lockb").exists(): pm = "bun" - elif (parent / "pnpm-lock.yaml").exists(): pm = "pnpm" - elif (parent / "yarn.lock").exists(): pm = "yarn" - else: pm = "npm" + if (parent / "bun.lock").exists() or (parent / "bun.lockb").exists(): + pm = "bun" + elif (parent / "pnpm-lock.yaml").exists(): + pm = "pnpm" + elif (parent / "yarn.lock").exists(): + pm = "yarn" + else: + pm = "npm" for key in ("typecheck", "type-check", "test", "lint", "build"): if key in scripts and scripts[key]: cmd = f"{pm} run {key}" - if cmd not in js_steps: js_steps.append(cmd) - if len(js_steps) >= 3: break + if cmd not in js_steps: + js_steps.append(cmd) + if len(js_steps) >= 3: + break break if js_steps: steps.append(f"Frontend: `{'` then `'.join(js_steps)}`") @@ -161,7 +169,8 @@ def _count_code_files(d: Path) -> int: return count def _build_tree(directory: Path, prefix: str = "", depth: int = 0) -> list[str]: - if depth > 3: return [] + if depth > 3: + return [] lines = [] try: children = sorted([c for c in directory.iterdir() if c.is_dir()], key=lambda p: p.name)