Skip to content

Commit 8b2e019

Browse files
committed
feat: add framework pattern detection with routes, API, middleware, entry points
1 parent d4f9fa8 commit 8b2e019

6 files changed

Lines changed: 359 additions & 28 deletions

File tree

DEPENDENCIES.md

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,45 @@
11
# stacklit dependency graph
22

3-
> Generated by [stacklit](https://github.com/glincker/stacklit) on 2026-04-10. Run `stacklit generate` to update.
3+
> Generated by [stacklit](https://github.com/glincker/stacklit) on 2026-04-13. Run `stacklit generate` to update.
44
55
```mermaid
66
graph LR
7-
classDef rust fill:#a5b3bf,color:#0d1117,stroke:#a5b3bf
87
classDef go fill:#e6edf3,color:#0d1117,stroke:#e6edf3
9-
classDef javascript fill:#c9d1d9,color:#0d1117,stroke:#c9d1d9
108
classDef java fill:#8b949e,color:#0d1117,stroke:#8b949e
11-
classDef typescript fill:#c9d1d9,color:#0d1117,stroke:#c9d1d9
9+
classDef javascript fill:#c9d1d9,color:#0d1117,stroke:#c9d1d9
1210
classDef python fill:#b1bac4,color:#0d1117,stroke:#b1bac4
13-
internal_summary["internal/summary<br/>AI-powered codebase summaries"]:::go
14-
internal_detect["internal/detect<br/>Framework and tool detection"]:::go
15-
internal_engine["internal/engine<br/>Core orchestration engine"]:::go
16-
internal_git["internal/git<br/>Git integration"]:::go
11+
classDef rust fill:#a5b3bf,color:#0d1117,stroke:#a5b3bf
12+
classDef typescript fill:#c9d1d9,color:#0d1117,stroke:#c9d1d9
13+
assets["assets<br/>Static assets"]:::go
1714
cmd_stacklit["cmd/stacklit<br/>Stacklit"]:::go
1815
internal_cli["internal/cli<br/>Command-line interface"]:::go
19-
internal_mcp["internal/mcp<br/>MCP server for AI agents"]:::go
20-
npm_bin["npm/bin<br/>Bin"]:::go
21-
assets["assets<br/>Static assets"]:::go
2216
internal_config["internal/config<br/>Configuration management"]:::go
17+
internal_derive["internal/derive<br/>Derive"]:::go
18+
internal_detect["internal/detect<br/>Framework and tool detection"]:::go
19+
internal_engine["internal/engine<br/>Core orchestration engine"]:::go
20+
internal_git["internal/git<br/>Git integration"]:::go
2321
internal_graph["internal/graph<br/>Dependency graph"]:::go
22+
internal_mcp["internal/mcp<br/>MCP server for AI agents"]:::go
2423
internal_monorepo["internal/monorepo<br/>Monorepo detection"]:::go
25-
internal_renderer["internal/renderer<br/>Output renderers"]:::go
26-
npm["npm<br/>Npm"]:::go
2724
internal_parser["internal/parser<br/>Source code parsers"]:::go
25+
internal_renderer["internal/renderer<br/>Output renderers"]:::go
2826
internal_schema["internal/schema<br/>Data schema definitions"]:::go
27+
internal_setup["internal/setup<br/>Setup"]:::go
28+
internal_summary["internal/summary<br/>AI-powered codebase summaries"]:::go
2929
internal_walker["internal/walker<br/>File system walker"]:::go
30+
npm["npm<br/>Npm"]:::go
31+
npm_bin["npm/bin<br/>Bin"]:::go
3032
cmd_stacklit --> internal_cli
33+
internal_cli --> internal_config
34+
internal_cli --> internal_derive
3135
internal_cli --> internal_engine
3236
internal_cli --> internal_git
3337
internal_cli --> internal_mcp
3438
internal_cli --> internal_renderer
3539
internal_cli --> internal_schema
40+
internal_cli --> internal_setup
3641
internal_cli --> internal_walker
42+
internal_derive --> internal_schema
3743
internal_engine --> internal_config
3844
internal_engine --> internal_detect
3945
internal_engine --> internal_git
@@ -48,5 +54,7 @@ graph LR
4854
internal_mcp --> internal_schema
4955
internal_renderer --> assets
5056
internal_renderer --> internal_schema
57+
internal_setup --> internal_derive
58+
internal_setup --> internal_schema
5159
internal_summary --> internal_schema
5260
```
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package detect
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
// FrameworkPattern holds detected structural patterns for a framework.
11+
type FrameworkPattern struct {
12+
Name string `json:"name"`
13+
Config []string `json:"config_files,omitempty"`
14+
Routes string `json:"routes,omitempty"`
15+
API string `json:"api,omitempty"`
16+
Middleware string `json:"middleware,omitempty"`
17+
Models string `json:"models,omitempty"`
18+
Entry string `json:"entry,omitempty"`
19+
}
20+
21+
// fileExists returns true if the file at root/rel exists and is a regular file.
22+
func fileExists(root, rel string) bool {
23+
info, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel)))
24+
return err == nil && !info.IsDir()
25+
}
26+
27+
// dirExists returns true if the directory at root/rel exists.
28+
func dirExists(root, rel string) bool {
29+
info, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel)))
30+
return err == nil && info.IsDir()
31+
}
32+
33+
// firstExisting returns the first candidate (relative path) that exists as a file under root,
34+
// or "" if none exist.
35+
func firstExisting(root string, candidates []string) string {
36+
for _, c := range candidates {
37+
if fileExists(root, c) {
38+
return c
39+
}
40+
}
41+
return ""
42+
}
43+
44+
// firstExistingDir returns the first candidate (relative path) that exists as a directory under root,
45+
// or "" if none exist.
46+
func firstExistingDir(root string, candidates []string) string {
47+
for _, c := range candidates {
48+
if dirExists(root, c) {
49+
return c + "/"
50+
}
51+
}
52+
return ""
53+
}
54+
55+
// hasPkgJSONDep returns true if the given package name appears in dependencies or devDependencies
56+
// of the package.json at root.
57+
func hasPkgJSONDep(root, pkg string) bool {
58+
data, err := os.ReadFile(filepath.Join(root, "package.json"))
59+
if err != nil {
60+
return false
61+
}
62+
var p struct {
63+
Dependencies map[string]string `json:"dependencies"`
64+
DevDependencies map[string]string `json:"devDependencies"`
65+
}
66+
if json.Unmarshal(data, &p) != nil {
67+
return false
68+
}
69+
if _, ok := p.Dependencies[pkg]; ok {
70+
return true
71+
}
72+
_, ok := p.DevDependencies[pkg]
73+
return ok
74+
}
75+
76+
// hasRequirement returns true if the given package name (case-insensitive prefix match on lines)
77+
// appears in requirements.txt at root.
78+
func hasRequirement(root, pkg string) bool {
79+
data, err := os.ReadFile(filepath.Join(root, "requirements.txt"))
80+
if err != nil {
81+
return false
82+
}
83+
lower := strings.ToLower(string(data))
84+
pkgLower := strings.ToLower(pkg)
85+
for _, line := range strings.Split(lower, "\n") {
86+
line = strings.TrimSpace(line)
87+
if strings.HasPrefix(line, pkgLower) {
88+
return true
89+
}
90+
}
91+
return false
92+
}
93+
94+
// hasGoMod returns true if go.mod at root contains the given module path substring.
95+
func hasGoMod(root, substr string) bool {
96+
data, err := os.ReadFile(filepath.Join(root, "go.mod"))
97+
if err != nil {
98+
return false
99+
}
100+
return strings.Contains(string(data), substr)
101+
}
102+
103+
// hasBuildFile returns true if the given file (pom.xml or build.gradle) at root contains substr.
104+
func hasBuildFile(root, file, substr string) bool {
105+
data, err := os.ReadFile(filepath.Join(root, file))
106+
if err != nil {
107+
return false
108+
}
109+
return strings.Contains(string(data), substr)
110+
}
111+
112+
// DetectFrameworkPatterns inspects root and returns structural patterns for detected frameworks.
113+
func DetectFrameworkPatterns(root string) []FrameworkPattern {
114+
var patterns []FrameworkPattern
115+
116+
// --- Next.js ---
117+
nextConfigFiles := []string{"next.config.js", "next.config.ts", "next.config.mjs"}
118+
isNext := firstExisting(root, nextConfigFiles) != "" || hasPkgJSONDep(root, "next")
119+
if isNext {
120+
p := FrameworkPattern{Name: "Next.js"}
121+
for _, f := range nextConfigFiles {
122+
if fileExists(root, f) {
123+
p.Config = append(p.Config, f)
124+
}
125+
}
126+
// Routes: prefer app/ over pages/
127+
if dirExists(root, "app") {
128+
p.Routes = "app/"
129+
} else if dirExists(root, "pages") {
130+
p.Routes = "pages/"
131+
}
132+
// API dir
133+
if dirExists(root, "app/api") {
134+
p.API = "app/api/"
135+
} else if dirExists(root, "pages/api") {
136+
p.API = "pages/api/"
137+
}
138+
// Middleware
139+
if mid := firstExisting(root, []string{"middleware.ts", "middleware.js"}); mid != "" {
140+
p.Middleware = mid
141+
}
142+
patterns = append(patterns, p)
143+
}
144+
145+
// --- Express ---
146+
if hasPkgJSONDep(root, "express") {
147+
p := FrameworkPattern{Name: "Express"}
148+
if dirExists(root, "routes") {
149+
p.Routes = "routes/"
150+
}
151+
if entry := firstExisting(root, []string{"app.js", "server.js"}); entry != "" {
152+
p.Entry = entry
153+
}
154+
patterns = append(patterns, p)
155+
}
156+
157+
// --- FastAPI ---
158+
if hasRequirement(root, "fastapi") {
159+
p := FrameworkPattern{Name: "FastAPI"}
160+
if routesDir := firstExistingDir(root, []string{"app/routers", "routers"}); routesDir != "" {
161+
p.Routes = routesDir
162+
}
163+
if entry := firstExisting(root, []string{"app/main.py", "main.py"}); entry != "" {
164+
p.Entry = entry
165+
}
166+
if dirExists(root, "app/models") {
167+
p.Models = "app/models/"
168+
}
169+
patterns = append(patterns, p)
170+
}
171+
172+
// --- Django ---
173+
isDjango := fileExists(root, "manage.py") || hasRequirement(root, "django")
174+
if isDjango {
175+
p := FrameworkPattern{Name: "Django"}
176+
p.Entry = "manage.py"
177+
patterns = append(patterns, p)
178+
}
179+
180+
// --- Gin ---
181+
if hasGoMod(root, "gin-gonic/gin") {
182+
p := FrameworkPattern{Name: "Gin"}
183+
if entry := firstExisting(root, []string{"main.go", "cmd/server/main.go"}); entry != "" {
184+
p.Entry = entry
185+
}
186+
patterns = append(patterns, p)
187+
}
188+
189+
// --- Spring Boot ---
190+
isSpring := hasBuildFile(root, "pom.xml", "spring-boot") ||
191+
hasBuildFile(root, "build.gradle", "spring-boot")
192+
if isSpring {
193+
p := FrameworkPattern{Name: "Spring Boot"}
194+
if dirExists(root, "src/main/java") {
195+
p.Routes = "src/main/java/"
196+
}
197+
patterns = append(patterns, p)
198+
}
199+
200+
return patterns
201+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package detect
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestDetectFrameworkPatterns_NextJS(t *testing.T) {
10+
dir := t.TempDir()
11+
os.WriteFile(filepath.Join(dir, "next.config.ts"), []byte("export default {}"), 0644)
12+
os.MkdirAll(filepath.Join(dir, "app", "api"), 0755)
13+
os.WriteFile(filepath.Join(dir, "app", "page.tsx"), []byte(""), 0644)
14+
os.WriteFile(filepath.Join(dir, "middleware.ts"), []byte(""), 0644)
15+
16+
patterns := DetectFrameworkPatterns(dir)
17+
found := false
18+
for _, p := range patterns {
19+
if p.Name == "Next.js" {
20+
found = true
21+
if p.Routes != "app/" {
22+
t.Errorf("expected routes=app/, got %s", p.Routes)
23+
}
24+
if p.API != "app/api/" {
25+
t.Errorf("expected api=app/api/, got %s", p.API)
26+
}
27+
if p.Middleware != "middleware.ts" {
28+
t.Errorf("expected middleware=middleware.ts, got %s", p.Middleware)
29+
}
30+
}
31+
}
32+
if !found {
33+
t.Error("Next.js pattern not detected")
34+
}
35+
}
36+
37+
func TestDetectFrameworkPatterns_Express(t *testing.T) {
38+
dir := t.TempDir()
39+
os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"dependencies":{"express":"^4.18.0"}}`), 0644)
40+
os.MkdirAll(filepath.Join(dir, "routes"), 0755)
41+
os.WriteFile(filepath.Join(dir, "app.js"), []byte(""), 0644)
42+
43+
patterns := DetectFrameworkPatterns(dir)
44+
found := false
45+
for _, p := range patterns {
46+
if p.Name == "Express" {
47+
found = true
48+
if p.Routes != "routes/" {
49+
t.Errorf("expected routes=routes/, got %s", p.Routes)
50+
}
51+
if p.Entry != "app.js" {
52+
t.Errorf("expected entry=app.js, got %s", p.Entry)
53+
}
54+
}
55+
}
56+
if !found {
57+
t.Error("Express pattern not detected")
58+
}
59+
}
60+
61+
func TestDetectFrameworkPatterns_FastAPI(t *testing.T) {
62+
dir := t.TempDir()
63+
os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("fastapi==0.100.0\nuvicorn"), 0644)
64+
os.MkdirAll(filepath.Join(dir, "app", "routers"), 0755)
65+
os.WriteFile(filepath.Join(dir, "app", "main.py"), []byte(""), 0644)
66+
os.MkdirAll(filepath.Join(dir, "app", "models"), 0755)
67+
68+
patterns := DetectFrameworkPatterns(dir)
69+
found := false
70+
for _, p := range patterns {
71+
if p.Name == "FastAPI" {
72+
found = true
73+
if p.Routes != "app/routers/" {
74+
t.Errorf("expected routes=app/routers/, got %s", p.Routes)
75+
}
76+
if p.Entry != "app/main.py" {
77+
t.Errorf("expected entry=app/main.py, got %s", p.Entry)
78+
}
79+
if p.Models != "app/models/" {
80+
t.Errorf("expected models=app/models/, got %s", p.Models)
81+
}
82+
}
83+
}
84+
if !found {
85+
t.Error("FastAPI pattern not detected")
86+
}
87+
}
88+
89+
func TestDetectFrameworkPatterns_NoFramework(t *testing.T) {
90+
dir := t.TempDir()
91+
os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0644)
92+
patterns := DetectFrameworkPatterns(dir)
93+
if len(patterns) != 0 {
94+
t.Errorf("expected 0 patterns, got %d", len(patterns))
95+
}
96+
}

internal/engine/engine.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,19 @@ func assembleIndex(
393393

394394
// --- Frameworks ---
395395
frameworks := detect.DetectFrameworks(root, allImports)
396+
detectedPatterns := detect.DetectFrameworkPatterns(root)
397+
frameworkPatterns := make([]schema.FrameworkPattern, len(detectedPatterns))
398+
for i, p := range detectedPatterns {
399+
frameworkPatterns[i] = schema.FrameworkPattern{
400+
Name: p.Name,
401+
Config: p.Config,
402+
Routes: p.Routes,
403+
API: p.API,
404+
Middleware: p.Middleware,
405+
Models: p.Models,
406+
Entry: p.Entry,
407+
}
408+
}
396409
primaryLang := ""
397410
primaryCount := 0
398411
for lang, ls := range langStats {
@@ -527,9 +540,10 @@ func assembleIndex(
527540
Workspaces: workspaces,
528541
},
529542
Tech: schema.Tech{
530-
PrimaryLanguage: primaryLang,
531-
Languages: langStats,
532-
Frameworks: frameworks,
543+
PrimaryLanguage: primaryLang,
544+
Languages: langStats,
545+
Frameworks: frameworks,
546+
FrameworkPatterns: frameworkPatterns,
533547
},
534548
Structure: schema.Structure{
535549
TotalFiles: len(files),

0 commit comments

Comments
 (0)