Skip to content

Commit b047a84

Browse files
committed
feat: add panic recovery to goroutines and enhance Lua parser, incremental build, webhook config
- Add defer recover() to signal handler, HTTP server, and sync queue shutdown goroutines - Log health check write errors instead of discarding - Rewrite Lua tree-sitter queries for correct AST structure (functions, methods, comments) - Apply includePaths filter to incremental build file walk - Auto-load .ccg.yaml include_paths in webhook builds via LoadIncludePathsFromConfig
1 parent 6ce0361 commit b047a84

17 files changed

Lines changed: 638 additions & 19 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ See [Architecture Details](guide/architecture.md) for component breakdown and DB
124124
| [Docker](guide/docker.md) | Docker build, MCP server, PostgreSQL deployment |
125125
| [Development](guide/development.md) | Dev guide, integration test, project structure |
126126
| [Architecture](guide/architecture.md) | Data flow, components, DB schema |
127+
| [CLAUDE.md Guide](guide/claude-md-guide.md) | Template for projects using CCG |
127128

128129
## License
129130

cmd/ccg/main.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
mcpserver "github.com/imtaebin/code-context-graph/internal/mcp"
3636
"github.com/imtaebin/code-context-graph/internal/model"
3737
"github.com/imtaebin/code-context-graph/internal/parse/treesitter"
38+
"github.com/imtaebin/code-context-graph/internal/pathutil"
3839
"github.com/imtaebin/code-context-graph/internal/service"
3940
"github.com/imtaebin/code-context-graph/internal/store/gormstore"
4041
"github.com/imtaebin/code-context-graph/internal/store/search"
@@ -134,6 +135,12 @@ func main() {
134135
sigCh := make(chan os.Signal, 1)
135136
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
136137
go func() {
138+
defer func() {
139+
if r := recover(); r != nil {
140+
slog.Error("signal handler panicked", "panic", r)
141+
os.Exit(2)
142+
}
143+
}()
137144
sig := <-sigCh
138145
slog.Info("received signal, shutting down", "signal", sig)
139146
cleanup()
@@ -287,7 +294,10 @@ func serveStreamableHTTP(deps *cli.Deps, srv *server.MCPServer, cfg cli.ServeCon
287294
buildCtx, buildCancel := context.WithTimeout(ctx, 10*time.Minute)
288295
defer buildCancel()
289296
buildCtx = ctxns.WithNamespace(buildCtx, ns)
290-
stats, err := graphSvc.Build(buildCtx, service.BuildOptions{Dir: repoDir})
297+
stats, err := graphSvc.Build(buildCtx, service.BuildOptions{
298+
Dir: repoDir,
299+
IncludePaths: pathutil.LoadIncludePathsFromConfig(repoDir),
300+
})
291301
if err != nil {
292302
deps.Logger.Error("webhook build failed", "repo", repoFullName, "error", err)
293303
return
@@ -316,6 +326,12 @@ func serveStreamableHTTP(deps *cli.Deps, srv *server.MCPServer, cfg cli.ServeCon
316326

317327
errCh := make(chan error, 1)
318328
go func() {
329+
defer func() {
330+
if r := recover(); r != nil {
331+
slog.Error("HTTP server goroutine panicked", "panic", r)
332+
errCh <- fmt.Errorf("HTTP server panicked: %v", r)
333+
}
334+
}()
319335
errCh <- httpServer.ListenAndServe()
320336
}()
321337

@@ -349,7 +365,10 @@ func handleHealth(w http.ResponseWriter, r *http.Request) {
349365
}
350366
w.Header().Set("Content-Type", "application/json")
351367
w.WriteHeader(http.StatusOK)
352-
_, _ = w.Write([]byte(`{"status":"ok"}`))
368+
_, err := w.Write([]byte(`{"status":"ok"}`))
369+
if err != nil {
370+
slog.Error("health check write failed", "error", err)
371+
}
353372
}
354373

355374
// openDB opens a GORM connection for the configured driver.

guide/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ code-context-graph 상세 문서 목차.
55
| 문서 | 설명 |
66
|------|------|
77
| [CLI Reference](cli-reference.md) | 전체 CLI 명령어, 옵션, 설정 파일 (`.ccg.yaml`) |
8-
| [MCP Tools](mcp-tools.md) | 28개 MCP 도구, Claude Code Skills, AI 어노테이션 |
8+
| [MCP Tools](mcp-tools.md) | 29개 MCP 도구, Claude Code Skills, AI 어노테이션 |
99
| [Annotations](annotations.md) | 커스텀 어노테이션 시스템 — 태그, 예시, 검색 |
1010
| [Webhook](webhook.md) | GitHub / Gitea webhook sync, 브랜치 필터링, graceful shutdown |
1111
| [Docker](docker.md) | Docker 이미지 빌드, MCP 서버 실행, PostgreSQL 연동 |
1212
| [Architecture](architecture.md) | 시스템 아키텍처, 데이터 흐름, DB 구조 |
1313
| [Development](development.md) | 빌드, 테스트, Integration test (Gitea + PostgreSQL) |
14+
| [CLAUDE.md Guide](claude-md-guide.md) | CCG 사용 프로젝트의 CLAUDE.md 작성 템플릿 |

guide/claude-md-guide.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# CLAUDE.md Guide for CCG Users
2+
3+
CCG를 사용하는 프로젝트의 CLAUDE.md에 아래 템플릿을 복사·수정하여 사용하세요.
4+
5+
---
6+
7+
## Template
8+
9+
````markdown
10+
## Code Knowledge Graph (CCG)
11+
12+
이 프로젝트는 `.mcp.json`에 등록된 [code-context-graph](https://github.com/tae2089/code-context-graph) MCP 서버를 사용합니다.
13+
14+
### 코드 분석 플로우
15+
16+
```
17+
get_minimal_context ← 항상 여기서 시작 (그래프 상태 + 추천 도구)
18+
19+
├─ 그래프 없음 → build_or_update_graph(path: ".")
20+
21+
├─ 코드 찾기 → search(query: "키워드")
22+
│ → query_graph(pattern: "callers_of", target: "pkg.Func")
23+
24+
├─ 변경 영향 → detect_changes(repo_root: ".")
25+
│ → get_impact_radius(qualified_name: "...", depth: 3)
26+
│ → get_affected_flows(repo_root: ".")
27+
28+
├─ 구조 파악 → get_architecture_overview()
29+
│ → list_communities()
30+
31+
└─ 코드 변경 후 → build_or_update_graph(path: ".", full_rebuild: false)
32+
```
33+
34+
###
35+
36+
- `search`는 코드뿐 아니라 `@intent`, `@domainRule` 등 어노테이션도 검색합니다
37+
- `search(path: "internal/auth")` 처럼 경로로 범위를 좁힐 수 있습니다
38+
- MSA 환경에서는 모든 도구에 `workspace` 파라미터로 서비스를 격리하세요
39+
````
40+
41+
---
42+
43+
## 최소 버전
44+
45+
```markdown
46+
## CCG
47+
48+
코드 분석 시 `get_minimal_context`를 먼저 호출하세요. 그래프 상태와 다음 단계를 안내합니다.
49+
```

internal/cli/build.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
func newBuildCmd(deps *Deps) *cobra.Command {
1919
var excludePatterns []string
2020
var noRecursive bool
21+
var includePaths []string
2122

2223
cmd := &cobra.Command{
2324
Use: "build [directory]",
@@ -30,6 +31,7 @@ func newBuildCmd(deps *Deps) *cobra.Command {
3031
}
3132

3233
patterns := resolveExcludes(excludePatterns)
34+
paths := resolveIncludePaths(includePaths)
3335

3436
svc := &service.GraphService{
3537
Store: deps.Store,
@@ -43,6 +45,7 @@ func newBuildCmd(deps *Deps) *cobra.Command {
4345
Dir: dir,
4446
NoRecursive: noRecursive,
4547
ExcludePatterns: patterns,
48+
IncludePaths: paths,
4649
}
4750

4851
ctx := context.Background()
@@ -62,6 +65,7 @@ func newBuildCmd(deps *Deps) *cobra.Command {
6265

6366
cmd.Flags().BoolVar(&noRecursive, "no-recursive", false, "Only parse files in the top-level directory, skip subdirectories")
6467
cmd.Flags().StringArrayVar(&excludePatterns, "exclude", nil, "Exclude files/directories matching pattern (repeatable, e.g. --exclude vendor --exclude *.pb.go)")
68+
cmd.Flags().StringArrayVar(&includePaths, "path", nil, "Only include specific paths (repeatable, e.g. --path src/api --path src/auth)")
6569

6670
return cmd
6771
}

internal/cli/build_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,61 @@ func Hello() {}
217217
}
218218
}
219219

220+
func TestBuildCommand_Path_OnlyIncludedPathsParsed(t *testing.T) {
221+
deps, stdout, stderr, db := setupBuildTest(t)
222+
223+
dir := t.TempDir()
224+
225+
apiDir := filepath.Join(dir, "src", "api")
226+
os.MkdirAll(apiDir, 0755)
227+
writeGoFile(t, apiDir, "handler.go", `package api
228+
func Handler() {}
229+
`)
230+
231+
authDir := filepath.Join(dir, "src", "auth")
232+
os.MkdirAll(authDir, 0755)
233+
writeGoFile(t, authDir, "auth.go", `package auth
234+
func Authenticate() {}
235+
`)
236+
237+
otherDir := filepath.Join(dir, "src", "other")
238+
os.MkdirAll(otherDir, 0755)
239+
writeGoFile(t, otherDir, "other.go", `package other
240+
func Other() {}
241+
`)
242+
243+
writeGoFile(t, dir, "root.go", `package root
244+
func Root() {}
245+
`)
246+
247+
err := executeCmd(deps, stdout, stderr, "build", "--path", "src/api", "--path", "src/auth", dir)
248+
if err != nil {
249+
t.Fatalf("unexpected error: %v", err)
250+
}
251+
252+
var nodes []model.Node
253+
db.Find(&nodes)
254+
255+
foundHandler := false
256+
foundAuth := false
257+
for _, n := range nodes {
258+
switch n.Name {
259+
case "Handler":
260+
foundHandler = true
261+
case "Authenticate":
262+
foundAuth = true
263+
case "Other", "Root":
264+
t.Errorf("--path should exclude %s, but it was parsed", n.Name)
265+
}
266+
}
267+
if !foundHandler {
268+
t.Error("expected Handler function to be parsed (in --path src/api)")
269+
}
270+
if !foundAuth {
271+
t.Error("expected Authenticate function to be parsed (in --path src/auth)")
272+
}
273+
}
274+
220275
func TestBuildCommand_NoRecursive_SkipsSubdirs(t *testing.T) {
221276
deps, stdout, stderr, db := setupBuildTest(t)
222277

internal/cli/config_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"testing"
88

99
"github.com/spf13/viper"
10+
11+
"github.com/imtaebin/code-context-graph/internal/model"
1012
)
1113

1214
func TestResolveExcludes_MergesConfigAndFlag(t *testing.T) {
@@ -84,6 +86,103 @@ func TestResolveOutDir_PrefersFlagOverConfig(t *testing.T) {
8486
viper.Reset()
8587
}
8688

89+
func TestResolveIncludePaths_MergesConfigAndFlag(t *testing.T) {
90+
viper.Reset()
91+
viper.Set("include_paths", []string{"src/api"})
92+
93+
result := resolveIncludePaths([]string{"src/auth"})
94+
95+
if len(result) != 2 {
96+
t.Fatalf("expected 2 paths, got %d: %v", len(result), result)
97+
}
98+
99+
has := func(s string) bool {
100+
for _, r := range result {
101+
if r == s {
102+
return true
103+
}
104+
}
105+
return false
106+
}
107+
108+
for _, want := range []string{"src/api", "src/auth"} {
109+
if !has(want) {
110+
t.Errorf("expected %q in result %v", want, result)
111+
}
112+
}
113+
114+
viper.Reset()
115+
}
116+
117+
func TestResolveIncludePaths_EmptyFlagUsesConfig(t *testing.T) {
118+
viper.Reset()
119+
viper.Set("include_paths", []string{"src/core", "src/api"})
120+
121+
result := resolveIncludePaths(nil)
122+
if len(result) != 2 {
123+
t.Fatalf("expected 2 paths, got %d: %v", len(result), result)
124+
}
125+
126+
viper.Reset()
127+
}
128+
129+
func TestResolveIncludePaths_EmptyBothReturnsNil(t *testing.T) {
130+
viper.Reset()
131+
132+
result := resolveIncludePaths(nil)
133+
if len(result) != 0 {
134+
t.Fatalf("expected empty, got %v", result)
135+
}
136+
137+
viper.Reset()
138+
}
139+
140+
func TestBuildCommand_PathFromConfig(t *testing.T) {
141+
deps, stdout, stderr, db := setupBuildTest(t)
142+
143+
dir := t.TempDir()
144+
145+
apiDir := filepath.Join(dir, "src", "api")
146+
os.MkdirAll(apiDir, 0755)
147+
writeGoFile(t, apiDir, "handler.go", `package api
148+
func Handler() {}
149+
`)
150+
151+
otherDir := filepath.Join(dir, "src", "other")
152+
os.MkdirAll(otherDir, 0755)
153+
writeGoFile(t, otherDir, "other.go", `package other
154+
func Other() {}
155+
`)
156+
157+
viper.Reset()
158+
viper.Set("include_paths", []string{"src/api"})
159+
defer viper.Reset()
160+
161+
err := executeCmd(deps, stdout, stderr, "build", dir)
162+
if err != nil {
163+
t.Fatalf("unexpected error: %v", err)
164+
}
165+
166+
var nodes []model.Node
167+
db.Find(&nodes)
168+
169+
for _, n := range nodes {
170+
if n.Name == "Other" {
171+
t.Error("expected Other NOT to be parsed when config has include_paths=[src/api]")
172+
}
173+
}
174+
175+
foundHandler := false
176+
for _, n := range nodes {
177+
if n.Name == "Handler" {
178+
foundHandler = true
179+
}
180+
}
181+
if !foundHandler {
182+
t.Error("expected Handler to be parsed (in config include_paths)")
183+
}
184+
}
185+
87186
func TestConfigFlag_LoadsYAML(t *testing.T) {
88187
cfgFile := filepath.Join(t.TempDir(), ".ccg.yaml")
89188
if err := os.WriteFile(cfgFile, []byte("exclude:\n - vendor\n - \"*.pb.go\"\n"), 0o644); err != nil {

internal/cli/root.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,17 @@ func resolveExcludes(flagPatterns []string) []string {
192192
combined = append(combined, flagPatterns...)
193193
return combined
194194
}
195+
196+
func resolveIncludePaths(flagPaths []string) []string {
197+
cfgPaths := viper.GetStringSlice("include_paths")
198+
if len(cfgPaths) == 0 {
199+
return flagPaths
200+
}
201+
if len(flagPaths) == 0 {
202+
return cfgPaths
203+
}
204+
combined := make([]string, 0, len(cfgPaths)+len(flagPaths))
205+
combined = append(combined, cfgPaths...)
206+
combined = append(combined, flagPaths...)
207+
return combined
208+
}

0 commit comments

Comments
 (0)