From ba78fa3b3b811575c2eafa28016191195fbc5918 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Mon, 16 Mar 2026 23:58:27 -0400 Subject: [PATCH 1/3] Revert "fix: E701/E702 ruff lint errors in commands/ and extractors/ -- expand compact one-liners to multi-line" This reverts commit 721157257e7e6672bff436002e8ea06b3a401f1c. --- saar/commands/extract.py | 6 +- saar/commands/quality.py | 6 +- saar/extractors/backend.py | 69 ++++++++-------------- saar/extractors/conventions.py | 60 +++++++------------ saar/extractors/frontend.py | 105 +++++++++++---------------------- saar/extractors/project.py | 27 +++------ 6 files changed, 91 insertions(+), 182 deletions(-) diff --git a/saar/commands/extract.py b/saar/commands/extract.py index 60703b1..c1ed660 100644 --- a/saar/commands/extract.py +++ b/saar/commands/extract.py @@ -289,13 +289,11 @@ 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 be8bbbf..1ef91a0 100644 --- a/saar/commands/quality.py +++ b/saar/commands/quality.py @@ -75,10 +75,8 @@ 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 a5df0cd..cb916b4 100644 --- a/saar/extractors/backend.py +++ b/saar/extractors/backend.py @@ -188,26 +188,19 @@ 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: @@ -215,14 +208,10 @@ 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: @@ -231,34 +220,25 @@ 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 @@ -269,14 +249,11 @@ 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 8702fa0..7f2c1d4 100644 --- a/saar/extractors/conventions.py +++ b/saar/extractors/conventions.py @@ -24,42 +24,28 @@ 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 @@ -68,15 +54,12 @@ 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] @@ -109,16 +92,13 @@ 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 91adec2..c37614e 100644 --- a/saar/extractors/frontend.py +++ b/saar/extractors/frontend.py @@ -48,40 +48,27 @@ 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: @@ -91,53 +78,34 @@ 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) @@ -184,12 +152,9 @@ 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 018002d..b26aebd 100644 --- a/saar/extractors/project.py +++ b/saar/extractors/project.py @@ -37,8 +37,7 @@ 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): @@ -99,29 +98,22 @@ 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)}`") @@ -169,8 +161,7 @@ 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) From cb2551f0fff090b58b54b86104dac997ff8f231f Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 17 Mar 2026 00:14:23 -0400 Subject: [PATCH 2/3] chore: PR template + CI improvements driven by saar dogfood PR template: - Verification commands from AGENTS.md (pytest, ruff, saar extract) - Module checklist matching saar/commands/ saar/extractors/ structure - saar/models.py off-limits warning surfaced explicitly - Dogfood step: saar extract . --no-interview as required check CI improvements: - Lint extracted to separate job (runs once, not 12x across matrix) - Tests depend on lint passing (fail fast on obvious errors) - Dogfood step in install-test: saar must score 100/100 on itself - auto-release now waits for CI workflow_run completion (was: fires on every push, could release broken code) (now: only fires when CI concludes with success) --- .github/pull_request_template.md | 56 ++++++++++++++++++++++++++++++ .github/workflows/auto-release.yml | 8 +++-- .github/workflows/ci.yml | 44 +++++++++++++---------- 3 files changed, 87 insertions(+), 21 deletions(-) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..8f0f569 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,56 @@ +## What does this PR do? + + + +## Why? + + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature / command +- [ ] Refactor (no behavior change) +- [ ] Docs / chore +- [ ] New extraction pattern (saar/extractors/) +- [ ] Formatter change (saar/formatters/) + +## Module affected + + +- [ ] `saar/commands/` — CLI command logic +- [ ] `saar/extractors/` — Pattern extraction +- [ ] `saar/formatters/` — Output generation +- [ ] `saar/linter.py` — AGENTS.md quality rules +- [ ] `saar/scorer.py` — Quality scoring +- [ ] `saar/models.py` — ⚠️ Core data contract (discuss before touching) + +## Verification + + + +```bash +source venv/bin/activate + +# 1. Lint +ruff check saar/ tests/ + +# 2. Tests — 548 must pass +pytest tests/ -q + +# 3. Dogfood — saar must extract itself cleanly with no stale warnings +saar extract . --no-interview + +# 4. If you changed a formatter, regenerate all context files +saar extract . --force --no-interview +``` + +## Checklist + +- [ ] `ruff check saar/ tests/` passes (no E/F violations) +- [ ] `pytest tests/ -q` → 548 passed +- [ ] `saar extract . --no-interview` runs clean (no stale fact warnings) +- [ ] Did NOT modify `saar/models.py` without discussion +- [ ] Did NOT add external dependencies (no Supabase, Redis, network calls in core path) +- [ ] Version bumped in `pyproject.toml` + `saar/__init__.py` (if shipping a release) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 4e8c50d..8424a77 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -1,6 +1,6 @@ name: Auto Release + Publish -# Triggers on every push to main. +# Triggers AFTER CI passes on main -- never release broken code. # If the version in pyproject.toml has no git tag yet, it: # 1. Creates the git tag (v0.x.y) # 2. Creates a GitHub Release @@ -8,7 +8,9 @@ name: Auto Release + Publish # If the version already has a tag, it does nothing. on: - push: + workflow_run: + workflows: [CI] + types: [completed] branches: [main] permissions: @@ -16,6 +18,8 @@ permissions: jobs: release: + # Only run if CI actually passed -- not on failure or cancellation + if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d689c0..c89181f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,55 +7,61 @@ on: branches: [main] jobs: + # Lint runs once -- no point running ruff 12 times across the matrix + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Lint + run: ruff check saar/ tests/ + + # Tests run across all supported Python versions and OS test: + needs: lint runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.10", "3.11", "3.12", "3.13"] fail-fast: false - steps: - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies run: pip install -e ".[dev]" - - name: Show tree-sitter version run: pip show tree-sitter tree-sitter-python tree-sitter-javascript - - - name: Lint - run: ruff check saar/ tests/ - - name: Test - run: pytest tests/ -v --tb=long 2>&1 + run: pytest tests/ -q --tb=short - # Quick smoke test: install from built wheel and run CLI + # Install from wheel + dogfood: saar must extract itself cleanly install-test: - runs-on: ubuntu-latest needs: test + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Build wheel run: | pip install build python -m build - - name: Install from wheel run: pip install dist/*.whl - - name: Smoke test CLI run: | saar --version saar extract . --format markdown --no-interview + - name: Dogfood -- saar must extract itself with no stale warnings + run: | + saar extract . --no-interview + saar lint . && echo "lint clean" || echo "lint violations found (non-blocking)" + saar stats . 2>&1 | grep -E "100/100|No improvements" From 4cf9f3ad02f956c899c60e70b1625679c8bedde4 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 17 Mar 2026 00:16:53 -0400 Subject: [PATCH 3/3] fix: include E701/E702 ruff fixes in this branch --- saar/commands/extract.py | 6 +- saar/commands/quality.py | 6 +- saar/extractors/backend.py | 69 ++++++++++++++-------- saar/extractors/conventions.py | 60 ++++++++++++------- saar/extractors/frontend.py | 105 ++++++++++++++++++++++----------- saar/extractors/project.py | 27 ++++++--- 6 files changed, 182 insertions(+), 91 deletions(-) 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)