Skip to content

Commit 3bb0520

Browse files
committed
feat: frontend stack detection -- framework, test runner, component lib, state, styling (OPE-141 partial)
1 parent 06880d4 commit 3bb0520

6 files changed

Lines changed: 515 additions & 6 deletions

File tree

CLAUDE.md

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

4-
253 functions, 38 classes.
5-
Async adoption: 21%.
4+
281 functions, 47 classes.
5+
Async adoption: 20%.
66
Type hint coverage: 92%.
77

88
## Coding Conventions
@@ -34,14 +34,14 @@ import os
3434

3535
These files have the most dependents -- understand them before editing:
3636

37-
- `saar/models.py` (14 dependents)
38-
- `saar/formatters/agents_md.py` (4 dependents)
37+
- `saar/models.py` (15 dependents)
38+
- `saar/formatters/agents_md.py` (5 dependents)
3939
- `saar/formatters/_tribal.py` (4 dependents)
40+
- `saar/extractor.py` (3 dependents)
41+
- `saar/interview.py` (3 dependents)
4042
- `saar/cli.py` (3 dependents)
4143
- `saar/formatters/claude_md.py` (3 dependents)
42-
- `saar/interview.py` (3 dependents)
4344
- `saar/dependency_analyzer.py` (2 dependents)
44-
- `saar/style_analyzer.py` (2 dependents)
4545

4646
## Error Handling
4747

