@@ -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)
0 commit comments