saar/extractor.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,154 @@ def _extract_test_patterns(
692692

693693
return pattern
694694

695+
def _extract_frontend_patterns(self, repo_path: Path) -> Optional["FrontendPattern"]:
696+
"""Detect frontend stack by reading package.json files.
697+
698+
Reads all package.json files found (handles monorepos with multiple
699+
frontend packages). Returns None if no package.json found -- meaning
700+
this is a pure backend/Python repo.
701+
"""
702+
from saar.models import FrontendPattern
703+
import json
704+
705+
pkg_files = [
706+
p for p in repo_path.rglob("package.json")
707+
if not self._should_skip(p, repo_path)
708+
and "node_modules" not in p.parts
709+
]
710+
if not pkg_files:
711+
return None
712+
713+
# merge deps across all package.json files (monorepo support)
714+
all_deps: dict = {}
715+
all_dev_deps: dict = {}
716+
all_scripts: dict = {}
717+
for pkg_file in pkg_files:
718+
try:
719+
data = json.loads(pkg_file.read_text(encoding="utf-8"))
720+
all_deps.update(data.get("dependencies", {}))
721+
all_dev_deps.update(data.get("devDependencies", {}))
722+
all_scripts.update(data.get("scripts", {}))
723+
except Exception:
724+
continue
725+
726+
combined = {**all_deps, **all_dev_deps}
727+
if not combined:
728+
return None
729+
730+
fp = FrontendPattern()
731+
732+
# -- package manager (check repo root AND subdirs for lockfiles) --
733+
def _has_lockfile(name: str) -> bool:
734+
# check root first, then any immediate subdirectory
735+
if (repo_path / name).exists():
736+
return True
737+
return any(
738+
(p / name).exists()
739+
for p in repo_path.iterdir()
740+
if p.is_dir() and not self._should_skip(p, repo_path)
741+
)
742+
743+
if _has_lockfile("bun.lock") or _has_lockfile("bun.lockb"):
744+
fp.package_manager = "bun"
745+
elif _has_lockfile("pnpm-lock.yaml"):
746+
fp.package_manager = "pnpm"
747+
elif _has_lockfile("yarn.lock"):
748+
fp.package_manager = "yarn"
749+
else:
750+
fp.package_manager = "npm"
751+
752+
# -- JS/TS language --
753+
if "typescript" in combined or any(k.startswith("@types/") for k in combined):
754+
fp.language = "TypeScript"
755+
else:
756+
fp.language = "JavaScript"
757+
758+
# -- UI framework (order matters -- Next before React) --
759+
if "next" in combined:
760+
fp.framework = "Next.js"
761+
elif "nuxt" in combined or "nuxt3" in combined:
762+
fp.framework = "Nuxt"
763+
elif "@sveltejs/kit" in combined or "svelte" in combined:
764+
fp.framework = "SvelteKit" if "@sveltejs/kit" in combined else "Svelte"
765+
elif "astro" in combined:
766+
fp.framework = "Astro"
767+
elif "@angular/core" in combined:
768+
fp.framework = "Angular"
769+
elif "react" in combined or "react-dom" in combined:
770+
fp.framework = "React"
771+
elif "vue" in combined:
772+
fp.framework = "Vue"
773+
774+
# -- build tool --
775+
if "vite" in combined or "@vitejs/plugin-react" in combined:
776+
fp.build_tool = "Vite"
777+
elif "turbopack" in combined or ("next" in combined and "webpack" not in combined):
778+
fp.build_tool = "Turbopack"
779+
elif "webpack" in combined:
780+
fp.build_tool = "webpack"
781+
782+
# -- test framework --
783+
if "vitest" in combined:
784+
fp.test_framework = "Vitest"
785+
# find the test run command
786+
test_cmd = all_scripts.get("test", "")
787+
if "vitest" in test_cmd:
788+
pm = fp.package_manager or "npm"
789+
run = "run" if pm in ("bun", "npm", "yarn", "pnpm") else ""
790+
fp.test_command = f"{pm} {run} test".strip()
791+
elif "jest" in combined or "@jest/core" in combined:
792+
fp.test_framework = "Jest"
793+
fp.test_command = "jest"
794+
elif "@playwright/test" in combined:
795+
fp.test_framework = "Playwright"
796+
elif "cypress" in combined:
797+
fp.test_framework = "Cypress"
798+
elif "mocha" in combined:
799+
fp.test_framework = "Mocha"
800+
801+
# -- component library --
802+
# shadcn/ui uses @radix-ui/* -- check for 3+ radix packages as signal
803+
radix_count = sum(1 for k in combined if k.startswith("@radix-ui/"))
804+
if radix_count >= 3:
805+
fp.component_library = "shadcn/ui"
806+
elif "@mui/material" in combined or "@material-ui/core" in combined:
807+
fp.component_library = "Material UI"
808+
elif "@chakra-ui/react" in combined:
809+
fp.component_library = "Chakra UI"
810+
elif "antd" in combined:
811+
fp.component_library = "Ant Design"
812+
elif "react-bootstrap" in combined:
813+
fp.component_library = "React Bootstrap"
814+
elif "@mantine/core" in combined:
815+
fp.component_library = "Mantine"
816+
817+
# -- state management --
818+
if "@tanstack/react-query" in combined or "react-query" in combined:
819+
fp.state_management = "TanStack Query"
820+
elif "zustand" in combined:
821+
fp.state_management = "Zustand"
822+
elif "@reduxjs/toolkit" in combined or "redux" in combined:
823+
fp.state_management = "Redux Toolkit" if "@reduxjs/toolkit" in combined else "Redux"
824+
elif "jotai" in combined:
825+
fp.state_management = "Jotai"
826+
elif "valtio" in combined:
827+
fp.state_management = "Valtio"
828+
elif "recoil" in combined:
829+
fp.state_management = "Recoil"
830+
831+
# -- styling --
832+
if "tailwindcss" in combined:
833+
fp.styling = "Tailwind CSS"
834+
elif "styled-components" in combined:
835+
fp.styling = "styled-components"
836+
elif "@emotion/react" in combined or "@emotion/styled" in combined:
837+
fp.styling = "Emotion"
838+
elif "sass" in combined or "node-sass" in combined:
839+
fp.styling = "Sass/SCSS"
840+
841+
return fp
842+
695843
def _extract_config_patterns(self, files: List[Path], repo_path: Path) -> ConfigPattern:
696844
pattern = ConfigPattern()
697845

@@ -802,6 +950,7 @@ def extract(
802950
router_pattern=router_pattern,
803951
team_rules=team_rules,
804952
team_rules_source=team_rules_source,
953+
frontend_patterns=self._extract_frontend_patterns(path),
805954
)
806955

807956
# Enrich with style analysis (AST-based, more precise than regex)

saar/formatters/agents_md.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,34 @@ def render_agents_md(dna: CodebaseDNA) -> str:
8787
)
8888
lines.append(f"**Languages:** {lang_str}\n")
8989

90+
# -- frontend stack --
91+
fp = dna.frontend_patterns
92+
if fp:
93+
lines.append("\n## Frontend\n")
94+
stack_parts = []
95+
if fp.framework:
96+
stack_parts.append(fp.framework)
97+
if fp.language:
98+
stack_parts.append(fp.language)
99+
if fp.build_tool:
100+
stack_parts.append(fp.build_tool)
101+
if stack_parts:
102+
lines.append(f"**Stack:** {' + '.join(stack_parts)}")
103+
if fp.package_manager:
104+
pm = fp.package_manager
105+
lines.append(f"- Package manager: `{pm}` -- always use `{pm} install`, never npm/yarn")
106+
if fp.component_library:
107+
lines.append(f"- Component library: {fp.component_library} -- use over custom components")
108+
if fp.state_management:
109+
lines.append(f"- State management: {fp.state_management}")
110+
if fp.styling:
111+
lines.append(f"- Styling: {fp.styling} -- no raw CSS files")
112+
if fp.test_framework:
113+
test_line = f"- Frontend tests: {fp.test_framework}"
114+
if fp.test_command:
115+
test_line += f" (`{fp.test_command}`)"
116+
lines.append(test_line)
117+
90118
# -- coding conventions --
91119
lines.append("## Coding Conventions\n")
92120
nc = dna.naming_conventions

saar/formatters/claude_md.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,34 @@ def render_claude_md(dna: CodebaseDNA) -> str:
2525
lines.append(f"Type hint coverage: {dna.type_hint_pct:.0f}%.")
2626
lines.append("")
2727

28+
# -- frontend stack --
29+
fp = dna.frontend_patterns
30+
if fp:
31+
lines.append("\n## Frontend\n")
32+
stack_parts = []
33+
if fp.framework:
34+
stack_parts.append(fp.framework)
35+
if fp.language:
36+
stack_parts.append(fp.language)
37+
if fp.build_tool:
38+
stack_parts.append(fp.build_tool)
39+
if stack_parts:
40+
lines.append(f"**Stack:** {' + '.join(stack_parts)}")
41+
if fp.package_manager:
42+
pm = fp.package_manager
43+
lines.append(f"- Package manager: `{pm}` -- always use `{pm} install`, never npm/yarn")
44+
if fp.component_library:
45+
lines.append(f"- Component library: {fp.component_library} -- use over custom components")
46+
if fp.state_management:
47+
lines.append(f"- State management: {fp.state_management}")
48+
if fp.styling:
49+
lines.append(f"- Styling: {fp.styling} -- no raw CSS files")
50+
if fp.test_framework:
51+
test_line = f"- Frontend tests: {fp.test_framework}"
52+
if fp.test_command:
53+
test_line += f" (`{fp.test_command}`)"
54+
lines.append(test_line)
55+
2856
# -- coding conventions as imperative rules --
2957
lines.append("## Coding Conventions\n")
3058
nc = dna.naming_conventions

saar/models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,20 @@ class TestPattern:
9595
coverage_config: bool = False
9696

9797

98+
@dataclass
99+
class FrontendPattern:
100+
"""Detected frontend stack patterns from package.json analysis."""
101+
framework: Optional[str] = None # next, react, vue, nuxt, svelte, astro
102+
test_framework: Optional[str] = None # vitest, jest, playwright, cypress
103+
test_command: Optional[str] = None # e.g. "bun run test", "npx vitest"
104+
component_library: Optional[str] = None # shadcn/ui, mui, chakra, antd
105+
state_management: Optional[str] = None # tanstack-query, zustand, redux
106+
styling: Optional[str] = None # tailwind, styled-components, emotion
107+
package_manager: Optional[str] = None # bun, pnpm, yarn, npm
108+
build_tool: Optional[str] = None # vite, webpack, turbopack
109+
language: Optional[str] = None # typescript, javascript
110+
111+
98112
@dataclass
99113
class ConfigPattern:
100114
"""Detected configuration patterns."""
@@ -117,6 +131,7 @@ class CodebaseDNA:
117131
logging_patterns: LoggingPattern = field(default_factory=LoggingPattern)
118132
naming_conventions: NamingConventions = field(default_factory=NamingConventions)
119133
test_patterns: TestPattern = field(default_factory=TestPattern)
134+
frontend_patterns: Optional[FrontendPattern] = None
120135
config_patterns: ConfigPattern = field(default_factory=ConfigPattern)
121136
middleware_patterns: List[str] = field(default_factory=list)
122137
common_imports: List[str] = field(default_factory=list)

0 commit comments

Comments
 (0)