diff --git a/.claude/skills/ccg-analyze/SKILL.md b/.claude/skills/ccg-analyze/SKILL.md index bf8c3a6..0ec1ea3 100644 --- a/.claude/skills/ccg-analyze/SKILL.md +++ b/.claude/skills/ccg-analyze/SKILL.md @@ -15,11 +15,7 @@ Graph-based analysis for **change impact, call flow, dead code, module structure | "Trace call flow from this function" | `trace_flow` | If broken at interfaces, see workaround below | | "Who calls this function?" | `query_graph` (callers_of) | | | "What does this function call?" | `query_graph` (callees_of) | | -| "Unused code" | `find_dead_code` | Interface methods may give false positives | -| "Large functions" | `find_large_functions` | Refactoring candidates | | "Risk of this change" | `detect_changes` + `get_affected_flows` | git diff-based | -| "Module structure" | `list_communities` + `get_architecture_overview` | First time on a codebase | -| "Test coverage gaps" | `get_community` (with coverage) | | ## trace_flow Limitations & Workaround @@ -67,14 +63,9 @@ If results are huge, the change scope is likely too wide. Reconsider the change | --------------------------- | ---------------------------- | | `get_impact_radius` | BFS blast radius | | `trace_flow` | Call chain trace | -| `find_large_functions` | Above line threshold | -| `find_dead_code` | No callers | | `detect_changes` | Git diff risk score | | `get_affected_flows` | Flows affected by change | | `list_flows` | Stored flow list | -| `list_communities` | Louvain module clusters | -| `get_community` | Community details + coverage | -| `get_architecture_overview` | Coupling summary | For detailed parameters, see MCP schema. diff --git a/CLAUDE.md b/CLAUDE.md index 0dc3b79..e22713c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,17 +6,16 @@ Follow the global prompt rules first. This file adds project-specific skill rout ## MCP 서버 -`.mcp.json`에 등록된 ccg MCP 서버가 29개 도구를 제공합니다: +`.mcp.json`에 등록된 ccg MCP 서버가 26개 도구를 제공합니다: - `parse_project`, `build_or_update_graph`, `run_postprocess` +- `get_postprocess_policy`, `reset_postprocess_policy` - `get_node`, `search`, `query_graph`, `list_graph_stats`, `get_minimal_context` -- `get_impact_radius`, `trace_flow` -- `find_large_functions`, `find_dead_code` +- `get_impact_radius`, `trace_flow`, `find_suspect_fallback_edges` - `detect_changes`, `get_affected_flows`, `list_flows` -- `list_communities`, `get_community`, `get_architecture_overview` - `get_annotation` -- `build_rag_index`, `get_rag_tree`, `get_doc_content`, `search_docs` -- `upload_file`, `upload_files`, `list_workspaces`, `list_files`, `delete_file`, `delete_workspace` +- `get_doc_content`, `search_docs`, `retrieve_docs` +- `upload_file`, `upload_files`, `list_files`, `delete_file`, `list_namespaces`, `delete_namespace` HTTP 모드 (`--transport streamable-http`)에서는 `/health` 및 `/webhook` 엔드포인트도 제공합니다. Webhook은 `--allow-repo` 플래그로 허용 리포지토리를 설정하면 활성화됩니다. @@ -50,7 +49,7 @@ Graceful shutdown: SIGINT/SIGTERM 시 진행 중인 clone/build에 context cance 상세 문서는 `guide/` 디렉토리를 참조하세요: - [CLI Reference](guide/cli-reference.md) — 전체 명령어, 플래그, 설정 파일 -- [MCP Tools](guide/mcp-tools.md) — 29개 MCP 도구, Skills, AI-Driven Annotation +- [MCP Tools](guide/mcp-tools.md) — 26개 MCP 도구, Skills, AI-Driven Annotation - [Annotations](guide/annotations.md) — 어노테이션 태그, 예시, 검색 - [Webhook](guide/webhook.md) — Webhook sync, 브랜치 필터링, HMAC, graceful shutdown - [Docker](guide/docker.md) — Docker 빌드, MCP 서버, PostgreSQL 배포 diff --git a/guide/architecture.md b/guide/architecture.md index 3774472..2a94208 100644 --- a/guide/architecture.md +++ b/guide/architecture.md @@ -54,11 +54,6 @@ queries. |--------|-------------| | `impact` | BFS blast-radius analysis | | `flows` | Call-chain flow tracing | -| `deadcode` | Unused code detection | -| `community` | Directory-based module communities | -| `coupling` | Inter-module coupling analysis | -| `coverage` | Test coverage analysis | -| `largefunc` | Large function detection | | `changes` | Git diff risk scoring | | `query` | Graph queries (callers, callees, imports) | | `incremental` | Incremental update | diff --git a/guide/cli-reference.md b/guide/cli-reference.md index 1b4f0ce..110bcae 100644 --- a/guide/cli-reference.md +++ b/guide/cli-reference.md @@ -74,7 +74,7 @@ CCG has two search surfaces with different jobs: |----------|----------------------| | Natural-language code understanding, module exploration, architecture questions | `ccg docs`, then MCP `retrieve_docs`, `get_rag_tree`, `get_doc_content` | | Exact symbol lookup, callers/callees, imports, bounded graph traversal | MCP `get_node`, `query_graph`, `get_minimal_context` | -| Impact analysis, flow tracing, dead code, large functions | MCP analysis tools such as `get_impact_radius`, `trace_flow`, `find_dead_code`, `find_large_functions` | +| Impact analysis, flow tracing | MCP analysis tools such as `get_impact_radius`, `trace_flow` | | Focused annotation/keyword candidate search | `ccg search` or MCP `search` | For coding agents, the recommended natural-language path is: diff --git a/guide/ko/architecture.md b/guide/ko/architecture.md index e7e0435..b7df6f8 100644 --- a/guide/ko/architecture.md +++ b/guide/ko/architecture.md @@ -51,11 +51,6 @@ GORM ORM 기반 저장소입니다. SQLite 및 PostgreSQL과 호환됩니다. |--------|-------------| | `impact` | BFS 영향 범위 분석 | | `flows` | 호출 체인 흐름 추적 | -| `deadcode` | 사용되지 않는 코드 감지 | -| `community` | 디렉토리 기반 모듈 커뮤니티 분석 | -| `coupling` | 모듈 간 결합도 분석 | -| `coverage` | 테스트 커버리지 분석 | -| `largefunc` | 대규모 함수 감지 | | `changes` | Git diff 리스크 점수 계산 | | `query` | 그래프 쿼리 (callers, callees, imports) | | `incremental` | 증분 업데이트 | diff --git a/guide/ko/cli-reference.md b/guide/ko/cli-reference.md index 8888149..9dd14a2 100644 --- a/guide/ko/cli-reference.md +++ b/guide/ko/cli-reference.md @@ -76,7 +76,7 @@ CCG에는 역할이 다른 두 검색 표면이 있습니다. |----------|-------------| | 자연어 기반 코드 이해, 모듈 탐색, 아키텍처 질문 | `ccg docs`, 이후 MCP `retrieve_docs`, `get_rag_tree`, `get_doc_content` | | 정확한 심볼 조회, caller/callee, import, bounded graph traversal | MCP `get_node`, `query_graph`, `get_minimal_context` | -| 영향 분석, flow 추적, dead code, large function | `get_impact_radius`, `trace_flow`, `find_dead_code`, `find_large_functions` 같은 MCP 분석 도구 | +| 영향 분석, flow 추적 | `get_impact_radius`, `trace_flow` 같은 MCP 분석 도구 | | 어노테이션/키워드 기반 후보 검색 | `ccg search` 또는 MCP `search` | 코딩 에이전트의 자연어 탐색에는 다음 흐름을 권장합니다. diff --git a/guide/ko/mcp-tools.md b/guide/ko/mcp-tools.md index ee4c64f..6f5590b 100644 --- a/guide/ko/mcp-tools.md +++ b/guide/ko/mcp-tools.md @@ -62,8 +62,6 @@ CCG는 아직 Prometheus `/metrics` 엔드포인트를 제공하지 않습니다 |------|-------------| | `get_impact_radius` | BFS 영향 범위(blast-radius) 분석 | | `trace_flow` | 호출 체인 흐름 추적 | -| `find_large_functions` | 라인 제한을 초과하는 함수 찾기; `limit` 지원 | -| `find_dead_code` | 사용되지 않는 코드 감지 | | `find_suspect_fallback_edges` | 의심스러운 fallback 호출 엣지 품질 리포트, 페이지네이션 지원 | | `detect_changes` | Git diff 리스크 점수 계산 | | `get_affected_flows` | 변경 사항의 영향을 받는 흐름 확인 | @@ -93,9 +91,8 @@ CCG는 아직 Prometheus `/metrics` 엔드포인트를 제공하지 않습니다 | `get_architecture_overview` | `community_limit`, `community_offset`, `coupling_limit`, `coupling_offset` | 커뮤니티와 결합도에 별도 페이지네이션 객체 포함 | 일부 분석 도구는 아직 내부적으로 전체 결과를 조회합니다. 큰 네임스페이스에서는 -`find_dead_code`, `find_suspect_fallback_edges`, 또는 광범위한 MCP prompt를 -호출하기 전에 입력 범위를 좁히십시오. `find_large_functions`는 `limit`을 -받지만 현재는 라인 기준 쿼리를 수행한 뒤 응답을 자릅니다. +`find_suspect_fallback_edges` 또는 광범위한 MCP prompt를 호출하기 전에 입력 +범위를 좁히십시오. ### 어노테이션 및 문서화 (Annotation & Documentation) diff --git a/guide/ko/operations.md b/guide/ko/operations.md index 02cc3e9..f86012b 100644 --- a/guide/ko/operations.md +++ b/guide/ko/operations.md @@ -68,10 +68,8 @@ 사용 전에 범위를 좁혀야 하는 고용량 표면: -- `find_dead_code`는 네임스페이스의 모든 미참조 노드를 반환할 수 있습니다. - `find_suspect_fallback_edges`는 모든 의심 fallback edge를 반환할 수 있습니다. -- `find_large_functions`는 `limit`을 받지만 현재 구현은 모든 매칭 함수를 찾은 뒤 응답을 자릅니다. -- architecture/onboarding 같은 MCP prompt는 넓은 프로젝트 상태를 요약하므로 네임스페이스로 그래프를 좁힌 뒤 사용하는 것이 좋습니다. +- onboarding 같은 MCP prompt는 넓은 프로젝트 상태를 요약하므로 네임스페이스로 그래프를 좁힌 뒤 사용하는 것이 좋습니다. 공유 서비스에서는 광범위한 분석 요청보다 경로 필터, 네임스페이스 분리, 페이지네이션 도구를 우선하십시오. 예상보다 큰 도구 응답은 네임스페이스가 너무 diff --git a/guide/mcp-tools.md b/guide/mcp-tools.md index 8b665cd..896658c 100644 --- a/guide/mcp-tools.md +++ b/guide/mcp-tools.md @@ -69,8 +69,6 @@ current machine-readable operational surfaces. |------|-------------| | `get_impact_radius` | BFS blast-radius analysis | | `trace_flow` | Call-chain flow tracing | -| `find_large_functions` | Functions exceeding line threshold; supports `limit` | -| `find_dead_code` | Unused code detection | | `find_suspect_fallback_edges` | Suspect fallback call-edge quality report with pagination | | `detect_changes` | Git diff risk scoring | | `get_affected_flows` | Flows affected by changes | @@ -99,10 +97,8 @@ communities, members, or coupling pairs. Paginated responses include | `get_architecture_overview` | `community_limit`, `community_offset`, `coupling_limit`, `coupling_offset` | Response includes separate community and coupling pagination objects | Some analysis tools still return full result sets internally. On large -namespaces, prefer scoped inputs before calling `find_dead_code`, -`find_suspect_fallback_edges`, or broad MCP prompts. `find_large_functions` -accepts `limit`, but it currently performs the line-threshold query before -truncating the response. +namespaces, prefer scoped inputs before calling `find_suspect_fallback_edges` +or broad MCP prompts. ### Annotation & Documentation diff --git a/guide/operations.md b/guide/operations.md index 79ca93f..7a14702 100644 --- a/guide/operations.md +++ b/guide/operations.md @@ -80,11 +80,8 @@ responses inspectable and reduces context pollution. Known high-volume surfaces that should be scoped before use: -- `find_dead_code` can return every unreferenced node in a namespace. - `find_suspect_fallback_edges` can return every suspect fallback edge. -- `find_large_functions` accepts `limit`, but the current implementation still - finds all matching functions before truncating the response. -- MCP prompts such as architecture and onboarding prompts summarize broad +- MCP prompts such as onboarding prompts summarize broad project state and are best used after the graph has been scoped by namespace. For shared services, prefer path filters, namespace splitting, and paginated diff --git a/internal/analysis/community/service.go b/internal/analysis/community/service.go deleted file mode 100644 index 395aa2c..0000000 --- a/internal/analysis/community/service.go +++ /dev/null @@ -1,298 +0,0 @@ -// @index 디렉토리 기반 커뮤니티 탐지. 코드베이스를 논리적 모듈로 분할하고 응집도를 측정한다. -package community - -import ( - "context" - "path" - "strings" - - "github.com/tae2089/code-context-graph/internal/ctxns" - "github.com/tae2089/code-context-graph/internal/model" - "gorm.io/gorm" -) - -// communityInsertBatchSize bounds the row count per bulk insert during community rebuild so a -// large namespace does not issue one INSERT per community or per membership. -const communityInsertBatchSize = 500 - -// Config controls directory-based community grouping. -// @intent define how file paths are collapsed into module community keys -type Config struct { - BaseDir string - Depth int -} - -// Stats summarizes one rebuilt community. -// @intent report membership and cohesion metrics for a detected community -type Stats struct { - Community model.Community - NodeCount int64 - InternalEdges int64 - ExternalEdges int64 - Cohesion float64 -} - -// Builder rebuilds communities from graph data. -// @intent persist directory-based module boundaries into community tables -type Builder struct { - db *gorm.DB -} - -// New creates a community builder. -// @intent construct a builder that writes detected communities to the database -func New(db *gorm.DB) *Builder { - return &Builder{db: db} -} - -// Rebuild creates communities by grouping nodes by directory path. -// Used by MCP run_postprocess tool and architecture_map prompt. -// -// @return community stats with node count, internal/external edges, cohesion score -// @intent partition codebase into logical modules for architecture analysis -// @domainRule groups nodes by file path directory up to configured depth -// @domainRule cohesion equals internal edges divided by total edges -// @sideEffect deletes all existing communities and memberships before rebuilding -// @mutates Community CommunityMembership tables -func (b *Builder) Rebuild(ctx context.Context, cfg Config) ([]Stats, error) { - var result []Stats - ns := ctxns.FromContext(ctx) - - err := b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := deleteCommunities(tx, ns); err != nil { - return err - } - - groups, err := groupNodesByDirectory(tx, ctx, cfg.Depth) - if err != nil { - return err - } - - communityMap, nodeComm, err := createCommunitiesAndMemberships(tx, groups, ns) - if err != nil { - return err - } - - counts, err := countEdgesByCommunity(tx, ns, groups, nodeComm) - if err != nil { - return err - } - - if err := aggregateDescriptions(tx, groups, communityMap); err != nil { - return err - } - - result = buildStats(groups, communityMap, counts) - return nil - }) - - return result, err -} - -// deleteCommunities removes existing communities and memberships for the namespace. -// @intent clear prior community state before a full rebuild -// @sideEffect deletes rows from community_memberships and communities tables -func deleteCommunities(tx *gorm.DB, ns string) error { - var ids []uint - if err := tx.Model(&model.Community{}). - Where("namespace = ?", ns). - Pluck("id", &ids).Error; err != nil { - return err - } - if len(ids) == 0 { - return nil - } - if err := tx.Where("community_id IN ?", ids).Delete(&model.CommunityMembership{}).Error; err != nil { - return err - } - return tx.Where("id IN ?", ids).Delete(&model.Community{}).Error -} - -// groupNodesByDirectory loads namespace nodes and buckets them by directory key. -// @intent produce the directory-keyed node groups that drive community creation -// @domainRule only id and file_path are needed for grouping and membership, so avoid loading full node rows. -func groupNodesByDirectory(tx *gorm.DB, ctx context.Context, depth int) (map[string][]model.Node, error) { - var nodes []model.Node - ns := ctxns.FromContext(ctx) - if err := tx.Model(&model.Node{}).Select("id", "file_path").Where("namespace = ?", ns).Find(&nodes).Error; err != nil { - return nil, err - } - - groups := map[string][]model.Node{} - for _, n := range nodes { - key := directoryKey(n.FilePath, depth) - groups[key] = append(groups[key], n) - } - return groups, nil -} - -// createCommunitiesAndMemberships persists one community per directory key -// and attaches every grouped node as a membership row. -// @intent materialize community and membership rows used by downstream analysis -// @sideEffect inserts into communities and community_memberships tables -// @return community lookup by key plus per-node directory key map -func createCommunitiesAndMemberships(tx *gorm.DB, groups map[string][]model.Node, ns string) (map[string]*model.Community, map[uint]string, error) { - communities := make([]model.Community, 0, len(groups)) - for key := range groups { - communities = append(communities, model.Community{ - Namespace: ns, - Key: key, - Label: key, - Strategy: "directory", - }) - } - if len(communities) > 0 { - if err := tx.CreateInBatches(communities, communityInsertBatchSize).Error; err != nil { - return nil, nil, err - } - } - communityMap := make(map[string]*model.Community, len(communities)) - for i := range communities { - communityMap[communities[i].Key] = &communities[i] - } - - nodeComm := map[uint]string{} - memberships := make([]model.CommunityMembership, 0, len(groups)) - for key, nodes := range groups { - communityID := communityMap[key].ID - for _, n := range nodes { - memberships = append(memberships, model.CommunityMembership{ - CommunityID: communityID, - NodeID: n.ID, - }) - nodeComm[n.ID] = key - } - } - if len(memberships) > 0 { - if err := tx.CreateInBatches(memberships, communityInsertBatchSize).Error; err != nil { - return nil, nil, err - } - } - - return communityMap, nodeComm, nil -} - -// edgeCounts holds internal vs external edge tallies for one community. -// @intent track per-community edge counts used to compute cohesion -type edgeCounts struct { - internal int64 - external int64 -} - -// countEdgesByCommunity tallies internal and cross-community edges in batches. -// @intent classify edges as internal or external relative to community membership -func countEdgesByCommunity(tx *gorm.DB, ns string, groups map[string][]model.Node, nodeComm map[uint]string) (map[string]*edgeCounts, error) { - counts := map[string]*edgeCounts{} - for key := range groups { - counts[key] = &edgeCounts{} - } - - var batchEdges []model.Edge - edgesQ := tx.Model(&model.Edge{}).Where("namespace = ?", ns) - if err := edgesQ.FindInBatches(&batchEdges, 500, func(tx *gorm.DB, batch int) error { - for _, e := range batchEdges { - fromKey, fromOK := nodeComm[e.FromNodeID] - toKey, toOK := nodeComm[e.ToNodeID] - if !fromOK || !toOK { - continue - } - if fromKey == toKey { - counts[fromKey].internal++ - } else { - counts[fromKey].external++ - } - } - return nil - }).Error; err != nil { - return nil, err - } - - return counts, nil -} - -// aggregateDescriptions joins file-level @index tags into a community description. -// @intent surface human-readable module summaries on each community row -// @sideEffect updates Community.Description fields in the database -func aggregateDescriptions(tx *gorm.DB, groups map[string][]model.Node, communityMap map[string]*model.Community) error { - fileNodeIDs := []uint{} - for _, ns := range groups { - for _, n := range ns { - if n.Kind == model.NodeKindFile { - fileNodeIDs = append(fileNodeIDs, n.ID) - } - } - } - if len(fileNodeIDs) == 0 { - return nil - } - - annByNode := map[uint]*model.Annotation{} - var annotations []model.Annotation - if err := tx.Where("node_id IN ?", fileNodeIDs).Preload("Tags").Find(&annotations).Error; err != nil { - return err - } - for i := range annotations { - annByNode[annotations[i].NodeID] = &annotations[i] - } - - for key, c := range communityMap { - var descriptions []string - for _, n := range groups[key] { - if n.Kind != model.NodeKindFile { - continue - } - if ann := annByNode[n.ID]; ann != nil { - for _, tag := range ann.Tags { - if tag.Kind == model.TagIndex { - descriptions = append(descriptions, tag.Value) - } - } - } - } - if len(descriptions) > 0 { - c.Description = strings.Join(descriptions, "; ") - if err := tx.Save(c).Error; err != nil { - return err - } - } - } - - return nil -} - -// buildStats assembles the per-community Stats records returned to callers. -// @intent compute cohesion and pack community results for the Rebuild response -func buildStats(groups map[string][]model.Node, communityMap map[string]*model.Community, counts map[string]*edgeCounts) []Stats { - var result []Stats - for key, c := range communityMap { - ec := counts[key] - var cohesion float64 - total := ec.internal + ec.external - if total > 0 { - cohesion = float64(ec.internal) / float64(total) - } - - result = append(result, Stats{ - Community: *c, - NodeCount: int64(len(groups[key])), - InternalEdges: ec.internal, - ExternalEdges: ec.external, - Cohesion: cohesion, - }) - } - return result -} - -// directoryKey derives a community key from a file path. -// @intent normalize file paths into stable directory group identifiers -// @param filePath repository-relative source file path -// @param depth maximum number of directory segments to keep -// @return grouped directory prefix used as the community key -// @ensures returned key contains at most depth path segments -func directoryKey(filePath string, depth int) string { - dir := path.Dir(filePath) - parts := strings.Split(dir, "/") - if len(parts) > depth { - parts = parts[:depth] - } - return strings.Join(parts, "/") -} diff --git a/internal/analysis/community/service_test.go b/internal/analysis/community/service_test.go deleted file mode 100644 index fa732ff..0000000 --- a/internal/analysis/community/service_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package community - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "github.com/tae2089/code-context-graph/internal/ctxns" - "github.com/tae2089/code-context-graph/internal/model" - "github.com/tae2089/code-context-graph/internal/store/gormstore" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - gormlogger "gorm.io/gorm/logger" -) - -func setupDB(t *testing.T) *gorm.DB { - t.Helper() - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - Logger: gormlogger.Discard, - }) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := gormstore.New(db).AutoMigrate(); err != nil { - t.Fatalf("migrate: %v", err) - } - return db -} - -func seedNode(t *testing.T, db *gorm.DB, id uint, name string, file string) { - t.Helper() - seedNodeNS(t, db, id, name, file, ctxns.DefaultNamespace) -} - -func seedNodeNS(t *testing.T, db *gorm.DB, id uint, name string, file string, ns string) { - t.Helper() - n := model.Node{ - ID: id, - QualifiedName: fmt.Sprintf("%s::%s", file, name), - Namespace: ns, - Kind: model.NodeKindFunction, - Name: name, - FilePath: file, - StartLine: 1, - EndLine: 10, - Language: "go", - } - if err := db.Create(&n).Error; err != nil { - t.Fatalf("seed node: %v", err) - } -} - -func seedEdge(t *testing.T, db *gorm.DB, from, to uint) { - t.Helper() - e := model.Edge{ - FromNodeID: from, - ToNodeID: to, - Kind: model.EdgeKindCalls, - Fingerprint: fmt.Sprintf("%d-%d", from, to), - } - if err := db.Create(&e).Error; err != nil { - t.Fatalf("seed edge: %v", err) - } -} - -type insertCountLogger struct { - gormlogger.Interface - membershipInserts int -} - -func (l *insertCountLogger) LogMode(gormlogger.LogLevel) gormlogger.Interface { return l } -func (l *insertCountLogger) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) { - sql, _ := fc() - if strings.Contains(sql, "INSERT INTO") && strings.Contains(sql, "community_memberships") { - l.membershipInserts++ - } -} - -func TestRebuild_BatchesMembershipInserts(t *testing.T) { - counter := &insertCountLogger{Interface: gormlogger.Discard} - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: counter}) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := gormstore.New(db).AutoMigrate(); err != nil { - t.Fatalf("migrate: %v", err) - } - // 40 nodes in one directory => 40 memberships that must not be 40 separate INSERTs. - for i := uint(1); i <= 40; i++ { - seedNode(t, db, i, fmt.Sprintf("N%d", i), fmt.Sprintf("pkg/f%d.go", i)) - } - - counter.membershipInserts = 0 - if _, err := New(db).Rebuild(context.Background(), Config{Depth: 1}); err != nil { - t.Fatal(err) - } - if counter.membershipInserts != 1 { - t.Fatalf("expected memberships to insert in a single batch, got %d INSERT statements", counter.membershipInserts) - } -} - -func TestRebuild_GroupsByDirectory(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "X", "a/x.go") - seedNode(t, db, 2, "Y", "a/y.go") - seedNode(t, db, 3, "Z", "b/z.go") - - b := New(db) - stats, err := b.Rebuild(context.Background(), Config{Depth: 1}) - if err != nil { - t.Fatal(err) - } - if len(stats) != 2 { - t.Fatalf("expected 2 communities, got %d", len(stats)) - } -} - -func TestRebuild_DepthConfig(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "X", "a/b/x.go") - seedNode(t, db, 2, "Y", "a/c/y.go") - - b := New(db) - stats, err := b.Rebuild(context.Background(), Config{Depth: 2}) - if err != nil { - t.Fatal(err) - } - if len(stats) != 2 { - t.Fatalf("expected 2 communities (a/b, a/c), got %d", len(stats)) - } -} - -func TestRebuild_Depth1(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "X", "a/b/x.go") - seedNode(t, db, 2, "Y", "a/c/y.go") - - b := New(db) - stats, err := b.Rebuild(context.Background(), Config{Depth: 1}) - if err != nil { - t.Fatal(err) - } - if len(stats) != 1 { - t.Fatalf("expected 1 community (a), got %d", len(stats)) - } -} - -func TestRebuild_CohesionScore(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "A1", "a/a1.go") - seedNode(t, db, 2, "A2", "a/a2.go") - seedNode(t, db, 3, "A3", "a/a3.go") - seedNode(t, db, 4, "B1", "b/b1.go") - seedEdge(t, db, 1, 2) // internal - seedEdge(t, db, 2, 3) // internal - seedEdge(t, db, 3, 1) // internal - seedEdge(t, db, 1, 4) // external - - b := New(db) - stats, err := b.Rebuild(context.Background(), Config{Depth: 1}) - if err != nil { - t.Fatal(err) - } - - var aStat *Stats - for i := range stats { - if stats[i].Community.Key == "a" { - aStat = &stats[i] - break - } - } - if aStat == nil { - t.Fatal("community 'a' not found") - } - if aStat.InternalEdges != 3 { - t.Errorf("expected 3 internal edges, got %d", aStat.InternalEdges) - } - if aStat.ExternalEdges != 1 { - t.Errorf("expected 1 external edge, got %d", aStat.ExternalEdges) - } - expectedCohesion := 0.75 - if aStat.Cohesion != expectedCohesion { - t.Errorf("expected cohesion %.2f, got %.2f", expectedCohesion, aStat.Cohesion) - } -} - -func TestRebuild_NoEdges(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "A", "a/a.go") - - b := New(db) - stats, err := b.Rebuild(context.Background(), Config{Depth: 1}) - if err != nil { - t.Fatal(err) - } - if len(stats) != 1 { - t.Fatalf("expected 1 community, got %d", len(stats)) - } - if stats[0].Cohesion != 0.0 { - t.Errorf("expected cohesion 0.0, got %.2f", stats[0].Cohesion) - } -} - -func TestRebuild_ReplacesPrevious(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "A", "a/a.go") - - b := New(db) - _, err := b.Rebuild(context.Background(), Config{Depth: 1}) - if err != nil { - t.Fatal(err) - } - - var count1 int64 - db.Model(&model.Community{}).Count(&count1) - - _, err = b.Rebuild(context.Background(), Config{Depth: 1}) - if err != nil { - t.Fatal(err) - } - - var count2 int64 - db.Model(&model.Community{}).Count(&count2) - - if count2 != count1 { - t.Errorf("expected same count after rebuild, got %d then %d", count1, count2) - } -} - -func TestRebuild_MembershipLinks(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "X", "a/x.go") - seedNode(t, db, 2, "Y", "a/y.go") - seedNode(t, db, 3, "Z", "b/z.go") - - b := New(db) - _, err := b.Rebuild(context.Background(), Config{Depth: 1}) - if err != nil { - t.Fatal(err) - } - - var memberships []model.CommunityMembership - if err := db.Find(&memberships).Error; err != nil { - t.Fatal(err) - } - if len(memberships) != 3 { - t.Fatalf("expected 3 memberships, got %d", len(memberships)) - } - - nodeComm := map[uint]uint{} - for _, m := range memberships { - if _, exists := nodeComm[m.NodeID]; exists { - t.Errorf("node %d has multiple memberships", m.NodeID) - } - nodeComm[m.NodeID] = m.CommunityID - } - if nodeComm[1] != nodeComm[2] { - t.Error("nodes 1 and 2 should be in the same community") - } - if nodeComm[1] == nodeComm[3] { - t.Error("nodes 1 and 3 should be in different communities") - } -} - -func TestRebuild_RespectsNamespace(t *testing.T) { - db := setupDB(t) - seedNodeNS(t, db, 1, "A1", "a/a1.go", "ns-a") - seedNodeNS(t, db, 2, "A2", "a/a2.go", "ns-a") - seedNodeNS(t, db, 3, "B1", "b/b1.go", "ns-b") - - b := New(db) - - ctxA := ctxns.WithNamespace(context.Background(), "ns-a") - stats, err := b.Rebuild(ctxA, Config{Depth: 1}) - if err != nil { - t.Fatal(err) - } - if len(stats) != 1 { - t.Fatalf("expected 1 community for ns-a, got %d", len(stats)) - } - - var totalNodes int64 - for _, s := range stats { - totalNodes += s.NodeCount - } - if totalNodes != 2 { - t.Errorf("expected 2 nodes in ns-a communities, got %d", totalNodes) - } -} - -func TestRebuild_EmptyNamespace_DoesNotDeleteOtherNamespaceCommunities(t *testing.T) { - db := setupDB(t) - seedNodeNS(t, db, 1, "Default", "default/a.go", "") - seedNodeNS(t, db, 2, "Tenant", "tenant/b.go", "tenant-a") - - foreignCommunity := model.Community{Namespace: "tenant-a", Key: "tenant-a/core", Label: "tenant-a/core", Strategy: "directory"} - if err := db.Create(&foreignCommunity).Error; err != nil { - t.Fatalf("seed foreign community: %v", err) - } - if err := db.Create(&model.CommunityMembership{CommunityID: foreignCommunity.ID, NodeID: 2}).Error; err != nil { - t.Fatalf("seed foreign membership: %v", err) - } - - b := New(db) - stats, err := b.Rebuild(context.Background(), Config{Depth: 1}) - if err != nil { - t.Fatal(err) - } - if len(stats) != 1 { - t.Fatalf("expected 1 default-namespace community, got %d", len(stats)) - } - - var foreignCount int64 - if err := db.Model(&model.Community{}).Where("namespace = ?", "tenant-a").Count(&foreignCount).Error; err != nil { - t.Fatalf("count foreign communities: %v", err) - } - if foreignCount != 1 { - t.Fatalf("expected tenant-a community preserved, got %d", foreignCount) - } -} - -func TestRebuild_EmptyNamespace_DeletesStaleDefaultNamespaceParents(t *testing.T) { - db := setupDB(t) - seedNodeNS(t, db, 1, "Default", "default/a.go", ctxns.DefaultNamespace) - - stale := model.Community{Namespace: ctxns.DefaultNamespace, Key: "stale", Label: "stale", Strategy: "directory"} - if err := db.Create(&stale).Error; err != nil { - t.Fatalf("seed stale community: %v", err) - } - - b := New(db) - stats, err := b.Rebuild(context.Background(), Config{Depth: 1}) - if err != nil { - t.Fatal(err) - } - if len(stats) != 1 { - t.Fatalf("expected 1 rebuilt default-namespace community, got %d", len(stats)) - } - - var staleCount int64 - if err := db.Model(&model.Community{}).Where("id = ?", stale.ID).Count(&staleCount).Error; err != nil { - t.Fatalf("count stale community: %v", err) - } - if staleCount != 0 { - t.Fatalf("expected stale default-namespace parent deleted, got %d", staleCount) - } -} - -func TestRebuild_AfterDeleteGraph_CleansStaleParentsAndRecreatesCommunity(t *testing.T) { - db := setupDB(t) - store := gormstore.New(db) - ctx := context.Background() - - seedNodeNS(t, db, 1, "Default", "default/a.go", ctxns.DefaultNamespace) - - stale := model.Community{Namespace: ctxns.DefaultNamespace, Key: "default", Label: "default", Strategy: "directory"} - if err := db.Create(&stale).Error; err != nil { - t.Fatalf("seed stale community: %v", err) - } - if err := db.Create(&model.CommunityMembership{CommunityID: stale.ID, NodeID: 1}).Error; err != nil { - t.Fatalf("seed stale membership: %v", err) - } - - if err := store.DeleteGraph(ctx); err != nil { - t.Fatalf("DeleteGraph: %v", err) - } - - seedNodeNS(t, db, 2, "DefaultNew", "default/b.go", ctxns.DefaultNamespace) - - b := New(db) - stats, err := b.Rebuild(ctx, Config{Depth: 1}) - if err != nil { - t.Fatalf("Rebuild after DeleteGraph: %v", err) - } - if len(stats) != 1 { - t.Fatalf("expected 1 rebuilt community, got %d", len(stats)) - } - - var count int64 - if err := db.Model(&model.Community{}).Where("namespace = ? AND key = ?", ctxns.DefaultNamespace, "default").Count(&count).Error; err != nil { - t.Fatalf("count rebuilt communities: %v", err) - } - if count != 1 { - t.Fatalf("expected exactly 1 default/default community after rebuild, got %d", count) - } -} diff --git a/internal/analysis/coupling/service.go b/internal/analysis/coupling/service.go deleted file mode 100644 index f22cc99..0000000 --- a/internal/analysis/coupling/service.go +++ /dev/null @@ -1,257 +0,0 @@ -// @index 모듈 간 결합도 분석. 커뮤니티 경계를 넘는 엣지를 집계하여 아키텍처 결합 강도를 측정한다. -package coupling - -import ( - "context" - "sort" - - "github.com/tae2089/code-context-graph/internal/ctxns" - "github.com/tae2089/code-context-graph/internal/model" - "github.com/tae2089/code-context-graph/internal/paging" - "gorm.io/gorm" -) - -// CouplingPair represents coupling strength between two communities. -// @intent expose cross-community dependency counts in a normalized form -type CouplingPair struct { - FromCommunity string - ToCommunity string - EdgeCount int64 - Strength float64 -} - -// Result carries one bounded page of coupling pairs plus pagination metadata. -// @intent expose paged architecture-coupling results so MCP handlers stop slicing unbounded slices in memory. -type Result struct { - Items []CouplingPair - Pagination paging.Page -} - -// Service analyzes architectural coupling from graph edges. -// @intent measure dependency strength between detected communities -type Service struct { - db *gorm.DB -} - -// New creates a coupling analysis service. -// @intent construct a service for cross-community dependency queries -func New(db *gorm.DB) *Service { - return &Service{db: db} -} - -// pairRow holds one cross-community edge aggregation row. -// @intent transport GROUP BY join results from Analyze into post-processing. -type pairRow struct { - FromCommID uint - ToCommID uint - EdgeCount int64 -} - -// Analyze measures coupling strength between communities. -// Used by MCP get_architecture_overview tool and architecture_map prompt. -// -// @return pairs of communities with cross-community edge counts and strength -// @intent detect tightly coupled modules for architecture improvement -// @domainRule strength equals edge count divided by maximum edge count across all pairs -// @domainRule only cross-community edges are counted -func (s *Service) Analyze(ctx context.Context) ([]CouplingPair, error) { - ns := ctxns.FromContext(ctx) - var rows []pairRow - q := s.db.WithContext(ctx). - Model(&model.Edge{}). - Select("cm1.community_id as from_comm_id, cm2.community_id as to_comm_id, COUNT(*) as edge_count"). - Joins("JOIN community_memberships cm1 ON cm1.node_id = edges.from_node_id"). - Joins("JOIN community_memberships cm2 ON cm2.node_id = edges.to_node_id"). - Joins("JOIN nodes n1 ON n1.id = edges.from_node_id"). - Where("cm1.community_id != cm2.community_id") - q = q.Where("edges.namespace = ? AND n1.namespace = ?", ns, ns) - if err := q.Group("cm1.community_id, cm2.community_id"). - Scan(&rows).Error; err != nil { - return nil, err - } - - if len(rows) == 0 { - return nil, nil - } - - commIDs := make([]uint, 0, len(rows)*2) - seen := map[uint]struct{}{} - for _, r := range rows { - if _, ok := seen[r.FromCommID]; !ok { - commIDs = append(commIDs, r.FromCommID) - seen[r.FromCommID] = struct{}{} - } - if _, ok := seen[r.ToCommID]; !ok { - commIDs = append(commIDs, r.ToCommID) - seen[r.ToCommID] = struct{}{} - } - } - - var communities []model.Community - if err := s.db.WithContext(ctx).Where("id IN ?", commIDs).Find(&communities).Error; err != nil { - return nil, err - } - commLabel := make(map[uint]string, len(communities)) - for _, c := range communities { - commLabel[c.ID] = c.Key - } - - var maxCount int64 - for _, r := range rows { - if r.EdgeCount > maxCount { - maxCount = r.EdgeCount - } - } - - result := make([]CouplingPair, 0, len(rows)) - for _, r := range rows { - result = append(result, CouplingPair{ - FromCommunity: commLabel[r.FromCommID], - ToCommunity: commLabel[r.ToCommID], - EdgeCount: r.EdgeCount, - Strength: float64(r.EdgeCount) / float64(maxCount), - }) - } - - sortCouplingPairs(result) - return result, nil -} - -// AnalyzePage returns one bounded page of coupling pairs. -// @intent bound handler response size and database aggregation work to one requested coupling window. -// @domainRule strength uses the maximum cross-community edge count across all pairs, but returned pair rows are paged in SQL. -// @domainRule pairs are sorted by descending strength, then descending edge count, then from/to community for stable pagination. -func (s *Service) AnalyzePage(ctx context.Context, req paging.Request) (Result, error) { - normalized, err := paging.Normalize(req) - if err != nil { - return Result{}, err - } - - maxCount, err := s.maxCrossCommunityEdgeCount(ctx) - if err != nil { - return Result{}, err - } - if maxCount == 0 { - return Result{Items: []CouplingPair{}, Pagination: paging.BuildPage(normalized, 0, false)}, nil - } - - rows, err := s.pageCrossCommunityRows(ctx, normalized) - if err != nil { - return Result{}, err - } - hasMore := len(rows) > normalized.Limit - if hasMore { - rows = rows[:normalized.Limit] - } - items, err := s.couplingPairsFromRows(ctx, rows, maxCount) - if err != nil { - return Result{}, err - } - return Result{Items: items, Pagination: paging.BuildPage(normalized, len(items), hasMore)}, nil -} - -// maxCrossCommunityEdgeCount returns the normalization denominator for coupling strength. -// @intent compute AnalyzePage strength without materializing every coupling pair. -func (s *Service) maxCrossCommunityEdgeCount(ctx context.Context) (int64, error) { - ns := ctxns.FromContext(ctx) - var row struct { - EdgeCount int64 - } - q := s.db.WithContext(ctx). - Model(&model.Edge{}). - Select("COUNT(*) as edge_count"). - Joins("JOIN community_memberships cm1 ON cm1.node_id = edges.from_node_id"). - Joins("JOIN community_memberships cm2 ON cm2.node_id = edges.to_node_id"). - Joins("JOIN nodes n1 ON n1.id = edges.from_node_id"). - Where("cm1.community_id != cm2.community_id"). - Where("edges.namespace = ? AND n1.namespace = ?", ns, ns). - Group("cm1.community_id, cm2.community_id") - if err := s.db.WithContext(ctx).Table("(?) as pair_counts", q). - Select("COALESCE(MAX(edge_count), 0) as edge_count"). - Scan(&row).Error; err != nil { - return 0, err - } - return row.EdgeCount, nil -} - -// pageCrossCommunityRows returns one ordered page plus one sentinel row for has_more. -// @intent push coupling pagination and deterministic ordering into SQL instead of slicing Analyze output. -func (s *Service) pageCrossCommunityRows(ctx context.Context, req paging.Request) ([]pairRow, error) { - ns := ctxns.FromContext(ctx) - var rows []pairRow - err := s.db.WithContext(ctx). - Model(&model.Edge{}). - Select("cm1.community_id as from_comm_id, cm2.community_id as to_comm_id, COUNT(*) as edge_count"). - Joins("JOIN community_memberships cm1 ON cm1.node_id = edges.from_node_id"). - Joins("JOIN community_memberships cm2 ON cm2.node_id = edges.to_node_id"). - Joins("JOIN nodes n1 ON n1.id = edges.from_node_id"). - Joins("JOIN communities c1 ON c1.id = cm1.community_id"). - Joins("JOIN communities c2 ON c2.id = cm2.community_id"). - Where("cm1.community_id != cm2.community_id"). - Where("edges.namespace = ? AND n1.namespace = ?", ns, ns). - Group("cm1.community_id, cm2.community_id, c1.key, c2.key"). - Order("edge_count DESC"). - Order("c1.key ASC"). - Order("c2.key ASC"). - Limit(req.Limit + 1). - Offset(req.Offset). - Scan(&rows).Error - return rows, err -} - -// couplingPairsFromRows maps aggregated community IDs to response labels and normalized strengths. -// @intent preserve Analyze response shape for SQL-paged coupling rows. -func (s *Service) couplingPairsFromRows(ctx context.Context, rows []pairRow, maxCount int64) ([]CouplingPair, error) { - if len(rows) == 0 { - return []CouplingPair{}, nil - } - commIDs := make([]uint, 0, len(rows)*2) - seen := map[uint]struct{}{} - for _, r := range rows { - if _, ok := seen[r.FromCommID]; !ok { - commIDs = append(commIDs, r.FromCommID) - seen[r.FromCommID] = struct{}{} - } - if _, ok := seen[r.ToCommID]; !ok { - commIDs = append(commIDs, r.ToCommID) - seen[r.ToCommID] = struct{}{} - } - } - - var communities []model.Community - if err := s.db.WithContext(ctx).Where("id IN ?", commIDs).Find(&communities).Error; err != nil { - return nil, err - } - commLabel := make(map[uint]string, len(communities)) - for _, c := range communities { - commLabel[c.ID] = c.Key - } - - items := make([]CouplingPair, 0, len(rows)) - for _, r := range rows { - items = append(items, CouplingPair{ - FromCommunity: commLabel[r.FromCommID], - ToCommunity: commLabel[r.ToCommID], - EdgeCount: r.EdgeCount, - Strength: float64(r.EdgeCount) / float64(maxCount), - }) - } - return items, nil -} - -// sortCouplingPairs orders pairs deterministically for stable pagination windows. -// @intent guarantee identical limit/offset slices regardless of map iteration order in Analyze. -func sortCouplingPairs(pairs []CouplingPair) { - sort.SliceStable(pairs, func(i, j int) bool { - if pairs[i].Strength != pairs[j].Strength { - return pairs[i].Strength > pairs[j].Strength - } - if pairs[i].EdgeCount != pairs[j].EdgeCount { - return pairs[i].EdgeCount > pairs[j].EdgeCount - } - if pairs[i].FromCommunity != pairs[j].FromCommunity { - return pairs[i].FromCommunity < pairs[j].FromCommunity - } - return pairs[i].ToCommunity < pairs[j].ToCommunity - }) -} diff --git a/internal/analysis/coupling/service_test.go b/internal/analysis/coupling/service_test.go deleted file mode 100644 index 9e9467b..0000000 --- a/internal/analysis/coupling/service_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package coupling - -import ( - "context" - "fmt" - "math" - "reflect" - "testing" - - "github.com/tae2089/code-context-graph/internal/model" - "github.com/tae2089/code-context-graph/internal/paging" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - gormlogger "gorm.io/gorm/logger" -) - -func setupDB(t *testing.T) *gorm.DB { - t.Helper() - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - Logger: gormlogger.Discard, - }) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(&model.Node{}, &model.Edge{}, &model.Community{}, &model.CommunityMembership{}); err != nil { - t.Fatalf("migrate: %v", err) - } - return db -} - -func seedNode(t *testing.T, db *gorm.DB, id uint, name, file string) { - t.Helper() - n := model.Node{ - ID: id, - QualifiedName: fmt.Sprintf("%s::%s", file, name), - Kind: model.NodeKindFunction, - Name: name, - FilePath: file, - StartLine: 1, - EndLine: 10, - Language: "go", - } - if err := db.Create(&n).Error; err != nil { - t.Fatalf("seed node: %v", err) - } -} - -func seedEdge(t *testing.T, db *gorm.DB, from, to uint) { - t.Helper() - e := model.Edge{ - FromNodeID: from, - ToNodeID: to, - Kind: model.EdgeKindCalls, - Fingerprint: fmt.Sprintf("%d-%d", from, to), - } - if err := db.Create(&e).Error; err != nil { - t.Fatalf("seed edge: %v", err) - } -} - -func seedCommunity(t *testing.T, db *gorm.DB, id uint, key string, nodeIDs ...uint) { - t.Helper() - c := model.Community{ID: id, Key: key, Label: key, Strategy: "directory"} - if err := db.Create(&c).Error; err != nil { - t.Fatalf("seed community: %v", err) - } - for _, nid := range nodeIDs { - m := model.CommunityMembership{CommunityID: id, NodeID: nid} - if err := db.Create(&m).Error; err != nil { - t.Fatalf("seed membership: %v", err) - } - } -} - -func findPair(pairs []CouplingPair, from, to string) *CouplingPair { - for i := range pairs { - if pairs[i].FromCommunity == from && pairs[i].ToCommunity == to { - return &pairs[i] - } - } - return nil -} - -func TestAnalyze_TwoCommunities(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "A1", "a/a.go") - seedNode(t, db, 2, "B1", "b/b.go") - seedCommunity(t, db, 1, "a", 1) - seedCommunity(t, db, 2, "b", 2) - for i := range 5 { - e := model.Edge{ - FromNodeID: 1, - ToNodeID: 2, - Kind: model.EdgeKindCalls, - Fingerprint: fmt.Sprintf("1-2-%d", i), - } - db.Create(&e) - } - - svc := New(db) - got, err := svc.Analyze(context.Background()) - if err != nil { - t.Fatal(err) - } - p := findPair(got, "a", "b") - if p == nil { - t.Fatal("expected coupling pair a→b") - } - if p.EdgeCount != 5 { - t.Errorf("expected 5 edges, got %d", p.EdgeCount) - } -} - -func TestAnalyze_NoCrossCommunityEdges(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "A1", "a/a.go") - seedNode(t, db, 2, "A2", "a/a2.go") - seedCommunity(t, db, 1, "a", 1, 2) - seedEdge(t, db, 1, 2) - - svc := New(db) - got, err := svc.Analyze(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("expected 0 cross-community pairs, got %d", len(got)) - } -} - -func TestAnalyze_Strength(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "A1", "a/a.go") - seedNode(t, db, 2, "B1", "b/b.go") - seedNode(t, db, 3, "C1", "c/c.go") - seedCommunity(t, db, 1, "a", 1) - seedCommunity(t, db, 2, "b", 2) - seedCommunity(t, db, 3, "c", 3) - for i := range 10 { - db.Create(&model.Edge{FromNodeID: 1, ToNodeID: 2, Kind: model.EdgeKindCalls, Fingerprint: fmt.Sprintf("ab-%d", i)}) - } - for i := range 5 { - db.Create(&model.Edge{FromNodeID: 1, ToNodeID: 3, Kind: model.EdgeKindCalls, Fingerprint: fmt.Sprintf("ac-%d", i)}) - } - - svc := New(db) - got, err := svc.Analyze(context.Background()) - if err != nil { - t.Fatal(err) - } - ab := findPair(got, "a", "b") - ac := findPair(got, "a", "c") - if ab == nil || ac == nil { - t.Fatal("expected both a→b and a→c pairs") - } - if ab.Strength != 1.0 { - t.Errorf("expected a→b strength=1.0, got %.2f", ab.Strength) - } - if math.Abs(ac.Strength-0.5) > 0.001 { - t.Errorf("expected a→c strength=0.5, got %.2f", ac.Strength) - } -} - -func TestAnalyze_BidirectionalCounting(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "A1", "a/a.go") - seedNode(t, db, 2, "B1", "b/b.go") - seedCommunity(t, db, 1, "a", 1) - seedCommunity(t, db, 2, "b", 2) - for i := range 3 { - db.Create(&model.Edge{FromNodeID: 1, ToNodeID: 2, Kind: model.EdgeKindCalls, Fingerprint: fmt.Sprintf("ab-%d", i)}) - } - for i := range 2 { - db.Create(&model.Edge{FromNodeID: 2, ToNodeID: 1, Kind: model.EdgeKindCalls, Fingerprint: fmt.Sprintf("ba-%d", i)}) - } - - svc := New(db) - got, err := svc.Analyze(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(got) != 2 { - t.Fatalf("expected 2 pairs (a→b, b→a), got %d", len(got)) - } - ab := findPair(got, "a", "b") - ba := findPair(got, "b", "a") - if ab == nil || ba == nil { - t.Fatal("expected both a→b and b→a") - } - if ab.EdgeCount != 3 { - t.Errorf("expected a→b count=3, got %d", ab.EdgeCount) - } - if ba.EdgeCount != 2 { - t.Errorf("expected b→a count=2, got %d", ba.EdgeCount) - } -} - -func TestAnalyze_NoCommunities(t *testing.T) { - db := setupDB(t) - - svc := New(db) - got, err := svc.Analyze(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("expected 0, got %d", len(got)) - } -} - -func seedCrossPair(t *testing.T, db *gorm.DB, fromNode, toNode uint, count int, tag string) { - t.Helper() - for i := range count { - if err := db.Create(&model.Edge{FromNodeID: fromNode, ToNodeID: toNode, Kind: model.EdgeKindCalls, Fingerprint: fmt.Sprintf("%s-%d", tag, i)}).Error; err != nil { - t.Fatalf("seed edge: %v", err) - } - } -} - -func TestAnalyzePage_AppliesLimitOffsetAndHasMore(t *testing.T) { - db := setupDB(t) - for i := uint(1); i <= 4; i++ { - seedNode(t, db, i, fmt.Sprintf("N%d", i), fmt.Sprintf("c%d/c.go", i)) - seedCommunity(t, db, i, fmt.Sprintf("c%d", i), i) - } - seedCrossPair(t, db, 1, 2, 10, "ab") - seedCrossPair(t, db, 1, 3, 7, "ac") - seedCrossPair(t, db, 1, 4, 4, "ad") - seedCrossPair(t, db, 2, 3, 2, "bc") - - svc := New(db) - - page1, err := svc.AnalyzePage(context.Background(), paging.Request{Limit: 2, Offset: 0}) - if err != nil { - t.Fatalf("page1: %v", err) - } - if len(page1.Items) != 2 { - t.Fatalf("page1 items=%d, want 2", len(page1.Items)) - } - if !page1.Pagination.HasMore { - t.Fatalf("page1 has_more=false, want true") - } - if page1.Items[0].Strength < page1.Items[1].Strength { - t.Fatalf("page1 not sorted by strength desc: %+v", page1.Items) - } - - page2, err := svc.AnalyzePage(context.Background(), paging.Request{Limit: 2, Offset: 2}) - if err != nil { - t.Fatalf("page2: %v", err) - } - if len(page2.Items) != 2 { - t.Fatalf("page2 items=%d, want 2", len(page2.Items)) - } - if page2.Pagination.HasMore { - t.Fatalf("page2 has_more=true, want false") - } -} - -func TestAnalyzePage_MatchesAnalyzeWindows(t *testing.T) { - db := setupDB(t) - for i := uint(1); i <= 5; i++ { - seedNode(t, db, i, fmt.Sprintf("N%d", i), fmt.Sprintf("c%d/c.go", i)) - seedCommunity(t, db, i, fmt.Sprintf("c%d", i), i) - } - seedCrossPair(t, db, 1, 2, 9, "c1-c2") - seedCrossPair(t, db, 1, 3, 9, "c1-c3") - seedCrossPair(t, db, 2, 3, 6, "c2-c3") - seedCrossPair(t, db, 3, 4, 3, "c3-c4") - seedCrossPair(t, db, 4, 5, 3, "c4-c5") - seedCrossPair(t, db, 5, 1, 1, "c5-c1") - - svc := New(db) - all, err := svc.Analyze(context.Background()) - if err != nil { - t.Fatalf("analyze: %v", err) - } - - windows := []paging.Request{ - {Limit: 2, Offset: 0}, - {Limit: 2, Offset: 2}, - {Limit: 3, Offset: 3}, - } - for _, window := range windows { - page, err := svc.AnalyzePage(context.Background(), window) - if err != nil { - t.Fatalf("AnalyzePage(%+v): %v", window, err) - } - end := min(window.Offset+window.Limit, len(all)) - want := all[window.Offset:end] - if !reflect.DeepEqual(page.Items, want) { - t.Fatalf("AnalyzePage(%+v) items=%+v, want %+v", window, page.Items, want) - } - } -} - -func TestAnalyzePage_RejectsLimitAboveMax(t *testing.T) { - db := setupDB(t) - svc := New(db) - if _, err := svc.AnalyzePage(context.Background(), paging.Request{Limit: paging.MaxLimit + 1}); err == nil { - t.Fatal("expected error for over-max limit") - } -} - -func TestAnalyzePage_OffsetBeyondTotalReturnsEmpty(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "A1", "a/a.go") - seedNode(t, db, 2, "B1", "b/b.go") - seedCommunity(t, db, 1, "a", 1) - seedCommunity(t, db, 2, "b", 2) - seedCrossPair(t, db, 1, 2, 3, "ab") - - svc := New(db) - page, err := svc.AnalyzePage(context.Background(), paging.Request{Limit: 10, Offset: 50}) - if err != nil { - t.Fatal(err) - } - if len(page.Items) != 0 { - t.Fatalf("items=%d, want 0", len(page.Items)) - } - if page.Pagination.HasMore { - t.Fatal("has_more=true, want false") - } -} diff --git a/internal/analysis/coverage/service.go b/internal/analysis/coverage/service.go deleted file mode 100644 index 4db7796..0000000 --- a/internal/analysis/coverage/service.go +++ /dev/null @@ -1,140 +0,0 @@ -// @index 테스트 커버리지 분석. tested_by 엣지 기반으로 파일별, 커뮤니티별 커버리지 비율을 계산한다. -package coverage - -import ( - "context" - "errors" - - "github.com/tae2089/trace" - - "github.com/tae2089/code-context-graph/internal/ctxns" - "github.com/tae2089/code-context-graph/internal/model" - "gorm.io/gorm" -) - -// FileCoverage holds coverage metrics for one source file. -// @intent report how many functions in a file are exercised by tests -type FileCoverage struct { - FilePath string - Total int - Tested int - Ratio float64 -} - -// CommunityCoverage holds aggregated coverage metrics for one community. -// @intent report test coverage across all functions assigned to a community -type CommunityCoverage struct { - CommunityID uint - Label string - Total int - Tested int - Ratio float64 -} - -// Service calculates test coverage metrics from graph relationships. -// @intent summarize tested_by coverage for files and communities -type Service struct { - db *gorm.DB -} - -// New creates a coverage analysis service. -// @intent construct a reusable service for coverage queries -func New(db *gorm.DB) *Service { - return &Service{db: db} -} - -// ByFile calculates test coverage ratio for a single file. -// Used by review_changes and pre_merge_check prompts. -// -// @param filePath source file path to analyze -// @return coverage ratio of functions with tested_by edges -// @intent measure how well a file is covered by tests -// @domainRule coverage ratio equals tested functions divided by total functions -// @domainRule files with no functions return ratio 0.0 -func (s *Service) ByFile(ctx context.Context, filePath string) (*FileCoverage, error) { - var functions []model.Node - if err := s.db.WithContext(ctx). - Where("namespace = ? AND file_path = ? AND kind = ?", ctxns.FromContext(ctx), filePath, model.NodeKindFunction). - Find(&functions).Error; err != nil { - return nil, err - } - - cov := &FileCoverage{FilePath: filePath, Total: len(functions)} - if cov.Total == 0 { - return cov, nil - } - - funcIDs := make([]uint, len(functions)) - for i, f := range functions { - funcIDs[i] = f.ID - } - - var testedCount int64 - err := s.db.WithContext(ctx). - Model(&model.Node{}). - Where("id IN ? AND id IN (?)", - funcIDs, - s.db.Model(&model.Edge{}).Select("to_node_id").Where("namespace = ? AND kind = ?", ctxns.FromContext(ctx), model.EdgeKindTestedBy), - ). - Count(&testedCount).Error - if err != nil { - return nil, err - } - - cov.Tested = int(testedCount) - cov.Ratio = float64(cov.Tested) / float64(cov.Total) - return cov, nil -} - -// ByCommunity calculates test coverage ratio for one community. -// @intent measure how thoroughly a detected module is exercised by tests -// @param communityID persisted community identifier to analyze -// @return coverage ratio of community functions with tested_by edges -// @domainRule coverage ratio equals tested functions divided by total functions -// @domainRule missing communities return a domain error instead of empty coverage -// @ensures successful results preserve the resolved community label -func (s *Service) ByCommunity(ctx context.Context, communityID uint) (*CommunityCoverage, error) { - var comm model.Community - if err := s.db.WithContext(ctx).First(&comm, communityID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, trace.New("community not found") - } - return nil, err - } - - var funcIDs []uint - err := s.db.WithContext(ctx). - Model(&model.Node{}). - Select("nodes.id"). - Joins("JOIN community_memberships ON community_memberships.node_id = nodes.id"). - Where("community_memberships.community_id = ? AND nodes.kind = ? AND nodes.namespace = ?", communityID, model.NodeKindFunction, ctxns.FromContext(ctx)). - Pluck("nodes.id", &funcIDs).Error - if err != nil { - return nil, err - } - - cov := &CommunityCoverage{ - CommunityID: communityID, - Label: comm.Label, - Total: len(funcIDs), - } - if cov.Total == 0 { - return cov, nil - } - - var testedCount int64 - err = s.db.WithContext(ctx). - Model(&model.Node{}). - Where("id IN ? AND id IN (?)", - funcIDs, - s.db.Model(&model.Edge{}).Select("to_node_id").Where("namespace = ? AND kind = ?", ctxns.FromContext(ctx), model.EdgeKindTestedBy), - ). - Count(&testedCount).Error - if err != nil { - return nil, err - } - - cov.Tested = int(testedCount) - cov.Ratio = float64(cov.Tested) / float64(cov.Total) - return cov, nil -} diff --git a/internal/analysis/coverage/service_test.go b/internal/analysis/coverage/service_test.go deleted file mode 100644 index 0bf956b..0000000 --- a/internal/analysis/coverage/service_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package coverage - -import ( - "context" - "fmt" - "math" - "testing" - - "github.com/tae2089/code-context-graph/internal/ctxns" - "github.com/tae2089/code-context-graph/internal/model" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - gormlogger "gorm.io/gorm/logger" -) - -func setupDB(t *testing.T) *gorm.DB { - t.Helper() - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - Logger: gormlogger.Discard, - }) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(&model.Node{}, &model.Edge{}, &model.Community{}, &model.CommunityMembership{}); err != nil { - t.Fatalf("migrate: %v", err) - } - return db -} - -func seedNode(t *testing.T, db *gorm.DB, id uint, name string, kind model.NodeKind, file string) { - t.Helper() - seedNodeNS(t, db, id, name, kind, file, "") -} - -func seedNodeNS(t *testing.T, db *gorm.DB, id uint, name string, kind model.NodeKind, file string, ns string) { - t.Helper() - n := model.Node{ - ID: id, - QualifiedName: fmt.Sprintf("%s::%s", file, name), - Namespace: ns, - Kind: kind, - Name: name, - FilePath: file, - StartLine: 1, - EndLine: 10, - Language: "go", - } - if err := db.Create(&n).Error; err != nil { - t.Fatalf("seed node: %v", err) - } -} - -func seedEdge(t *testing.T, db *gorm.DB, from, to uint, kind model.EdgeKind) { - t.Helper() - e := model.Edge{ - FromNodeID: from, - ToNodeID: to, - Kind: kind, - Fingerprint: fmt.Sprintf("%d-%d-%s", from, to, kind), - } - if err := db.Create(&e).Error; err != nil { - t.Fatalf("seed edge: %v", err) - } -} - -func seedCommunity(t *testing.T, db *gorm.DB, id uint, key string, nodeIDs ...uint) { - t.Helper() - c := model.Community{ID: id, Key: key, Label: key, Strategy: "directory"} - if err := db.Create(&c).Error; err != nil { - t.Fatalf("seed community: %v", err) - } - for _, nid := range nodeIDs { - m := model.CommunityMembership{CommunityID: id, NodeID: nid} - if err := db.Create(&m).Error; err != nil { - t.Fatalf("seed membership: %v", err) - } - } -} - -func TestByFile_AllTested(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "Foo", model.NodeKindFunction, "a.go") - seedNode(t, db, 2, "Bar", model.NodeKindFunction, "a.go") - seedNode(t, db, 10, "TestFoo", model.NodeKindTest, "a_test.go") - seedNode(t, db, 11, "TestBar", model.NodeKindTest, "a_test.go") - seedEdge(t, db, 10, 1, model.EdgeKindTestedBy) - seedEdge(t, db, 11, 2, model.EdgeKindTestedBy) - - svc := New(db) - got, err := svc.ByFile(context.Background(), "a.go") - if err != nil { - t.Fatal(err) - } - if got.Total != 2 { - t.Errorf("expected total=2, got %d", got.Total) - } - if got.Tested != 2 { - t.Errorf("expected tested=2, got %d", got.Tested) - } - if got.Ratio != 1.0 { - t.Errorf("expected ratio=1.0, got %.2f", got.Ratio) - } -} - -func TestByFile_NoneTested(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "Foo", model.NodeKindFunction, "a.go") - - svc := New(db) - got, err := svc.ByFile(context.Background(), "a.go") - if err != nil { - t.Fatal(err) - } - if got.Ratio != 0.0 { - t.Errorf("expected ratio=0.0, got %.2f", got.Ratio) - } -} - -func TestByFile_PartialCoverage(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "Foo", model.NodeKindFunction, "a.go") - seedNode(t, db, 2, "Bar", model.NodeKindFunction, "a.go") - seedNode(t, db, 3, "Baz", model.NodeKindFunction, "a.go") - seedNode(t, db, 10, "TestFoo", model.NodeKindTest, "a_test.go") - seedNode(t, db, 11, "TestBar", model.NodeKindTest, "a_test.go") - seedEdge(t, db, 10, 1, model.EdgeKindTestedBy) - seedEdge(t, db, 11, 2, model.EdgeKindTestedBy) - - svc := New(db) - got, err := svc.ByFile(context.Background(), "a.go") - if err != nil { - t.Fatal(err) - } - if got.Total != 3 { - t.Errorf("expected total=3, got %d", got.Total) - } - if got.Tested != 2 { - t.Errorf("expected tested=2, got %d", got.Tested) - } - expected := 2.0 / 3.0 - if math.Abs(got.Ratio-expected) > 0.001 { - t.Errorf("expected ratio≈%.3f, got %.3f", expected, got.Ratio) - } -} - -func TestByFile_NoFunctions(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "a.go", model.NodeKindFile, "a.go") - - svc := New(db) - got, err := svc.ByFile(context.Background(), "a.go") - if err != nil { - t.Fatal(err) - } - if got.Total != 0 { - t.Errorf("expected total=0, got %d", got.Total) - } - if got.Ratio != 0.0 { - t.Errorf("expected ratio=0.0, got %.2f", got.Ratio) - } -} - -func TestByCommunity_AggregatesFiles(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "Foo", model.NodeKindFunction, "a/x.go") - seedNode(t, db, 2, "Bar", model.NodeKindFunction, "a/y.go") - seedNode(t, db, 3, "Baz", model.NodeKindFunction, "a/y.go") - seedNode(t, db, 10, "TestFoo", model.NodeKindTest, "a/x_test.go") - seedEdge(t, db, 10, 1, model.EdgeKindTestedBy) - seedCommunity(t, db, 1, "a", 1, 2, 3) - - svc := New(db) - got, err := svc.ByCommunity(context.Background(), 1) - if err != nil { - t.Fatal(err) - } - if got.Total != 3 { - t.Errorf("expected total=3, got %d", got.Total) - } - if got.Tested != 1 { - t.Errorf("expected tested=1, got %d", got.Tested) - } -} - -func TestByCommunity_InvalidID(t *testing.T) { - db := setupDB(t) - - svc := New(db) - _, err := svc.ByCommunity(context.Background(), 999) - if err == nil { - t.Fatal("expected error for invalid community ID") - } -} - -func TestByFile_RespectsNamespace(t *testing.T) { - db := setupDB(t) - seedNodeNS(t, db, 1, "FooA", model.NodeKindFunction, "a.go", "ns-a") - seedNodeNS(t, db, 2, "FooB", model.NodeKindFunction, "a.go", "ns-b") - - svc := New(db) - - ctxA := ctxns.WithNamespace(context.Background(), "ns-a") - got, err := svc.ByFile(ctxA, "a.go") - if err != nil { - t.Fatal(err) - } - if got.Total != 1 { - t.Errorf("expected total=1 for ns-a, got %d", got.Total) - } -} diff --git a/internal/analysis/deadcode/service.go b/internal/analysis/deadcode/service.go deleted file mode 100644 index 2ccec8c..0000000 --- a/internal/analysis/deadcode/service.go +++ /dev/null @@ -1,108 +0,0 @@ -// @index 미사용 코드 탐지. incoming edge가 없는 함수와 클래스를 dead code 후보로 식별한다. -package deadcode - -import ( - "context" - "path" - - "github.com/tae2089/code-context-graph/internal/ctxns" - "github.com/tae2089/code-context-graph/internal/model" - "github.com/tae2089/code-context-graph/internal/paging" - "gorm.io/gorm" -) - -// Options controls dead code filtering. -// @intent narrow dead code detection by node kind and file scope -type Options struct { - Kinds []model.NodeKind - FilePattern string - Page paging.Request -} - -// Result carries one dead-code page plus pagination metadata. -// @intent let callers expose bounded dead-code responses while preserving legacy fields. -type Result struct { - Items []model.Node - Pagination paging.Page -} - -// Service finds unreachable graph nodes. -// @intent surface code elements that have no incoming references -type Service struct { - db *gorm.DB -} - -// New creates a dead code analysis service. -// @intent construct a service for querying unused graph nodes -func New(db *gorm.DB) *Service { - return &Service{db: db} -} - -// Find detects unused code with no incoming edges. -// Used by MCP find_dead_code tool and pre_merge_check prompt. -// -// @return nodes that have zero incoming edges (never called or referenced) -// @intent detect dead code candidates for cleanup -// @domainRule file and test nodes are always excluded from results -// @domainRule supports filtering by node kind and file path pattern -func (s *Service) Find(ctx context.Context, opts Options) ([]model.Node, error) { - page, err := s.FindPage(ctx, opts) - if err != nil { - return nil, err - } - return page.Items, nil -} - -// FindPage detects unused code with no incoming edges and returns one bounded page. -// @intent push dead-code pagination into the query layer so handlers do not slice full results. -func (s *Service) FindPage(ctx context.Context, opts Options) (Result, error) { - req, err := paging.Normalize(opts.Page) - if err != nil { - return Result{}, err - } - - q := s.db.WithContext(ctx). - Where("namespace = ?", ctxns.FromContext(ctx)). - Where("kind NOT IN ?", []model.NodeKind{model.NodeKindFile, model.NodeKindTest}). - Where("id NOT IN (?)", - s.db.Model(&model.Edge{}).Select("to_node_id").Where("namespace = ?", ctxns.FromContext(ctx)), - ) - - if len(opts.Kinds) > 0 { - q = q.Where("kind IN ?", opts.Kinds) - } - if cleanPrefix := normalizePathPrefix(opts.FilePattern); cleanPrefix != "" { - q = q.Where("file_path = ? OR file_path LIKE ?", cleanPrefix, cleanPrefix+"/%") - } - - var nodes []model.Node - if err := q. - Order("file_path ASC"). - Order("start_line ASC"). - Order("qualified_name ASC"). - Limit(req.Limit + 1). - Offset(req.Offset). - Find(&nodes).Error; err != nil { - return Result{}, err - } - hasMore := len(nodes) > req.Limit - if hasMore { - nodes = nodes[:req.Limit] - } - return Result{Items: nodes, Pagination: paging.BuildPage(req, len(nodes), hasMore)}, nil -} - -// normalizePathPrefix cleans a path prefix and returns an empty string if the cleaned result is ".". -// @intent ensure path prefix filtering is consistent regardless of trailing slashes or "." input -// @domainRule a path prefix of "." should be treated the same as an empty prefix (no filtering) -// @see deadcode.Service.FindPage -func normalizePathPrefix(prefix string) string { - if prefix == "" { - return "" - } - clean := path.Clean(prefix) - if clean == "." { - return "" - } - return clean -} diff --git a/internal/analysis/deadcode/service_test.go b/internal/analysis/deadcode/service_test.go deleted file mode 100644 index 1de7a1f..0000000 --- a/internal/analysis/deadcode/service_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package deadcode - -import ( - "context" - "fmt" - "testing" - - "github.com/tae2089/code-context-graph/internal/ctxns" - "github.com/tae2089/code-context-graph/internal/model" - "github.com/tae2089/code-context-graph/internal/paging" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - gormlogger "gorm.io/gorm/logger" -) - -func setupDB(t *testing.T) *gorm.DB { - t.Helper() - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - Logger: gormlogger.Discard, - }) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(&model.Node{}, &model.Edge{}); err != nil { - t.Fatalf("migrate: %v", err) - } - return db -} - -func seedNode(t *testing.T, db *gorm.DB, id uint, name string, kind model.NodeKind, file string) { - t.Helper() - seedNodeNS(t, db, id, name, kind, file, "") -} - -func seedNodeNS(t *testing.T, db *gorm.DB, id uint, name string, kind model.NodeKind, file string, ns string) { - t.Helper() - n := model.Node{ - ID: id, - QualifiedName: fmt.Sprintf("%s::%s", file, name), - Namespace: ns, - Kind: kind, - Name: name, - FilePath: file, - StartLine: 1, - EndLine: 10, - Language: "go", - } - if err := db.Create(&n).Error; err != nil { - t.Fatalf("seed node: %v", err) - } -} - -func seedEdge(t *testing.T, db *gorm.DB, from, to uint, kind model.EdgeKind) { - t.Helper() - e := model.Edge{ - FromNodeID: from, - ToNodeID: to, - Kind: kind, - Fingerprint: fmt.Sprintf("%d-%d-%s", from, to, kind), - } - if err := db.Create(&e).Error; err != nil { - t.Fatalf("seed edge: %v", err) - } -} - -func TestFind_NoIncomingEdges(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "Unused", model.NodeKindFunction, "a.go") - seedNode(t, db, 2, "Used", model.NodeKindFunction, "b.go") - seedEdge(t, db, 1, 2, model.EdgeKindCalls) - - svc := New(db) - got, err := svc.Find(context.Background(), Options{}) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 { - t.Fatalf("expected 1 dead code node, got %d", len(got)) - } - if got[0].Name != "Unused" { - t.Errorf("expected Unused, got %s", got[0].Name) - } -} - -func TestFind_HasIncomingEdges(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "Caller", model.NodeKindFunction, "a.go") - seedNode(t, db, 2, "Called", model.NodeKindFunction, "b.go") - seedEdge(t, db, 1, 2, model.EdgeKindCalls) - - svc := New(db) - got, err := svc.Find(context.Background(), Options{Kinds: []model.NodeKind{model.NodeKindFunction}}) - if err != nil { - t.Fatal(err) - } - for _, n := range got { - if n.Name == "Called" { - t.Error("Called should not be in dead code (has incoming edge)") - } - } -} - -func TestFind_FilterByKind(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "UnusedFunc", model.NodeKindFunction, "a.go") - seedNode(t, db, 2, "UnusedClass", model.NodeKindClass, "a.go") - - svc := New(db) - got, err := svc.Find(context.Background(), Options{Kinds: []model.NodeKind{model.NodeKindFunction}}) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 { - t.Fatalf("expected 1, got %d", len(got)) - } - if got[0].Name != "UnusedFunc" { - t.Errorf("expected UnusedFunc, got %s", got[0].Name) - } -} - -func TestFind_FilterByFilePattern(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "InternalFunc", model.NodeKindFunction, "internal/a.go") - seedNode(t, db, 2, "ExternalFunc", model.NodeKindFunction, "external/b.go") - - svc := New(db) - got, err := svc.Find(context.Background(), Options{FilePattern: "internal/"}) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 { - t.Fatalf("expected 1, got %d", len(got)) - } - if got[0].Name != "InternalFunc" { - t.Errorf("expected InternalFunc, got %s", got[0].Name) - } -} - -func TestFind_ExcludesFileNodes(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "a.go", model.NodeKindFile, "a.go") - seedNode(t, db, 2, "UnusedFunc", model.NodeKindFunction, "a.go") - - svc := New(db) - got, err := svc.Find(context.Background(), Options{}) - if err != nil { - t.Fatal(err) - } - for _, n := range got { - if n.Kind == model.NodeKindFile { - t.Error("file nodes should be excluded from dead code") - } - } - if len(got) != 1 { - t.Fatalf("expected 1, got %d", len(got)) - } -} - -func TestFind_ExcludesTestNodes(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "TestFoo", model.NodeKindTest, "a_test.go") - seedNode(t, db, 2, "UnusedFunc", model.NodeKindFunction, "a.go") - - svc := New(db) - got, err := svc.Find(context.Background(), Options{}) - if err != nil { - t.Fatal(err) - } - for _, n := range got { - if n.Kind == model.NodeKindTest { - t.Error("test nodes should be excluded from dead code") - } - } - if len(got) != 1 { - t.Fatalf("expected 1, got %d", len(got)) - } -} - -func TestFind_RespectsNamespace(t *testing.T) { - db := setupDB(t) - seedNodeNS(t, db, 1, "UnusedA", model.NodeKindFunction, "a.go", "ns-a") - seedNodeNS(t, db, 2, "UnusedB", model.NodeKindFunction, "b.go", "ns-b") - - svc := New(db) - - ctxA := ctxns.WithNamespace(context.Background(), "ns-a") - got, err := svc.Find(ctxA, Options{}) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 { - t.Fatalf("expected 1 node for ns-a, got %d", len(got)) - } - if got[0].Name != "UnusedA" { - t.Errorf("expected UnusedA, got %s", got[0].Name) - } - - ctxB := ctxns.WithNamespace(context.Background(), "ns-b") - got, err = svc.Find(ctxB, Options{}) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 { - t.Fatalf("expected 1 node for ns-b, got %d", len(got)) - } - if got[0].Name != "UnusedB" { - t.Errorf("expected UnusedB, got %s", got[0].Name) - } -} - -func TestFindPage_ReturnsBoundedResultsAndPagination(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "UnusedA", model.NodeKindFunction, "a.go") - seedNode(t, db, 2, "UnusedB", model.NodeKindFunction, "b.go") - seedNode(t, db, 3, "UnusedC", model.NodeKindFunction, "c.go") - - svc := New(db) - got, err := svc.FindPage(context.Background(), Options{Page: paging.Request{Limit: 2, Offset: 1}}) - if err != nil { - t.Fatal(err) - } - if len(got.Items) != 2 { - t.Fatalf("expected 2 items, got %d", len(got.Items)) - } - if got.Items[0].Name != "UnusedB" || got.Items[1].Name != "UnusedC" { - t.Fatalf("unexpected page order: %#v", []string{got.Items[0].Name, got.Items[1].Name}) - } - if got.Pagination.Limit != 2 || got.Pagination.Offset != 1 || got.Pagination.Returned != 2 { - t.Fatalf("unexpected pagination: %+v", got.Pagination) - } - if got.Pagination.HasMore { - t.Fatal("expected has_more=false on final page") - } -} - -func TestFindPage_PathFilterRespectsBoundary(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "Internal", model.NodeKindFunction, "internal/a.go") - seedNode(t, db, 2, "InternalTwo", model.NodeKindFunction, "internal2/b.go") - - svc := New(db) - got, err := svc.FindPage(context.Background(), Options{FilePattern: "internal", Page: paging.Request{Limit: 10}}) - if err != nil { - t.Fatal(err) - } - if len(got.Items) != 1 { - t.Fatalf("expected 1 item, got %d", len(got.Items)) - } - if got.Items[0].FilePath != "internal/a.go" { - t.Fatalf("unexpected file path: %s", got.Items[0].FilePath) - } -} diff --git a/internal/analysis/largefunc/service.go b/internal/analysis/largefunc/service.go deleted file mode 100644 index d166aab..0000000 --- a/internal/analysis/largefunc/service.go +++ /dev/null @@ -1,110 +0,0 @@ -// @index Large function and test detection that returns nodes exceeding a line-count threshold in descending size order. -package largefunc - -import ( - "context" - "path" - - "github.com/tae2089/code-context-graph/internal/ctxns" - "github.com/tae2089/code-context-graph/internal/model" - "github.com/tae2089/code-context-graph/internal/paging" - "gorm.io/gorm" -) - -// Service finds oversized functions and tests. -// @intent highlight large code units that may need refactoring or review -type Service struct { - db *gorm.DB -} - -// Options controls bounded large-function detection. -// @intent keep large-function filtering and pagination rules in one typed input. -type Options struct { - Threshold int - PathPrefix string - Page paging.Request -} - -// Result carries one large-function page plus pagination metadata. -// @intent let MCP handlers expose bounded large-function results without recomputing has_more. -type Result struct { - Items []model.Node - Pagination paging.Page -} - -// New creates a large function analysis service. -// @intent construct a service for querying nodes above a line threshold -func New(db *gorm.DB) *Service { - return &Service{db: db} -} - -// Find returns functions and tests longer than the threshold. -// @intent identify oversized executable nodes for maintainability analysis -// @param threshold minimum line-count threshold that results must strictly exceed -// @return functions and tests ordered from longest to shortest -// @domainRule only function and test nodes participate in large-function analysis -// @domainRule line count is computed as (end_line - start_line + 1) and must strictly exceed threshold -// @see mcp.handlers.findLargeFunctions -func (s *Service) Find(ctx context.Context, threshold int) ([]model.Node, error) { - var nodes []model.Node - for offset := 0; ; offset += paging.MaxLimit { - page, err := s.FindPage(ctx, Options{Threshold: threshold, Page: paging.Request{Limit: paging.MaxLimit, Offset: offset}}) - if err != nil { - return nil, err - } - nodes = append(nodes, page.Items...) - if !page.Pagination.HasMore { - return nodes, nil - } - } -} - -// FindPage returns a bounded page of functions and tests longer than the threshold. -// @intent apply pagination at the query layer so large-function analysis stays bounded. -// @domainRule results are sorted by descending line count with deterministic file/name tiebreakers. -func (s *Service) FindPage(ctx context.Context, opts Options) (Result, error) { - req, err := paging.Normalize(opts.Page) - if err != nil { - return Result{}, err - } - - var nodes []model.Node - q := s.db.WithContext(ctx). - Where("namespace = ?", ctxns.FromContext(ctx)). - Where("kind IN ? AND (end_line - start_line + 1) > ?", - []model.NodeKind{model.NodeKindFunction, model.NodeKindTest}, - opts.Threshold, - ). - Order("(end_line - start_line + 1) DESC"). - Order("file_path ASC"). - Order("start_line ASC"). - Order("qualified_name ASC") - - if cleanPrefix := normalizePathPrefix(opts.PathPrefix); cleanPrefix != "" { - q = q.Where("file_path = ? OR file_path LIKE ?", cleanPrefix, cleanPrefix+"/%") - } - - if err := q.Limit(req.Limit + 1).Offset(req.Offset).Find(&nodes).Error; err != nil { - return Result{}, err - } - hasMore := len(nodes) > req.Limit - if hasMore { - nodes = nodes[:req.Limit] - } - return Result{Items: nodes, Pagination: paging.BuildPage(req, len(nodes), hasMore)}, nil -} - -// normalizePathPrefix cleans a path prefix and returns an empty string if the cleaned result is ".". -// @intent ensure path prefix filtering is consistent regardless of trailing slashes or "." input -// @domainRule a path prefix of "." should be treated the same as an empty prefix (no filtering) -// @see largefunc.Service.FindPage -func normalizePathPrefix(prefix string) string { - if prefix == "" { - return "" - } - clean := path.Clean(prefix) - if clean == "." { - return "" - } - return clean -} diff --git a/internal/analysis/largefunc/service_test.go b/internal/analysis/largefunc/service_test.go deleted file mode 100644 index 613daa0..0000000 --- a/internal/analysis/largefunc/service_test.go +++ /dev/null @@ -1,224 +0,0 @@ -package largefunc - -import ( - "context" - "fmt" - "testing" - - "github.com/tae2089/code-context-graph/internal/ctxns" - "github.com/tae2089/code-context-graph/internal/model" - "github.com/tae2089/code-context-graph/internal/paging" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - gormlogger "gorm.io/gorm/logger" -) - -func setupDB(t *testing.T) *gorm.DB { - t.Helper() - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - Logger: gormlogger.Discard, - }) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(&model.Node{}); err != nil { - t.Fatalf("migrate: %v", err) - } - return db -} - -func seedNode(t *testing.T, db *gorm.DB, id uint, name string, kind model.NodeKind, startLine, endLine int) { - t.Helper() - seedNodeNS(t, db, id, name, kind, startLine, endLine, "") -} - -func seedNodeNS(t *testing.T, db *gorm.DB, id uint, name string, kind model.NodeKind, startLine, endLine int, ns string) { - t.Helper() - n := model.Node{ - ID: id, - QualifiedName: fmt.Sprintf("pkg::%s", name), - Namespace: ns, - Kind: kind, - Name: name, - FilePath: "pkg.go", - StartLine: startLine, - EndLine: endLine, - Language: "go", - } - if err := db.Create(&n).Error; err != nil { - t.Fatalf("seed node: %v", err) - } -} - -func TestFind_AboveThreshold(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "BigFunc", model.NodeKindFunction, 1, 50) - - svc := New(db) - got, err := svc.Find(context.Background(), 30) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 { - t.Fatalf("expected 1, got %d", len(got)) - } - if got[0].Name != "BigFunc" { - t.Errorf("expected BigFunc, got %s", got[0].Name) - } -} - -func TestFind_BelowThreshold(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "SmallFunc", model.NodeKindFunction, 1, 10) - - svc := New(db) - got, err := svc.Find(context.Background(), 30) - if err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("expected 0, got %d", len(got)) - } -} - -func TestFind_ExactThreshold(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "ExactFunc", model.NodeKindFunction, 1, 30) - - svc := New(db) - got, err := svc.Find(context.Background(), 30) - if err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("expected 0 (exact threshold not included), got %d", len(got)) - } -} - -func TestFind_OnlyFunctionKinds(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "BigClass", model.NodeKindClass, 1, 100) - seedNode(t, db, 2, "BigType", model.NodeKindType, 1, 100) - seedNode(t, db, 3, "BigFunc", model.NodeKindFunction, 1, 100) - seedNode(t, db, 4, "BigTest", model.NodeKindTest, 1, 100) - - svc := New(db) - got, err := svc.Find(context.Background(), 30) - if err != nil { - t.Fatal(err) - } - if len(got) != 2 { - t.Fatalf("expected 2 (function + test only), got %d", len(got)) - } -} - -func TestFind_OrderByLineCount(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "Medium", model.NodeKindFunction, 1, 50) - seedNode(t, db, 2, "Large", model.NodeKindFunction, 1, 100) - seedNode(t, db, 3, "Small", model.NodeKindFunction, 1, 40) - - svc := New(db) - got, err := svc.Find(context.Background(), 30) - if err != nil { - t.Fatal(err) - } - if len(got) != 3 { - t.Fatalf("expected 3, got %d", len(got)) - } - if got[0].Name != "Large" { - t.Errorf("expected first=Large, got %s", got[0].Name) - } - if got[1].Name != "Medium" { - t.Errorf("expected second=Medium, got %s", got[1].Name) - } - if got[2].Name != "Small" { - t.Errorf("expected third=Small, got %s", got[2].Name) - } -} - -func TestFind_ReturnsMoreThanMaxLimit(t *testing.T) { - db := setupDB(t) - for i := range make([]struct{}, paging.MaxLimit+1) { - seedNode(t, db, uint(i+1), fmt.Sprintf("Func%03d", i+1), model.NodeKindFunction, 1, 100+i) - } - - svc := New(db) - got, err := svc.Find(context.Background(), 30) - if err != nil { - t.Fatal(err) - } - if len(got) != paging.MaxLimit+1 { - t.Fatalf("expected %d, got %d", paging.MaxLimit+1, len(got)) - } - if got[0].Name != "Func501" || got[len(got)-1].Name != "Func001" { - t.Fatalf("unexpected boundary order: first=%s last=%s", got[0].Name, got[len(got)-1].Name) - } -} - -func TestFind_RespectsNamespace(t *testing.T) { - db := setupDB(t) - seedNodeNS(t, db, 1, "BigA", model.NodeKindFunction, 1, 100, "ns-a") - seedNodeNS(t, db, 2, "BigB", model.NodeKindFunction, 1, 100, "ns-b") - - svc := New(db) - - ctxA := ctxns.WithNamespace(context.Background(), "ns-a") - got, err := svc.Find(ctxA, 30) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 { - t.Fatalf("expected 1 node for ns-a, got %d", len(got)) - } - if got[0].Name != "BigA" { - t.Errorf("expected BigA, got %s", got[0].Name) - } -} - -func TestFindPage_ReturnsBoundedResultsAndPagination(t *testing.T) { - db := setupDB(t) - seedNode(t, db, 1, "One", model.NodeKindFunction, 1, 100) - seedNode(t, db, 2, "Two", model.NodeKindFunction, 1, 90) - seedNode(t, db, 3, "Three", model.NodeKindFunction, 1, 80) - - svc := New(db) - got, err := svc.FindPage(context.Background(), Options{Threshold: 30, Page: paging.Request{Limit: 2, Offset: 1}}) - if err != nil { - t.Fatal(err) - } - if len(got.Items) != 2 { - t.Fatalf("expected 2 items, got %d", len(got.Items)) - } - if got.Items[0].Name != "Two" || got.Items[1].Name != "Three" { - t.Fatalf("unexpected page order: %#v", []string{got.Items[0].Name, got.Items[1].Name}) - } - if got.Pagination.Limit != 2 || got.Pagination.Offset != 1 || got.Pagination.Returned != 2 { - t.Fatalf("unexpected pagination: %+v", got.Pagination) - } - if got.Pagination.HasMore { - t.Fatal("expected has_more=false on final page") - } -} - -func TestFindPage_PathFilterRespectsBoundary(t *testing.T) { - db := setupDB(t) - if err := db.Create(&model.Node{ID: 1, QualifiedName: "pkg.API", Namespace: "", Kind: model.NodeKindFunction, Name: "API", FilePath: "internal/api/handler.go", StartLine: 1, EndLine: 100, Language: "go"}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&model.Node{ID: 2, QualifiedName: "pkg.API2", Namespace: "", Kind: model.NodeKindFunction, Name: "API2", FilePath: "internal/api2/handler.go", StartLine: 1, EndLine: 120, Language: "go"}).Error; err != nil { - t.Fatal(err) - } - - svc := New(db) - got, err := svc.FindPage(context.Background(), Options{Threshold: 30, PathPrefix: "internal/api", Page: paging.Request{Limit: 10}}) - if err != nil { - t.Fatal(err) - } - if len(got.Items) != 1 { - t.Fatalf("expected 1 boundary-safe item, got %d", len(got.Items)) - } - if got.Items[0].FilePath != "internal/api/handler.go" { - t.Fatalf("unexpected file path: %s", got.Items[0].FilePath) - } -} diff --git a/internal/mcp/deps.go b/internal/mcp/deps.go index 848dcc5..88f669e 100644 --- a/internal/mcp/deps.go +++ b/internal/mcp/deps.go @@ -8,18 +8,12 @@ import ( "gorm.io/gorm" "github.com/tae2089/code-context-graph/internal/analysis/changes" - "github.com/tae2089/code-context-graph/internal/analysis/community" - "github.com/tae2089/code-context-graph/internal/analysis/coupling" - "github.com/tae2089/code-context-graph/internal/analysis/coverage" - "github.com/tae2089/code-context-graph/internal/analysis/deadcode" fallbackanalysis "github.com/tae2089/code-context-graph/internal/analysis/fallback" flowspkg "github.com/tae2089/code-context-graph/internal/analysis/flows" impactpkg "github.com/tae2089/code-context-graph/internal/analysis/impact" "github.com/tae2089/code-context-graph/internal/analysis/incremental" - "github.com/tae2089/code-context-graph/internal/analysis/largefunc" "github.com/tae2089/code-context-graph/internal/analysis/query" "github.com/tae2089/code-context-graph/internal/model" - "github.com/tae2089/code-context-graph/internal/paging" postprocesspolicy "github.com/tae2089/code-context-graph/internal/postprocess/policy" "github.com/tae2089/code-context-graph/internal/store" storesearch "github.com/tae2089/code-context-graph/internal/store/search" @@ -80,22 +74,6 @@ type QueryService interface { FindExactNameMatches(ctx context.Context, target string, limit int) ([]query.CandidateMatch, error) } -// LargefuncAnalyzer defines the oversized-function detection contract. -// @intent Injects an analyzer to detect large functions with high maintenance costs. -// @see mcp.handlers.findLargeFunctions -type LargefuncAnalyzer interface { - Find(ctx context.Context, threshold int) ([]model.Node, error) - FindPage(ctx context.Context, opts largefunc.Options) (largefunc.Result, error) -} - -// DeadcodeAnalyzer defines the unused-code detection contract. -// @intent Injects an analyzer to find unreferenced nodes as candidates for cleanup. -// @see mcp.handlers.findDeadCode -type DeadcodeAnalyzer interface { - Find(ctx context.Context, opts deadcode.Options) ([]model.Node, error) - FindPage(ctx context.Context, opts deadcode.Options) (deadcode.Result, error) -} - // FallbackAnalyzer defines the suspect fallback-edge analysis contract. // @intent Detects untrustworthy fallback call edges based on annotation overlap. // @see mcp.handlers.findSuspectFallbackEdges @@ -104,30 +82,6 @@ type FallbackAnalyzer interface { FindSuspectsPage(ctx context.Context, opts fallbackanalysis.Options) (fallbackanalysis.Result, error) } -// CouplingAnalyzer defines the inter-community coupling analysis contract. -// @intent Connects an analyzer to the server that calculates coupling between architectural boundaries. -// @see mcp.handlers.getArchitectureOverview -type CouplingAnalyzer interface { - Analyze(ctx context.Context) ([]coupling.CouplingPair, error) - AnalyzePage(ctx context.Context, req paging.Request) (coupling.Result, error) -} - -// CoverageAnalyzer defines file and community coverage lookup operations. -// @intent Provides test coverage information for risk summaries and community detail responses. -// @see mcp.handlers.getCommunity -// @see mcp.promptHandlers.reviewChanges -type CoverageAnalyzer interface { - ByFile(ctx context.Context, filePath string) (*coverage.FileCoverage, error) - ByCommunity(ctx context.Context, communityID uint) (*coverage.CommunityCoverage, error) -} - -// CommunityBuilder defines the community rebuild contract. -// @intent Injects an implementation to recalculate module communities during graph post-processing. -// @see mcp.handlers.runPostprocess -type CommunityBuilder interface { - Rebuild(ctx context.Context, cfg community.Config) ([]community.Stats, error) -} - // IncrementalSyncer defines the incremental graph synchronization contract. // @intent Injects a syncer that reflects only changed files into the graph without full re-parsing. // @see mcp.handlers.buildOrUpdateGraph @@ -159,12 +113,7 @@ type Deps struct { // Added in Phase 11 QueryService QueryService - LargefuncAnalyzer LargefuncAnalyzer - DeadcodeAnalyzer DeadcodeAnalyzer FallbackAnalyzer FallbackAnalyzer - CouplingAnalyzer CouplingAnalyzer - CoverageAnalyzer CoverageAnalyzer - CommunityBuilder CommunityBuilder FlowBuilder FlowBuilder Incremental IncrementalSyncer PostprocessPolicy PostprocessPolicy diff --git a/internal/mcp/e2e_http_test.go b/internal/mcp/e2e_http_test.go index 7c5ecfe..93d0868 100644 --- a/internal/mcp/e2e_http_test.go +++ b/internal/mcp/e2e_http_test.go @@ -179,7 +179,7 @@ func TestE2EHTTP_ListTools(t *testing.T) { toolNames[tool.Name] = true } - required := []string{"parse_project", "get_node", "search", "list_graph_stats", "find_dead_code"} + required := []string{"parse_project", "get_node", "search", "list_graph_stats", "query_graph"} for _, name := range required { if !toolNames[name] { t.Errorf("missing tool: %s", name) diff --git a/internal/mcp/e2e_test.go b/internal/mcp/e2e_test.go index 82092dc..2fe8493 100644 --- a/internal/mcp/e2e_test.go +++ b/internal/mcp/e2e_test.go @@ -15,8 +15,6 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" - "github.com/tae2089/code-context-graph/internal/analysis/community" - "github.com/tae2089/code-context-graph/internal/analysis/deadcode" "github.com/tae2089/code-context-graph/internal/analysis/flows" "github.com/tae2089/code-context-graph/internal/analysis/impact" "github.com/tae2089/code-context-graph/internal/analysis/incremental" @@ -527,51 +525,6 @@ func Gamma() {} } } -func TestE2E_BuildAndCommunities(t *testing.T) { - deps := setupE2EDeps(t) - - commBuilder := community.New(deps.DB) - deps.CommunityBuilder = commBuilder - - dir := t.TempDir() - writeGoFile(t, dir, "svc.go", `package svc - -func Run() {} -func Stop() {} -`) - - buildResult := callTool(t, deps, "build_or_update_graph", map[string]any{ - "path": dir, - "full_rebuild": true, - "postprocess": "none", - }) - if buildResult.IsError { - t.Fatalf("build_or_update_graph error: %s", getTextContent(buildResult)) - } - - ppResult := callTool(t, deps, "run_postprocess", map[string]any{ - "flows": false, - "communities": true, - "fts": false, - }) - if ppResult.IsError { - t.Fatalf("run_postprocess error: %s", getTextContent(ppResult)) - } - - lcResult := callTool(t, deps, "list_communities", map[string]any{}) - if lcResult.IsError { - t.Fatalf("list_communities error: %s", getTextContent(lcResult)) - } - - text := getTextContent(lcResult) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - comms := resp["communities"].([]any) - if len(comms) == 0 { - t.Error("expected at least 1 community after rebuild") - } -} - func TestE2E_UploadBuildSearch(t *testing.T) { deps := setupE2EDeps(t) @@ -669,58 +622,3 @@ func RefundPayment(txID string) error { t.Fatal("expected at least 1 search result for 'RefundPayment'") } } - -func TestE2E_BuildAndDeadCode(t *testing.T) { - deps := setupE2EDeps(t) - - dcAnalyzer := deadcode.New(deps.DB) - deps.DeadcodeAnalyzer = dcAnalyzer - - dir := t.TempDir() - writeGoFile(t, dir, "dead.go", `package dead - -func UsedFunc() {} -func UnusedFunc() {} -`) - - buildResult := callTool(t, deps, "build_or_update_graph", map[string]any{ - "path": dir, - "full_rebuild": true, - "postprocess": "none", - }) - if buildResult.IsError { - t.Fatalf("build_or_update_graph error: %s", getTextContent(buildResult)) - } - - // Make UsedFunc have an incoming edge - ctx := context.Background() - usedNode, _ := deps.Store.GetNode(ctx, "dead.UsedFunc") - unusedNode, _ := deps.Store.GetNode(ctx, "dead.UnusedFunc") - if usedNode == nil || unusedNode == nil { - t.Fatal("expected both nodes to exist") - } - deps.Store.UpsertEdges(ctx, []model.Edge{ - {FromNodeID: unusedNode.ID, ToNodeID: usedNode.ID, Kind: model.EdgeKindCalls, Fingerprint: "calls-unused-used"}, - }) - - dcResult := callTool(t, deps, "find_dead_code", map[string]any{}) - if dcResult.IsError { - t.Fatalf("find_dead_code error: %s", getTextContent(dcResult)) - } - - text := getTextContent(dcResult) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - deadCode := resp["dead_code"].([]any) - // UnusedFunc has no incoming edge, so it should appear as dead code - found := false - for _, dc := range deadCode { - entry := dc.(map[string]any) - if entry["name"] == "dead.UnusedFunc" { - found = true - } - } - if !found { - t.Error("expected dead.UnusedFunc in dead code results") - } -} diff --git a/internal/mcp/handler_analysis.go b/internal/mcp/handler_analysis.go index 320a486..ae7de04 100644 --- a/internal/mcp/handler_analysis.go +++ b/internal/mcp/handler_analysis.go @@ -10,7 +10,6 @@ import ( "github.com/tae2089/trace" "github.com/tae2089/code-context-graph/internal/analysis/changes" - "github.com/tae2089/code-context-graph/internal/analysis/deadcode" fallbackanalysis "github.com/tae2089/code-context-graph/internal/analysis/fallback" flowspkg "github.com/tae2089/code-context-graph/internal/analysis/flows" impactpkg "github.com/tae2089/code-context-graph/internal/analysis/impact" @@ -105,15 +104,6 @@ type affectedFlowsResponse struct { Pagination paging.Page `json:"pagination"` } -// deadCodeItem summarizes one node reported as dead code. -// @intent preserve a stable per-item DTO for findDeadCode responses. -type deadCodeItem struct { - Name string `json:"name"` - Kind model.NodeKind `json:"kind"` - File string `json:"file"` - StartLine int `json:"start_line"` -} - // suspectFallbackEdgeItem summarizes one fallback edge inspected for suspicion. // @intent preserve a stable per-item DTO for findSuspectFallbackEdges responses. type suspectFallbackEdgeItem struct { @@ -461,56 +451,6 @@ func (h *handlers) getAffectedFlows(ctx context.Context, request mcp.CallToolReq })) } -// findDeadCode returns nodes that have no incoming usage edges. -// @intent find unused code candidates so maintainers can reduce long-term maintenance burden. -// @param request path and kinds narrow the detection scope. -// @requires DeadcodeAnalyzer must be configured. -// @ensures returns dead_code entries and their count when analysis succeeds. -// @domainRule only nodes without incoming edges qualify as dead code candidates. -func (h *handlers) findDeadCode(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ctx = h.applyNamespace(ctx, request) - h.logger().Info("find_dead_code called") - - input, err := decodeFindDeadCodeRequest(request) - if err != nil { - return finalizeToolResult("", err) - } - - if h.deps.DeadcodeAnalyzer == nil { - return mcp.NewToolResultError("DeadcodeAnalyzer not configured"), nil - } - - opts := deadcode.Options{Page: input.Page, FilePattern: input.PathPrefix} - for _, k := range input.Kinds { - opts.Kinds = append(opts.Kinds, model.NodeKind(k)) - } - - cacheParams := map[string]any{ - "path": input.PathPrefix, - "kinds": input.Kinds, - "limit": input.Page.Limit, - "offset": input.Page.Offset, - "namespace": input.Namespace, - } - return finalizeToolResult(h.cachedExecute(ctx, "find_dead_code:", cacheParams, func() (string, error) { - page, err := h.deps.DeadcodeAnalyzer.FindPage(ctx, opts) - if err != nil { - return "", trace.Wrap(err, "deadcode error") - } - - items := make([]deadCodeItem, len(page.Items)) - for i, n := range page.Items { - items[i] = deadCodeItem{Name: n.QualifiedName, Kind: n.Kind, File: n.FilePath, StartLine: n.StartLine} - } - - result, err := encodePagedListResponse("dead_code", items, page.Pagination) - if err != nil { - return "", trace.Wrap(err, "marshal result") - } - return result, nil - })) -} - // findSuspectFallbackEdges returns fallback call edges whose source/target annotations do not overlap on intent/domain rules. // @intent surface low-confidence fallback call candidates so operators can manually review weakly explained edges. // @param request supports optional limit and offset to bound fallback suspect analysis. diff --git a/internal/mcp/handler_analysis_dto_test.go b/internal/mcp/handler_analysis_dto_test.go index d4265f5..786e1da 100644 --- a/internal/mcp/handler_analysis_dto_test.go +++ b/internal/mcp/handler_analysis_dto_test.go @@ -121,32 +121,6 @@ func TestHandler_AnalysisResponses_WireContractFrozen(t *testing.T) { } }) - t.Run("find_dead_code", func(t *testing.T) { - deps := setupTestDeps(t) - deps.DeadcodeAnalyzer = &mockDeadcodeAnalyzer{result: []model.Node{{QualifiedName: "pkg.Dead", Kind: model.NodeKindFunction, Name: "Dead", FilePath: "dead.go", StartLine: 10, EndLine: 20, Language: "go"}}} - - result := callTool(t, deps, "find_dead_code", map[string]any{"limit": 10, "offset": 0}) - if result.IsError { - t.Fatalf("find_dead_code returned error: %s", getTextContent(result)) - } - - var resp map[string]any - if err := json.Unmarshal([]byte(getTextContent(result)), &resp); err != nil { - t.Fatalf("expected JSON response, got: %s", getTextContent(result)) - } - - if !reflect.DeepEqual(sortedKeys(resp), []string{"count", "dead_code", "items", "pagination"}) { - t.Fatalf("unexpected top-level keys: %v", sortedKeys(resp)) - } - pagination, ok := resp["pagination"].(map[string]any) - if !ok { - t.Fatalf("pagination type = %T, want map[string]any", resp["pagination"]) - } - if !reflect.DeepEqual(sortedKeys(pagination), []string{"has_more", "limit", "offset", "returned"}) { - t.Fatalf("unexpected pagination keys: %v", sortedKeys(pagination)) - } - }) - t.Run("find_suspect_fallback_edges", func(t *testing.T) { deps := setupTestDeps(t) deps.FallbackAnalyzer = &mockFallbackAnalyzer{result: []fallbackanalysis.SuspectEdge{{ @@ -177,36 +151,6 @@ func TestHandler_AnalysisResponses_WireContractFrozen(t *testing.T) { t.Fatalf("unexpected pagination keys: %v", sortedKeys(pagination)) } }) - - t.Run("find_large_functions", func(t *testing.T) { - deps := setupTestDeps(t) - deps.LargefuncAnalyzer = &mockLargefuncAnalyzer{result: []model.Node{{QualifiedName: "pkg.Big", Kind: model.NodeKindFunction, Name: "Big", FilePath: "big.go", StartLine: 1, EndLine: 100}}} - - result := callTool(t, deps, "find_large_functions", map[string]any{"limit": 3, "offset": 0}) - if result.IsError { - t.Fatalf("find_large_functions returned error: %s", getTextContent(result)) - } - - var resp map[string]any - if err := json.Unmarshal([]byte(getTextContent(result)), &resp); err != nil { - t.Fatalf("expected JSON response, got: %s", getTextContent(result)) - } - - if !reflect.DeepEqual(sortedKeys(resp), []string{"count", "items", "pagination", "results"}) { - t.Fatalf("unexpected top-level keys: %v", sortedKeys(resp)) - } - pagination, ok := resp["pagination"].(map[string]any) - if !ok { - t.Fatalf("pagination type = %T, want map[string]any", resp["pagination"]) - } - if _, hasNext := pagination["next_offset"]; hasNext { - if !reflect.DeepEqual(sortedKeys(pagination), []string{"has_more", "limit", "next_offset", "offset", "returned"}) { - t.Fatalf("unexpected pagination keys: %v", sortedKeys(pagination)) - } - } else if !reflect.DeepEqual(sortedKeys(pagination), []string{"has_more", "limit", "offset", "returned"}) { - t.Fatalf("unexpected pagination keys: %v", sortedKeys(pagination)) - } - }) } func TestPagedListResponse_MarshalJSON_PreservesEnvelope(t *testing.T) { diff --git a/internal/mcp/handler_analysis_io.go b/internal/mcp/handler_analysis_io.go index 43cbd3d..0c54301 100644 --- a/internal/mcp/handler_analysis_io.go +++ b/internal/mcp/handler_analysis_io.go @@ -9,15 +9,6 @@ import ( "github.com/tae2089/code-context-graph/internal/paging" ) -// findDeadCodeInput captures decoded request arguments for find_dead_code. -// @intent give findDeadCode a typed view of its request so the handler stays a thin adapter over deadcode.Service. -type findDeadCodeInput struct { - Page paging.Request - Kinds []string - PathPrefix string - Namespace string -} - // findSuspectFallbackInput captures decoded request arguments for find_suspect_fallback_edges. // @intent give findSuspectFallbackEdges a typed view of its request so the handler stays a thin adapter over the fallback analyzer. type findSuspectFallbackInput struct { @@ -25,31 +16,6 @@ type findSuspectFallbackInput struct { Namespace string } -// findLargeFuncsInput captures decoded request arguments for find_large_functions. -// @intent give findLargeFunctions a typed view of its request so the handler stays a thin adapter over largefunc.Service. -type findLargeFuncsInput struct { - MinLines int - Page paging.Request - PathPrefix string - Namespace string -} - -// decodeFindDeadCodeRequest extracts and validates find_dead_code arguments. -// @intent isolate request parsing and pagination validation for find_dead_code. -// @ensures returned page request is normalized and limit/offset are non-negative. -func decodeFindDeadCodeRequest(request mcp.CallToolRequest) (findDeadCodeInput, error) { - pageReq, err := decodeListPageRequest(request, 50) - if err != nil { - return findDeadCodeInput{}, err - } - return findDeadCodeInput{ - Page: pageReq, - Kinds: request.GetStringSlice("kinds", nil), - PathPrefix: request.GetString("path", ""), - Namespace: requestNamespace(request), - }, nil -} - // decodeFindSuspectFallbackRequest extracts and validates find_suspect_fallback_edges arguments. // @intent isolate request parsing and pagination validation for find_suspect_fallback_edges. func decodeFindSuspectFallbackRequest(request mcp.CallToolRequest) (findSuspectFallbackInput, error) { @@ -63,21 +29,6 @@ func decodeFindSuspectFallbackRequest(request mcp.CallToolRequest) (findSuspectF }, nil } -// decodeFindLargeFuncsRequest extracts and validates find_large_functions arguments. -// @intent isolate request parsing and pagination validation for find_large_functions. -func decodeFindLargeFuncsRequest(request mcp.CallToolRequest) (findLargeFuncsInput, error) { - pageReq, err := decodeListPageRequest(request, 50) - if err != nil { - return findLargeFuncsInput{}, err - } - return findLargeFuncsInput{ - MinLines: request.GetInt("min_lines", 50), - Page: pageReq, - PathPrefix: request.GetString("path", ""), - Namespace: requestNamespace(request), - }, nil -} - // decodeListPageRequest extracts shared limit/offset arguments and returns a normalized paging request. // @intent keep MCP list-style handlers from repeating the same pagination decode boilerplate. func decodeListPageRequest(request mcp.CallToolRequest, defaultLimit int) (paging.Request, error) { diff --git a/internal/mcp/handler_context.go b/internal/mcp/handler_context.go index 2fa41e8..f08ac46 100644 --- a/internal/mcp/handler_context.go +++ b/internal/mcp/handler_context.go @@ -254,14 +254,14 @@ func suggestTools(task string) []string { } for _, kw := range refactorKeywords { if strings.Contains(lower, kw) { - return []string{"find_dead_code", "find_large_functions", "get_architecture_overview"} + return []string{"get_impact_radius", "query_graph", "search"} } } for _, kw := range onboardKeywords { if strings.Contains(lower, kw) { - return []string{"get_architecture_overview", "list_communities", "list_flows"} + return []string{"list_flows", "get_minimal_context", "search"} } } - return []string{"detect_changes", "search", "get_architecture_overview"} + return []string{"detect_changes", "search", "query_graph"} } diff --git a/internal/mcp/handler_context_test.go b/internal/mcp/handler_context_test.go index 0a195a1..cd72c18 100644 --- a/internal/mcp/handler_context_test.go +++ b/internal/mcp/handler_context_test.go @@ -388,48 +388,3 @@ func TestGetMinimalContext_RejectsRepoRootOutsideConfiguredRoot(t *testing.T) { t.Fatal("expected get_minimal_context to reject repo_root outside configured root") } } - -func TestGetMinimalContext_TaskKeywordSuggestions(t *testing.T) { - deps := setupTestDeps(t) - - tests := []struct { - task string - contains string - }{ - {"review this PR", "detect_changes"}, - {"debug the login bug", "search"}, - {"refactor auth module", "find_dead_code"}, - {"onboard new developer", "get_architecture_overview"}, - {"random task", "detect_changes"}, - } - - for _, tt := range tests { - t.Run(tt.task, func(t *testing.T) { - result := callTool(t, deps, "get_minimal_context", map[string]any{"task": tt.task}) - if result.IsError { - t.Fatalf("returned error: %s", getTextContent(result)) - } - - var data map[string]any - if err := json.Unmarshal([]byte(getTextContent(result)), &data); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - - tools, ok := data["suggested_tools"].([]any) - if !ok { - t.Fatal("missing suggested_tools") - } - - found := false - for _, tool := range tools { - if tool.(string) == tt.contains { - found = true - break - } - } - if !found { - t.Errorf("suggested_tools %v should contain %q", tools, tt.contains) - } - }) - } -} diff --git a/internal/mcp/handler_graph.go b/internal/mcp/handler_graph.go index d3cfd50..d0ed201 100644 --- a/internal/mcp/handler_graph.go +++ b/internal/mcp/handler_graph.go @@ -3,7 +3,6 @@ package mcp import ( "context" - "fmt" "github.com/mark3labs/mcp-go/mcp" "github.com/tae2089/trace" @@ -22,15 +21,6 @@ type graphFlowInfo struct { NodeCount int `json:"node_count"` } -// graphCommInfo represents a summarized community response entry. -// @intent serialize listCommunities results with the legacy response shape. -type graphCommInfo struct { - ID uint `json:"id"` - Label string `json:"label"` - NodeCount int `json:"node_count"` - Cohesion float64 `json:"cohesion"` -} - // listFlowsResponse holds the listFlows wire payload. // @intent preserve the legacy listFlows response shape with typed fields. type listFlowsResponse struct { @@ -39,74 +29,6 @@ type listFlowsResponse struct { Pagination paging.Page `json:"pagination"` } -// listCommunitiesResponse holds the listCommunities wire payload. -// @intent preserve the legacy listCommunities response shape with typed fields. -type listCommunitiesResponse struct { - Communities []graphCommInfo `json:"communities"` - DerivedState map[string]any `json:"derived_state"` - Pagination paging.Page `json:"pagination"` -} - -// communityMemberSummary is a typed member entry for getCommunity. -// @intent preserve the legacy community member shape with typed fields. -type communityMemberSummary = nodeSummary - -// getCommunityResponse holds the getCommunity wire payload. -// @intent preserve the legacy getCommunity response shape with typed fields. -type getCommunityResponse struct { - ID uint `json:"id"` - Label string `json:"label"` - NodeCount int64 `json:"node_count"` - DerivedState map[string]any `json:"derived_state"` - Coverage *float64 `json:"coverage,omitempty"` - Members []communityMemberSummary `json:"members,omitempty"` - MembersPagination *paging.Page `json:"members_pagination,omitempty"` -} - -// archCommCount is a helper struct for counting community nodes in architecture overview. -// @intent support community node counting in getArchitectureOverview without polluting model.Community. -type archCommCount struct { - ID uint - Label string - NodeCount int64 -} - -// architectureOverviewCommunity is a typed community entry for architecture overview. -// @intent preserve the legacy communities item shape with typed fields. -type architectureOverviewCommunity struct { - ID uint `json:"id"` - Label string `json:"label"` - NodeCount int64 `json:"node_count"` -} - -// architectureOverviewCoupling is a typed coupling entry for architecture overview. -// @intent preserve the legacy coupling item shape with typed fields. -type architectureOverviewCoupling struct { - From string `json:"from"` - To string `json:"to"` - EdgeCount int64 `json:"edge_count"` - Strength float64 `json:"strength"` -} - -// architectureOverviewResponse holds the architecture overview wire payload. -// @intent preserve the legacy architecture overview response shape with typed fields. -type architectureOverviewResponse struct { - Communities []architectureOverviewCommunity `json:"communities"` - CommunitiesPagination paging.Page `json:"communities_pagination"` - Coupling []architectureOverviewCoupling `json:"coupling"` - CouplingPagination paging.Page `json:"coupling_pagination"` - Warnings []string `json:"warnings"` - DerivedState map[string]any `json:"derived_state"` -} - -// communityRow is a helper struct for counting community nodes in listCommunities. -// @intent support community node counting in listCommunities without polluting model.Community. -type communityRow struct { - ID uint - Label string - NodeCount int64 -} - // flowRow is a helper struct for counting flow nodes in listFlows. // @intent support flow node counting in listFlows without polluting model.Flow. type flowRow struct { @@ -191,283 +113,6 @@ func (h *handlers) listFlows(ctx context.Context, request mcp.CallToolRequest) ( })) } -// listCommunities lists communities with size-based filtering and sorting. -// @intent Provides a summarized list of the community structure based on size. -// @param request sort_by and min_size control the filtering and sorting of the response. -// @ensures Returns a list including community ID, label, and node count on success. -// @see mcp.handlers.getCommunity -func (h *handlers) listCommunities(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ctx = h.applyNamespace(ctx, request) - log := h.logger() - - sortBy := request.GetString("sort_by", "size") - minSize := request.GetInt("min_size", 0) - limit := request.GetInt("limit", defaultQueryGraphLimit) - offset := request.GetInt("offset", 0) - if err := validateQueryGraphLimit(limit); err != nil { - return finalizeToolResult("", err) - } - if err := validateOffset(offset); err != nil { - return finalizeToolResult("", err) - } - - log.Info("list_communities called", "sort_by", sortBy, "min_size", minSize, "limit", limit, "offset", offset) - - return finalizeToolResult(h.cachedExecute(ctx, "list_communities:", map[string]any{"sort_by": sortBy, "min_size": minSize, "limit": limit, "offset": offset, "namespace": requestNamespace(request)}, func() (string, error) { - ns := ctxns.FromContext(ctx) - var communityRows []communityRow - communityQ := h.deps.DB.WithContext(ctx). - Model(&model.Community{}). - Select("communities.id AS id, communities.label AS label, COALESCE(COUNT(community_memberships.id),0) AS node_count"). - Joins("LEFT JOIN community_memberships ON community_memberships.community_id = communities.id"). - Where("communities.namespace = ?", ns). - Group("communities.id, communities.label") - if minSize > 0 { - communityQ = communityQ.Having("COUNT(community_memberships.id) >= ?", minSize) - } - - switch sortBy { - case "name": - communityQ = communityQ.Order("communities.label ASC").Order("communities.id ASC") - default: - communityQ = communityQ.Order("node_count DESC").Order("communities.label ASC").Order("communities.id ASC") - } - - fetchLimit := limit + 1 - if err := communityQ.Limit(fetchLimit).Offset(offset).Find(&communityRows).Error; err != nil { - return "", trace.Wrap(err, "find communities") - } - - hasMore := len(communityRows) > limit - if hasMore { - communityRows = communityRows[:limit] - } - - infos := make([]graphCommInfo, len(communityRows)) - for i, c := range communityRows { - infos[i] = graphCommInfo{ - ID: c.ID, - Label: c.Label, - NodeCount: int(c.NodeCount), - } - } - - result, err := marshalJSON(listCommunitiesResponse{ - Communities: infos, - DerivedState: derivedStateCommunities(), - Pagination: paging.BuildPage(paging.Request{Limit: limit, Offset: offset}, len(infos), hasMore), - }) - if err != nil { - return "", trace.Wrap(err, "marshal result") - } - return result, nil - })) -} - -// getCommunity returns community metadata with optional members and coverage. -// @intent Enables detailed lookup of a specific community's size and members. -// @param request community_id is required, and include_members controls whether to include the member list. -// @requires request.community_id must point to an existing community. -// @ensures Returns basic community information along with optional coverage and members on success. -// @see mcp.handlers.listCommunities -func (h *handlers) getCommunity(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ctx = h.applyNamespace(ctx, request) - log := h.logger() - - communityID := request.GetInt("community_id", 0) - if communityID == 0 { - return mcp.NewToolResultError("missing parameter: community_id"), nil - } - includeMembers := request.GetBool("include_members", false) - memberLimit := request.GetInt("member_limit", 100) - memberOffset := request.GetInt("member_offset", 0) - if err := validateQueryGraphLimit(memberLimit); err != nil { - return finalizeToolResult("", err) - } - if err := validateOffset(memberOffset); err != nil { - return finalizeToolResult("", err) - } - - log.Info("get_community called", "community_id", communityID, "include_members", includeMembers, "member_limit", memberLimit, "member_offset", memberOffset) - - var comm model.Community - commQ := h.deps.DB.WithContext(ctx).Where("namespace = ?", ctxns.FromContext(ctx)) - if err := commQ.First(&comm, communityID).Error; err != nil { - return mcp.NewToolResultError(fmt.Sprintf("community %d not found", communityID)), nil - } - - return finalizeToolResult(h.cachedExecute(ctx, "get_community:", map[string]any{"community_id": communityID, "include_members": includeMembers, "member_limit": memberLimit, "member_offset": memberOffset, "namespace": requestNamespace(request)}, func() (string, error) { - ns := ctxns.FromContext(ctx) - var memberCount int64 - memberQ := h.deps.DB.WithContext(ctx).Model(&model.CommunityMembership{}). - Joins("JOIN communities ON communities.id = community_memberships.community_id"). - Where("community_id = ?", comm.ID). - Where("communities.namespace = ?", ns) - if err := memberQ.Count(&memberCount).Error; err != nil { - return "", trace.Wrap(err, "count community members") - } - - gcData := getCommunityResponse{ - ID: comm.ID, - Label: comm.Label, - NodeCount: memberCount, - DerivedState: derivedStateCommunities(), - } - - if h.deps.CoverageAnalyzer != nil { - cc, err := h.deps.CoverageAnalyzer.ByCommunity(ctx, comm.ID) - if err == nil && cc != nil { - coverage := cc.Ratio - gcData.Coverage = &coverage - } - } - - if includeMembers { - var nodes []model.Node - membersQ := h.deps.DB.WithContext(ctx).Model(&model.Node{}). - Select("nodes.*"). - Joins("JOIN community_memberships ON community_memberships.node_id = nodes.id"). - Joins("JOIN communities ON communities.id = community_memberships.community_id"). - Where("community_memberships.community_id = ?", comm.ID). - Where("communities.namespace = ?", ns). - Where("nodes.namespace = ?", ns). - Order("nodes.file_path ASC"). - Order("nodes.start_line ASC"). - Order("nodes.id ASC") - - fetchLimit := memberLimit + 1 - if err := membersQ.Limit(fetchLimit).Offset(memberOffset).Find(&nodes).Error; err != nil { - return "", trace.Wrap(err, "find community nodes") - } - - hasMore := len(nodes) > memberLimit - if hasMore { - nodes = nodes[:memberLimit] - } - - members := make([]communityMemberSummary, len(nodes)) - for i, n := range nodes { - members[i] = nodeToSummary(n) - } - gcData.Members = members - page := paging.BuildPage(paging.Request{Limit: memberLimit, Offset: memberOffset}, len(members), hasMore) - gcData.MembersPagination = &page - } - - result, err := marshalJSON(gcData) - if err != nil { - return "", trace.Wrap(err, "marshal result") - } - return result, nil - })) -} - -// getArchitectureOverview summarizes communities, coupling, and architecture warnings. -// @intent Summarizes codebase module boundaries and tightly coupled areas in a single response. -// @ensures Returns a list of communities, coupling pairs, and warning messages on success. -// @domainRule Pairs with coupling strength exceeding 0.8 are marked as warnings. -// @see mcp.handlers.listCommunities -func (h *handlers) getArchitectureOverview(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ctx = h.applyNamespace(ctx, request) - log := h.logger() - communityLimit := request.GetInt("community_limit", defaultQueryGraphLimit) - communityOffset := request.GetInt("community_offset", 0) - couplingLimit := request.GetInt("coupling_limit", defaultQueryGraphLimit) - couplingOffset := request.GetInt("coupling_offset", 0) - if err := validateQueryGraphLimit(communityLimit); err != nil { - return finalizeToolResult("", err) - } - if err := validateOffset(communityOffset); err != nil { - return finalizeToolResult("", err) - } - if err := validateQueryGraphLimit(couplingLimit); err != nil { - return finalizeToolResult("", err) - } - if err := validateOffset(couplingOffset); err != nil { - return finalizeToolResult("", err) - } - - log.Info("get_architecture_overview called", "community_limit", communityLimit, "community_offset", communityOffset, "coupling_limit", couplingLimit, "coupling_offset", couplingOffset) - - return finalizeToolResult(h.cachedExecute(ctx, "get_architecture_overview:", map[string]any{ - "community_limit": communityLimit, - "community_offset": communityOffset, - "coupling_limit": couplingLimit, - "coupling_offset": couplingOffset, - "namespace": requestNamespace(request), - }, func() (string, error) { - ns := ctxns.FromContext(ctx) - - var archCCRows []archCommCount - archCountQ := h.deps.DB.WithContext(ctx). - Model(&model.Community{}). - Select("communities.id AS id, communities.label AS label, COALESCE(COUNT(community_memberships.id),0) AS node_count"). - Joins("LEFT JOIN community_memberships ON community_memberships.community_id = communities.id"). - Where("communities.namespace = ?", ns). - Group("communities.id, communities.label"). - Order("node_count DESC, communities.label ASC, communities.id ASC") - - archFetchLimit := communityLimit + 1 - if err := archCountQ.Limit(archFetchLimit).Offset(communityOffset).Find(&archCCRows).Error; err != nil { - return "", trace.Wrap(err, "find communities for architecture overview") - } - - communityHasMore := len(archCCRows) > communityLimit - if communityHasMore { - archCCRows = archCCRows[:communityLimit] - } - - commInfos := make([]architectureOverviewCommunity, len(archCCRows)) - for i, c := range archCCRows { - commInfos[i] = architectureOverviewCommunity{ID: c.ID, Label: c.Label, NodeCount: c.NodeCount} - } - - couplingPairs := []architectureOverviewCoupling{} - warnings := []string{} - if len(archCCRows) == 0 { - warnings = []string{"No communities found. Run community rebuild first."} - } - - couplingHasMore := false - if h.deps.CouplingAnalyzer != nil { - page, err := h.deps.CouplingAnalyzer.AnalyzePage(ctx, paging.Request{Limit: couplingLimit, Offset: couplingOffset}) - if err == nil { - for _, cp := range page.Items { - couplingPairs = append(couplingPairs, architectureOverviewCoupling{From: cp.FromCommunity, To: cp.ToCommunity, EdgeCount: cp.EdgeCount, Strength: cp.Strength}) - if cp.Strength > 0.8 { - warnings = append(warnings, fmt.Sprintf("High coupling between %s and %s (strength: %.2f)", cp.FromCommunity, cp.ToCommunity, cp.Strength)) - } - } - couplingHasMore = page.Pagination.HasMore - } - } - - result, err := marshalJSON(architectureOverviewResponse{ - Communities: commInfos, - CommunitiesPagination: paging.BuildPage(paging.Request{Limit: communityLimit, Offset: communityOffset}, len(commInfos), communityHasMore), - Coupling: couplingPairs, - CouplingPagination: paging.BuildPage(paging.Request{Limit: couplingLimit, Offset: couplingOffset}, len(couplingPairs), couplingHasMore), - Warnings: warnings, - DerivedState: derivedStateSummary(), - }) - if err != nil { - return "", trace.Wrap(err, "marshal result") - } - return result, nil - })) -} - -// @intent describe community-membership freshness so callers know when to re-run postprocess. -func derivedStateCommunities() map[string]any { - return map[string]any{ - "communities": map[string]any{ - "freshness": "unknown", - "source": "stored_community_memberships", - "refresh_hint": "run_postprocess with communities=true after graph changes", - }, - } -} - // @intent describe flow-membership freshness so callers know when to re-run postprocess. func derivedStateFlows() map[string]any { return map[string]any{ @@ -481,9 +126,5 @@ func derivedStateFlows() map[string]any { // @intent merge community and flow freshness hints into a single derived-state map for status responses. func derivedStateSummary() map[string]any { - state := derivedStateCommunities() - for k, v := range derivedStateFlows() { - state[k] = v - } - return state + return derivedStateFlows() } diff --git a/internal/mcp/handler_parse.go b/internal/mcp/handler_parse.go index 9a3fe46..e4b5882 100644 --- a/internal/mcp/handler_parse.go +++ b/internal/mcp/handler_parse.go @@ -10,7 +10,6 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/tae2089/trace" - "github.com/tae2089/code-context-graph/internal/analysis/community" flowspkg "github.com/tae2089/code-context-graph/internal/analysis/flows" "github.com/tae2089/code-context-graph/internal/obs" postprocesspolicy "github.com/tae2089/code-context-graph/internal/postprocess/policy" @@ -239,21 +238,6 @@ func (h *handlers) buildOrUpdateGraph(ctx context.Context, request mcp.CallToolR } else { skippedSteps = append(skippedSteps, "flows") } - // community rebuild - if h.deps.CommunityBuilder != nil { - _, err := h.deps.CommunityBuilder.Rebuild(ctx, community.Config{Depth: 2}) - if err != nil { - if failClosed { - failClosedErr = err - failedSteps = append(failedSteps, "communities") - break - } - log.WarnContext(ctx, "community rebuild failed", append(obs.TraceLogArgs(ctx), trace.SlogError(err))...) - failedSteps = append(failedSteps, "communities") - } - } else { - skippedSteps = appendUniqueStrings(skippedSteps, "communities") - } // search rebuild if h.deps.SearchBackend != nil && h.deps.DB != nil { if _, err := h.refreshSearchDocuments(ctx); err != nil { @@ -358,17 +342,12 @@ func (h *handlers) runPostprocess(ctx context.Context, request mcp.CallToolReque log := h.logger() doFlows := request.GetBool("flows", true) - doCommunities := request.GetBool("communities", true) doFTS := request.GetBool("fts", true) postprocessPolicy := request.GetString("postprocess_policy", "") policySource := postprocesspolicy.SourceExplicit if postprocessPolicy == "" { policySource = postprocesspolicy.SourceAuto } - communityDepth := request.GetInt("community_depth", 2) - if communityDepth < 1 || communityDepth > 8 { - return mcp.NewToolResultError("community_depth must be between 1 and 8"), nil - } if postprocessPolicy != "" && postprocessPolicy != postprocesspolicy.PolicyDegraded && postprocessPolicy != postprocesspolicy.PolicyFailClosed { return mcp.NewToolResultError("postprocess_policy must be degraded or fail_closed"), nil } @@ -387,7 +366,7 @@ func (h *handlers) runPostprocess(ctx context.Context, request mcp.CallToolReque } failClosed := postprocessPolicy == postprocesspolicy.PolicyFailClosed - log.Info("run_postprocess called", "flows", doFlows, "communities", doCommunities, "fts", doFTS) + log.Info("run_postprocess called", "flows", doFlows, "fts", doFTS) var flowsCount, communitiesCount, ftsIndexed int var failedSteps []string @@ -416,27 +395,6 @@ func (h *handlers) runPostprocess(ctx context.Context, request mcp.CallToolReque skippedSteps = appendUniqueStrings(skippedSteps, "flows") } - if doCommunities { - if h.deps.CommunityBuilder != nil { - stats, err := h.deps.CommunityBuilder.Rebuild(ctx, community.Config{Depth: communityDepth}) - if err != nil { - if failClosed { - failClosedErr = err - failedSteps = append(failedSteps, "communities") - } else { - log.Warn("community rebuild failed", trace.SlogError(err)) - failedSteps = append(failedSteps, "communities") - } - } else { - communitiesCount = len(stats) - } - } else { - skippedSteps = appendUniqueStrings(skippedSteps, "communities") - } - } else { - skippedSteps = appendUniqueStrings(skippedSteps, "communities") - } - if doFTS { if h.deps.SearchBackend != nil && h.deps.DB != nil { if _, err := h.refreshSearchDocuments(ctx); err != nil { diff --git a/internal/mcp/handler_query.go b/internal/mcp/handler_query.go index 8d73031..be3c334 100644 --- a/internal/mcp/handler_query.go +++ b/internal/mcp/handler_query.go @@ -10,7 +10,6 @@ import ( "github.com/tae2089/trace" "gorm.io/gorm" - "github.com/tae2089/code-context-graph/internal/analysis/largefunc" querypkg "github.com/tae2089/code-context-graph/internal/analysis/query" "github.com/tae2089/code-context-graph/internal/ccgref" "github.com/tae2089/code-context-graph/internal/ctxns" @@ -28,14 +27,6 @@ const ( searchPathFetchCap = 500 ) -// largeFunctionItem summarizes one oversized function candidate. -// @intent preserve a stable per-item DTO for findLargeFunctions responses. -type largeFunctionItem struct { - Name string `json:"name"` - File string `json:"file"` - Lines int `json:"lines"` -} - // annotationTagItem serializes one stored annotation tag. // @intent expose annotation tags with typed fields for getAnnotation callers. type annotationTagItem struct { @@ -691,50 +682,3 @@ func (h *handlers) listGraphStats(ctx context.Context, request mcp.CallToolReque return result, nil })) } - -// findLargeFunctions returns functions whose line counts exceed a threshold. -// @intent find oversized functions so maintainers can prioritize refactoring or review attention. -// @param request min_lines is the length threshold and path is an optional file path prefix filter. -// @requires LargefuncAnalyzer must be configured. -// @ensures returns functions exceeding the threshold and their count when analysis succeeds. -// @domainRule function length is calculated as end_line-start_line+1. -func (h *handlers) findLargeFunctions(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ctx = h.applyNamespace(ctx, request) - log := h.logger() - - input, err := decodeFindLargeFuncsRequest(request) - if err != nil { - return finalizeToolResult("", err) - } - - log.Info("find_large_functions called", "min_lines", input.MinLines, "limit", input.Page.Limit, "offset", input.Page.Offset, "path", input.PathPrefix) - - if h.deps.LargefuncAnalyzer == nil { - return mcp.NewToolResultError("LargefuncAnalyzer not configured"), nil - } - - cacheParams := map[string]any{ - "min_lines": input.MinLines, - "limit": input.Page.Limit, - "offset": input.Page.Offset, - "path": input.PathPrefix, - "namespace": input.Namespace, - } - return finalizeToolResult(h.cachedExecute(ctx, "find_large_functions:", cacheParams, func() (string, error) { - page, err := h.deps.LargefuncAnalyzer.FindPage(ctx, largefunc.Options{Threshold: input.MinLines, PathPrefix: input.PathPrefix, Page: input.Page}) - if err != nil { - return "", trace.Wrap(err, "largefunc error") - } - - items := make([]largeFunctionItem, len(page.Items)) - for i, n := range page.Items { - items[i] = largeFunctionItem{Name: n.QualifiedName, File: n.FilePath, Lines: n.EndLine - n.StartLine + 1} - } - - result, err := encodePagedListResponse("results", items, page.Pagination) - if err != nil { - return "", trace.Wrap(err, "marshal result") - } - return result, nil - })) -} diff --git a/internal/mcp/handlers_test.go b/internal/mcp/handlers_test.go index 6dcf5e1..d22aa79 100644 --- a/internal/mcp/handlers_test.go +++ b/internal/mcp/handlers_test.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "log/slog" "os" "path/filepath" @@ -15,16 +14,11 @@ import ( "testing" "time" - "github.com/mark3labs/mcp-go/mcp" oteltrace "go.opentelemetry.io/otel/trace" "gorm.io/gorm" "github.com/tae2089/code-context-graph/internal/analysis/changes" - "github.com/tae2089/code-context-graph/internal/analysis/community" - "github.com/tae2089/code-context-graph/internal/analysis/coupling" - "github.com/tae2089/code-context-graph/internal/analysis/coverage" fallbackanalysis "github.com/tae2089/code-context-graph/internal/analysis/fallback" - "github.com/tae2089/code-context-graph/internal/analysis/flows" "github.com/tae2089/code-context-graph/internal/analysis/incremental" "github.com/tae2089/code-context-graph/internal/analysis/query" "github.com/tae2089/code-context-graph/internal/ctxns" @@ -488,96 +482,6 @@ func TestHandler_GetAnnotation_ExposesCCGSeeRef(t *testing.T) { // 11.0 Structural change (Tidy First) // ============================================================ -func TestDeps_NewInterfaces(t *testing.T) { - // The existing six tools must still work even when the new interface fields are nil. - deps := setupTestDepsMinimal(t) - ctx := context.Background() - - // Set up the data required by the existing tools. - deps.Store.UpsertNodes(ctx, []model.Node{ - {QualifiedName: "pkg.Func1", Kind: model.NodeKindFunction, Name: "Func1", FilePath: "func1.go", StartLine: 1, EndLine: 5, Language: "go"}, - }) - - // Call the existing tools while every new interface field is nil. - // QueryService, LargefuncAnalyzer, DeadcodeAnalyzer, CouplingAnalyzer, - // CoverageAnalyzer, CommunityBuilder, and Incremental are all nil. - if deps.QueryService != nil { - t.Error("expected QueryService to be nil") - } - if deps.LargefuncAnalyzer != nil { - t.Error("expected LargefuncAnalyzer to be nil") - } - if deps.DeadcodeAnalyzer != nil { - t.Error("expected DeadcodeAnalyzer to be nil") - } - if deps.CouplingAnalyzer != nil { - t.Error("expected CouplingAnalyzer to be nil") - } - if deps.CoverageAnalyzer != nil { - t.Error("expected CoverageAnalyzer to be nil") - } - if deps.CommunityBuilder != nil { - t.Error("expected CommunityBuilder to be nil") - } - if deps.FlowBuilder != nil { - t.Error("expected FlowBuilder to be nil") - } - if deps.Incremental != nil { - t.Error("expected Incremental to be nil") - } - - // Verify that the existing six tools still work. - result := callTool(t, deps, "get_node", map[string]any{"qualified_name": "pkg.Func1"}) - if result.IsError { - t.Fatalf("get_node should work with nil new interfaces: %s", getTextContent(result)) - } -} - -func TestPrompts_UsesDepsInterfaces(t *testing.T) { - // Keep the existing five prompt tests after refactoring prompts.go to use Deps fields. - // When QueryService, LargefuncAnalyzer, and others are set on Deps, prompts.go must use them. - deps := setupTestDeps(t) - ctx := context.Background() - - // Set Deps fields with mock implementations. - mockQuery := &mockQueryService{} - mockLF := &mockLargefuncAnalyzer{} - mockDC := &mockDeadcodeAnalyzer{} - mockCoup := &mockCouplingAnalyzer{} - mockCov := &mockCoverageAnalyzer{} - - deps.QueryService = mockQuery - deps.LargefuncAnalyzer = mockLF - deps.DeadcodeAnalyzer = mockDC - deps.CouplingAnalyzer = mockCoup - deps.CoverageAnalyzer = mockCov - - // Set up test data. - deps.Store.UpsertNodes(ctx, []model.Node{ - {QualifiedName: "pkg.TestFunc", Kind: model.NodeKindFunction, Name: "TestFunc", FilePath: "test.go", StartLine: 1, EndLine: 100, Language: "go"}, - }) - - // Call the onboard_developer prompt; it must use LargefuncAnalyzer from Deps when present. - srv := NewServer(deps) - msg, _ := json.Marshal(map[string]any{ - "jsonrpc": "2.0", - "id": 1, - "method": "prompts/get", - "params": map[string]any{"name": "onboard_developer"}, - }) - resp := srv.HandleMessage(ctx, msg) - rpcResp, ok := resp.(mcp.JSONRPCResponse) - if !ok { - t.Fatalf("expected JSONRPCResponse, got %T", resp) - } - _ = rpcResp - - // Check that mockLF.findCalled is true to verify that Deps.LargefuncAnalyzer was used. - if !mockLF.findPageCalled { - t.Error("expected prompts.go to use Deps.LargefuncAnalyzer.FindPage instead of inline creation") - } -} - func TestHandler_TraceFlow(t *testing.T) { deps := setupTestDeps(t) ctx := context.Background() @@ -1236,170 +1140,6 @@ func Replaced() {} } } -func TestBuildOrUpdateGraph_PostprocessFull(t *testing.T) { - deps := setupTestDeps(t) - - mockComm := &mockCommunityBuilder{ - result: []community.Stats{}, - } - mockFlow := &mockFlowBuilder{} - deps.CommunityBuilder = mockComm - deps.FlowBuilder = mockFlow - - dir := t.TempDir() - writeGoFile(t, dir, "svc.go", `package svc - -func Run() {} -`) - - result := callTool(t, deps, "build_or_update_graph", map[string]any{ - "path": dir, - "full_rebuild": true, - "postprocess": "full", - }) - if result.IsError { - t.Fatalf("build_or_update_graph error: %s", getTextContent(result)) - } - - if !mockComm.rebuildCalled { - t.Error("expected CommunityBuilder.Rebuild to be called for postprocess=full") - } - if !mockFlow.rebuildCalled { - t.Error("expected FlowBuilder.Rebuild to be called for postprocess=full") - } -} - -func TestBuildOrUpdateGraph_PostprocessNone(t *testing.T) { - deps := setupTestDeps(t) - - mockComm := &mockCommunityBuilder{} - deps.CommunityBuilder = mockComm - - dir := t.TempDir() - writeGoFile(t, dir, "svc.go", `package svc - -func Run() {} -`) - - result := callTool(t, deps, "build_or_update_graph", map[string]any{ - "path": dir, - "full_rebuild": true, - "postprocess": "none", - }) - if result.IsError { - t.Fatalf("build_or_update_graph error: %s", getTextContent(result)) - } - - if mockComm.rebuildCalled { - t.Error("expected CommunityBuilder.Rebuild NOT to be called for postprocess=none") - } -} - -func TestBuildOrUpdateGraph_EmptyFailedStepsIsNullJSON(t *testing.T) { - deps := setupTestDeps(t) - - mockComm := &mockCommunityBuilder{} - deps.CommunityBuilder = mockComm - - dir := t.TempDir() - writeGoFile(t, dir, "svc.go", `package svc - -func Run() {} -`) - - result := callTool(t, deps, "build_or_update_graph", map[string]any{ - "path": dir, - "full_rebuild": true, - "postprocess": "none", - }) - if result.IsError { - t.Fatalf("build_or_update_graph error: %s", getTextContent(result)) - } - - var resp struct { - FailedSteps json.RawMessage `json:"failed_steps"` - SkippedSteps json.RawMessage `json:"skipped_steps"` - Status string `json:"status"` - } - text := getTextContent(result) - if err := json.Unmarshal([]byte(text), &resp); err != nil { - t.Fatalf("expected JSON, got: %s", text) - } - if string(resp.FailedSteps) != "null" { - t.Fatalf("expected failed_steps=null, got %s", string(resp.FailedSteps)) - } - if string(resp.SkippedSteps) == "null" || len(resp.SkippedSteps) == 0 { - t.Fatalf("expected skipped_steps array, got %s", string(resp.SkippedSteps)) - } - - var skipped []any - if err := json.Unmarshal(resp.SkippedSteps, &skipped); err != nil { - t.Fatalf("expected skipped_steps array, got %s", string(resp.SkippedSteps)) - } - if !containsString(skipped, "communities") || !containsString(skipped, "flows") { - t.Fatalf("expected skipped_steps to contain communities and flows, got %v", skipped) - } -} - -func TestBuildOrUpdateGraph_DegradedOnCommunityFailure(t *testing.T) { - deps := setupTestDeps(t) - deps.CommunityBuilder = &mockCommunityBuilder{err: errors.New("community rebuild boom")} - - dir := t.TempDir() - writeGoFile(t, dir, "svc.go", `package svc - -func Run() {} -`) - - result := callTool(t, deps, "build_or_update_graph", map[string]any{ - "path": dir, - "full_rebuild": true, - "postprocess": "full", - }) - if result.IsError { - t.Fatalf("build_or_update_graph should not return tool error, got: %s", getTextContent(result)) - } - - var resp map[string]any - if err := json.Unmarshal([]byte(getTextContent(result)), &resp); err != nil { - t.Fatalf("expected JSON, got: %s", getTextContent(result)) - } - if resp["status"] != "degraded" { - t.Fatalf("expected status=degraded, got %v", resp["status"]) - } - failedSteps, ok := resp["failed_steps"].([]any) - if !ok || len(failedSteps) == 0 { - t.Fatalf("expected failed_steps to be non-empty, got %v", resp["failed_steps"]) - } - if !containsString(failedSteps, "communities") { - t.Fatalf("expected communities in failed_steps, got %v", failedSteps) - } -} - -func TestBuildOrUpdateGraph_FailClosedOnCommunityFailure(t *testing.T) { - deps := setupTestDeps(t) - deps.CommunityBuilder = &mockCommunityBuilder{err: errors.New("community rebuild boom")} - - dir := t.TempDir() - writeGoFile(t, dir, "svc.go", `package svc - -func Run() {} -`) - - result := callTool(t, deps, "build_or_update_graph", map[string]any{ - "path": dir, - "full_rebuild": true, - "postprocess": "full", - "postprocess_policy": "fail_closed", - }) - if !result.IsError { - t.Fatalf("expected fail_closed community failure to return tool error, got: %s", getTextContent(result)) - } - if !strings.Contains(getTextContent(result), "community rebuild boom") { - t.Fatalf("unexpected error: %s", getTextContent(result)) - } -} - func TestBuildOrUpdateGraph_DegradedOnSearchDocumentRefreshFailure(t *testing.T) { deps := setupTestDeps(t) backend := &failSearchBackend{} @@ -1541,80 +1281,6 @@ func Run() {} } } -func TestBuildOrUpdateGraph_UsesAutomaticPolicyWhenNotExplicitlyProvided(t *testing.T) { - deps := setupTestDeps(t) - stub := &stubPostprocessPolicy{resolvedPolicy: "fail_closed", resolvedSource: "auto"} - deps.PostprocessPolicy = stub - deps.CommunityBuilder = &mockCommunityBuilder{err: errors.New("community rebuild boom")} - - dir := t.TempDir() - writeGoFile(t, dir, "svc.go", "package svc\nfunc Run() {}\n") - - result := callTool(t, deps, "build_or_update_graph", map[string]any{ - "path": dir, - "full_rebuild": true, - "postprocess": "full", - }) - if !result.IsError { - t.Fatalf("expected auto fail_closed policy to return tool error, got: %s", getTextContent(result)) - } - if len(stub.resolvedInputs) != 1 { - t.Fatalf("resolve calls = %d, want 1", len(stub.resolvedInputs)) - } - if stub.resolvedInputs[0].ExplicitPolicy != "" { - t.Fatalf("explicit policy = %q, want empty", stub.resolvedInputs[0].ExplicitPolicy) - } -} - -func TestBuildOrUpdateGraph_PassesExplicitPolicyToResolverAndRecordsRun(t *testing.T) { - deps := setupTestDeps(t) - stub := &stubPostprocessPolicy{resolvedPolicy: "degraded", resolvedSource: "explicit"} - deps.PostprocessPolicy = stub - deps.CommunityBuilder = &mockCommunityBuilder{err: errors.New("community rebuild boom")} - - dir := t.TempDir() - writeGoFile(t, dir, "svc.go", "package svc\nfunc Run() {}\n") - - result := callTool(t, deps, "build_or_update_graph", map[string]any{ - "path": dir, - "full_rebuild": true, - "postprocess": "full", - "postprocess_policy": "degraded", - }) - if result.IsError { - t.Fatalf("expected explicit degraded resolver result not to error, got: %s", getTextContent(result)) - } - if got := len(stub.resolvedInputs); got != 1 { - t.Fatalf("resolve calls = %d, want 1", got) - } - if stub.resolvedInputs[0].ExplicitPolicy != "degraded" { - t.Fatalf("explicit policy = %q, want degraded", stub.resolvedInputs[0].ExplicitPolicy) - } - if got := len(stub.recordedRuns); got != 1 { - t.Fatalf("recorded runs = %d, want 1", got) - } - if stub.recordedRuns[0].Policy != "degraded" { - t.Fatalf("recorded policy = %q, want degraded", stub.recordedRuns[0].Policy) - } - if stub.recordedRuns[0].Source != "explicit" { - t.Fatalf("recorded source = %q, want explicit", stub.recordedRuns[0].Source) - } - if stub.recordedRuns[0].Tool != "build_or_update_graph" { - t.Fatalf("recorded tool = %q, want build_or_update_graph", stub.recordedRuns[0].Tool) - } - - var resp map[string]any - if err := json.Unmarshal([]byte(getTextContent(result)), &resp); err != nil { - t.Fatalf("expected JSON, got: %s", getTextContent(result)) - } - if resp["postprocess_policy"] != "degraded" { - t.Fatalf("response postprocess_policy = %v, want degraded", resp["postprocess_policy"]) - } - if resp["policy_source"] != "explicit" { - t.Fatalf("response policy_source = %v, want explicit", resp["policy_source"]) - } -} - func containsString(values []any, target string) bool { for _, v := range values { if s, ok := v.(string); ok && s == target { @@ -1706,49 +1372,6 @@ func Other() {} // 11.2 run_postprocess // ============================================================ -func TestRunPostprocess_AllEnabled(t *testing.T) { - deps := setupTestDeps(t) - - mockComm := &mockCommunityBuilder{result: []community.Stats{}} - mockFlow := &mockFlowBuilder{result: []flows.Stats{{NodeCount: 2}}} - deps.CommunityBuilder = mockComm - deps.FlowBuilder = mockFlow - - result := callTool(t, deps, "run_postprocess", map[string]any{ - "flows": true, - "communities": true, - "fts": true, - }) - if result.IsError { - t.Fatalf("run_postprocess error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - if err := json.Unmarshal([]byte(text), &resp); err != nil { - t.Fatalf("expected JSON, got: %s", text) - } - if resp["status"] != "ok" { - t.Errorf("expected status=ok, got %v", resp["status"]) - } - if !mockComm.rebuildCalled { - t.Error("expected CommunityBuilder.Rebuild to be called") - } - if !mockFlow.rebuildCalled { - t.Error("expected FlowBuilder.Rebuild to be called") - } - if got := resp["flows_count"]; got != float64(1) { - t.Errorf("expected flows_count=1 after flow rebuild, got %v", got) - } - skipped, ok := resp["skipped_steps"].([]any) - if !ok { - t.Fatalf("expected skipped_steps array, got %v", resp["skipped_steps"]) - } - if containsString(skipped, "flows") { - t.Fatalf("expected skipped_steps to omit flows when builder is configured, got %v", resp["skipped_steps"]) - } -} - func TestRunPostprocess_FlowsSkippedWhenBuilderNil(t *testing.T) { deps := setupTestDeps(t) deps.FlowBuilder = nil @@ -1775,33 +1398,12 @@ func TestRunPostprocess_FlowsSkippedWhenBuilderNil(t *testing.T) { } } -func TestRunPostprocess_OnlyFTS(t *testing.T) { +func TestRunPostprocess_NoneEnabled(t *testing.T) { deps := setupTestDeps(t) - mockComm := &mockCommunityBuilder{} - deps.CommunityBuilder = mockComm - result := callTool(t, deps, "run_postprocess", map[string]any{ - "flows": false, - "communities": false, - "fts": true, - }) - if result.IsError { - t.Fatalf("run_postprocess error: %s", getTextContent(result)) - } - - if mockComm.rebuildCalled { - t.Error("expected CommunityBuilder.Rebuild NOT to be called") - } -} - -func TestRunPostprocess_NoneEnabled(t *testing.T) { - deps := setupTestDeps(t) - - result := callTool(t, deps, "run_postprocess", map[string]any{ - "flows": false, - "communities": false, - "fts": false, + "flows": false, + "fts": false, }) if result.IsError { t.Fatalf("run_postprocess error: %s", getTextContent(result)) @@ -1821,9 +1423,8 @@ func TestRunPostprocess_EmptyFailedStepsIsNullJSON(t *testing.T) { deps := setupTestDeps(t) result := callTool(t, deps, "run_postprocess", map[string]any{ - "flows": false, - "communities": false, - "fts": false, + "flows": false, + "fts": false, }) if result.IsError { t.Fatalf("run_postprocess error: %s", getTextContent(result)) @@ -1849,60 +1450,13 @@ func TestRunPostprocess_EmptyFailedStepsIsNullJSON(t *testing.T) { if err := json.Unmarshal(resp.SkippedSteps, &skipped); err != nil { t.Fatalf("expected skipped_steps array, got %s", string(resp.SkippedSteps)) } - for _, want := range []string{"flows", "communities", "search_documents", "fts"} { + for _, want := range []string{"flows", "search_documents", "fts"} { if !containsString(skipped, want) { t.Fatalf("expected skipped_steps to contain %s, got %v", want, skipped) } } } -func TestRunPostprocess_RejectsInvalidCommunityDepth(t *testing.T) { - for _, depth := range []int{0, 9} { - t.Run(fmt.Sprintf("depth-%d", depth), func(t *testing.T) { - deps := setupTestDeps(t) - mockComm := &mockCommunityBuilder{} - deps.CommunityBuilder = mockComm - - result := callTool(t, deps, "run_postprocess", map[string]any{ - "communities": true, - "fts": false, - "community_depth": depth, - }) - if !result.IsError { - t.Fatalf("expected community_depth=%d to be rejected", depth) - } - if mockComm.rebuildCalled { - t.Fatal("community rebuild should not run for invalid depth") - } - }) - } -} - -func TestRunPostprocess_UsesAutomaticPolicyWhenNotExplicitlyProvided(t *testing.T) { - deps := setupTestDeps(t) - stub := &stubPostprocessPolicy{resolvedPolicy: "fail_closed", resolvedSource: "auto"} - deps.PostprocessPolicy = stub - deps.CommunityBuilder = &mockCommunityBuilder{err: errors.New("community rebuild boom")} - - result := callTool(t, deps, "run_postprocess", map[string]any{ - "communities": true, - "fts": false, - "flows": false, - }) - if !result.IsError { - t.Fatalf("expected auto fail_closed policy to return tool error, got: %s", getTextContent(result)) - } - if len(stub.resolvedInputs) != 1 { - t.Fatalf("resolve calls = %d, want 1", len(stub.resolvedInputs)) - } - if stub.resolvedInputs[0].Tool != "run_postprocess" { - t.Fatalf("resolver tool = %q, want run_postprocess", stub.resolvedInputs[0].Tool) - } - if stub.resolvedInputs[0].ExplicitPolicy != "" { - t.Fatalf("explicit policy = %q, want empty", stub.resolvedInputs[0].ExplicitPolicy) - } -} - func TestRunPostprocess_PassesExplicitPolicyToResolverAndRecordsRun(t *testing.T) { deps := setupTestDeps(t) stub := &stubPostprocessPolicy{resolvedPolicy: "degraded", resolvedSource: "explicit"} @@ -1946,90 +1500,6 @@ func TestRunPostprocess_PassesExplicitPolicyToResolverAndRecordsRun(t *testing.T } } -func TestRunPostprocess_AutoEscalatesAfterThreeFailuresWithRealPolicyStore(t *testing.T) { - deps := setupTestDepsWithRealPostprocessPolicy(t) - deps.CommunityBuilder = &mockCommunityBuilder{err: errors.New("community rebuild boom")} - - for i := 0; i < 3; i++ { - result := callTool(t, deps, "run_postprocess", map[string]any{ - "communities": true, - "fts": false, - "flows": false, - }) - if result.IsError { - t.Fatalf("attempt %d should be degraded before escalation, got: %s", i+1, getTextContent(result)) - } - var resp map[string]any - if err := json.Unmarshal([]byte(getTextContent(result)), &resp); err != nil { - t.Fatalf("expected JSON, got: %s", getTextContent(result)) - } - if resp["postprocess_policy"] != "degraded" { - t.Fatalf("attempt %d policy = %v, want degraded", i+1, resp["postprocess_policy"]) - } - } - - result := callTool(t, deps, "run_postprocess", map[string]any{ - "communities": true, - "fts": false, - "flows": false, - }) - if !result.IsError { - t.Fatalf("expected fourth attempt to fail_closed, got: %s", getTextContent(result)) - } - - policyStore := postprocesspolicy.NewStore(deps.DB) - count, err := policyStore.ConsecutiveFailures(context.Background(), postprocesspolicy.ToolRunPostprocess, 10) - if err != nil { - t.Fatalf("consecutive failures: %v", err) - } - if count != 4 { - t.Fatalf("consecutive failures = %d, want 4", count) - } - state, err := policyStore.GetState(context.Background(), postprocesspolicy.ToolRunPostprocess) - if err != nil { - t.Fatalf("get state: %v", err) - } - if state == nil || state.Policy != postprocesspolicy.PolicyFailClosed { - t.Fatalf("state policy = %v, want fail_closed", state) - } -} - -func TestRunPostprocess_RealPolicyStoreIsolatedByNamespaceAndTool(t *testing.T) { - deps := setupTestDepsWithRealPostprocessPolicy(t) - deps.CommunityBuilder = &mockCommunityBuilder{err: errors.New("community rebuild boom")} - - for i := 0; i < 3; i++ { - result := callToolWithNamespace(t, deps, "ns-a", "run_postprocess", map[string]any{ - "communities": true, - "fts": false, - "flows": false, - }) - if result.IsError { - t.Fatalf("ns-a attempt %d should be degraded, got: %s", i+1, getTextContent(result)) - } - } - - dir := t.TempDir() - writeGoFile(t, dir, "svc.go", "package svc\nfunc Run() {}\n") - buildResult := callToolWithNamespace(t, deps, "ns-b", "build_or_update_graph", map[string]any{ - "path": dir, - "full_rebuild": true, - "postprocess": "none", - }) - if buildResult.IsError { - t.Fatalf("ns-b build should not be affected by ns-a run_postprocess failures, got: %s", getTextContent(buildResult)) - } - - fourth := callToolWithNamespace(t, deps, "ns-a", "run_postprocess", map[string]any{ - "communities": true, - "fts": false, - "flows": false, - }) - if !fourth.IsError { - t.Fatalf("expected ns-a fourth run_postprocess attempt to fail_closed, got: %s", getTextContent(fourth)) - } -} - func TestGetPostprocessPolicy_UsesPolicyStatusSummary(t *testing.T) { deps := setupTestDeps(t) deps.PostprocessPolicy = &stubPostprocessPolicy{ @@ -2644,135 +2114,6 @@ func TestListGraphStats_EmptyDB(t *testing.T) { } } -// ============================================================ -// 11.5 find_large_functions -// ============================================================ - -func TestFindLargeFunctions_DefaultThreshold(t *testing.T) { - deps := setupTestDeps(t) - - mockLF := &mockLargefuncAnalyzer{ - result: []model.Node{ - {QualifiedName: "pkg.Big", Kind: model.NodeKindFunction, Name: "Big", FilePath: "big.go", StartLine: 1, EndLine: 100}, - }, - } - deps.LargefuncAnalyzer = mockLF - - result := callTool(t, deps, "find_large_functions", map[string]any{}) - if result.IsError { - t.Fatalf("find_large_functions error: %s", getTextContent(result)) - } - if !mockLF.findPageCalled { - t.Error("expected FindPage to be called") - } -} - -func TestFindLargeFunctions_CustomThreshold(t *testing.T) { - deps := setupTestDeps(t) - - mockLF := &mockLargefuncAnalyzer{ - result: []model.Node{ - {QualifiedName: "pkg.Medium", Kind: model.NodeKindFunction, Name: "Medium", FilePath: "med.go", StartLine: 1, EndLine: 40}, - }, - } - deps.LargefuncAnalyzer = mockLF - - result := callTool(t, deps, "find_large_functions", map[string]any{"min_lines": 30}) - if result.IsError { - t.Fatalf("find_large_functions error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - if resp["count"].(float64) != 1 { - t.Errorf("expected 1 result, got %v", resp["count"]) - } -} - -func TestFindLargeFunctions_Limit(t *testing.T) { - deps := setupTestDeps(t) - - mockLF := &mockLargefuncAnalyzer{ - result: []model.Node{ - {QualifiedName: "pkg.A", Kind: model.NodeKindFunction, Name: "A", FilePath: "a.go", StartLine: 1, EndLine: 100}, - {QualifiedName: "pkg.B", Kind: model.NodeKindFunction, Name: "B", FilePath: "b.go", StartLine: 1, EndLine: 80}, - {QualifiedName: "pkg.C", Kind: model.NodeKindFunction, Name: "C", FilePath: "c.go", StartLine: 1, EndLine: 60}, - {QualifiedName: "pkg.D", Kind: model.NodeKindFunction, Name: "D", FilePath: "d.go", StartLine: 1, EndLine: 55}, - }, - } - deps.LargefuncAnalyzer = mockLF - - result := callTool(t, deps, "find_large_functions", map[string]any{"limit": 3}) - if result.IsError { - t.Fatalf("find_large_functions error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - if resp["count"].(float64) != 3 { - t.Errorf("expected 3 results (limit), got %v", resp["count"]) - } -} - -func TestFindLargeFunctions_NoResults(t *testing.T) { - deps := setupTestDeps(t) - - mockLF := &mockLargefuncAnalyzer{result: []model.Node{}} - deps.LargefuncAnalyzer = mockLF - - result := callTool(t, deps, "find_large_functions", map[string]any{"min_lines": 1000}) - if result.IsError { - t.Fatalf("find_large_functions error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - if resp["count"].(float64) != 0 { - t.Errorf("expected 0 results, got %v", resp["count"]) - } -} - -func TestFindLargeFunctions_InvalidLimit(t *testing.T) { - deps := setupTestDeps(t) - - result := callTool(t, deps, "find_large_functions", map[string]any{"limit": 0}) - if !result.IsError { - t.Fatal("expected invalid limit to return tool error") - } - if !strings.Contains(getTextContent(result), "limit must be > 0") { - t.Fatalf("unexpected error: %s", getTextContent(result)) - } -} - -func TestFindLargeFunctions_PathFilter_RespectsPathBoundary(t *testing.T) { - deps := setupTestDeps(t) - deps.LargefuncAnalyzer = &mockLargefuncAnalyzer{result: []model.Node{ - {QualifiedName: "pkg.API", Kind: model.NodeKindFunction, Name: "API", FilePath: "internal/api/handler.go", StartLine: 1, EndLine: 100}, - {QualifiedName: "pkg.API2", Kind: model.NodeKindFunction, Name: "API2", FilePath: "internal/api2/handler.go", StartLine: 1, EndLine: 120}, - }} - - result := callTool(t, deps, "find_large_functions", map[string]any{"path": "internal/api"}) - if result.IsError { - t.Fatalf("find_large_functions error: %s", getTextContent(result)) - } - - var resp map[string]any - if err := json.Unmarshal([]byte(getTextContent(result)), &resp); err != nil { - t.Fatalf("expected JSON, got: %s", getTextContent(result)) - } - if resp["count"].(float64) != 1 { - t.Fatalf("expected 1 boundary-safe result, got %v", resp["count"]) - } - results := resp["results"].([]any) - entry := results[0].(map[string]any) - if entry["file"] != "internal/api/handler.go" { - t.Fatalf("unexpected file: %v", entry["file"]) - } -} - // ============================================================ // 11.6 detect_changes // ============================================================ @@ -3142,472 +2483,15 @@ func TestListFlows_InvalidLimit(t *testing.T) { } // ============================================================ -// 11.9 list_communities // ============================================================ -func TestListCommunities_SortBySize(t *testing.T) { - deps := setupTestDeps(t) - ctx := context.Background() - - c1 := model.Community{Key: "small", Label: "small", Strategy: "directory"} - c2 := model.Community{Key: "big", Label: "big", Strategy: "directory"} - deps.DB.Create(&c1) - deps.DB.Create(&c2) - - deps.Store.UpsertNodes(ctx, []model.Node{ - {QualifiedName: "s.N1", Kind: model.NodeKindFunction, Name: "N1", FilePath: "s.go", StartLine: 1, EndLine: 5, Language: "go"}, - {QualifiedName: "b.N1", Kind: model.NodeKindFunction, Name: "N1", FilePath: "b.go", StartLine: 1, EndLine: 5, Language: "go"}, - {QualifiedName: "b.N2", Kind: model.NodeKindFunction, Name: "N2", FilePath: "b.go", StartLine: 10, EndLine: 15, Language: "go"}, - }) - sn1, _ := deps.Store.GetNode(ctx, "s.N1") - bn1, _ := deps.Store.GetNode(ctx, "b.N1") - bn2, _ := deps.Store.GetNode(ctx, "b.N2") - - deps.DB.Create(&model.CommunityMembership{CommunityID: c1.ID, NodeID: sn1.ID}) - deps.DB.Create(&model.CommunityMembership{CommunityID: c2.ID, NodeID: bn1.ID}) - deps.DB.Create(&model.CommunityMembership{CommunityID: c2.ID, NodeID: bn2.ID}) - - result := callTool(t, deps, "list_communities", map[string]any{"sort_by": "size"}) - if result.IsError { - t.Fatalf("list_communities error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - comms := resp["communities"].([]any) - first := comms[0].(map[string]any) - if first["label"] != "big" { - t.Errorf("expected big first (most nodes), got %v", first["label"]) - } -} - -func TestListCommunities_SortByName(t *testing.T) { - deps := setupTestDeps(t) - - deps.DB.Create(&model.Community{Key: "zulu", Label: "zulu", Strategy: "directory"}) - deps.DB.Create(&model.Community{Key: "alpha", Label: "alpha", Strategy: "directory"}) - - result := callTool(t, deps, "list_communities", map[string]any{"sort_by": "name"}) - if result.IsError { - t.Fatalf("list_communities error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - comms := resp["communities"].([]any) - first := comms[0].(map[string]any) - if first["label"] != "alpha" { - t.Errorf("expected alpha first, got %v", first["label"]) - } -} - -func TestListCommunities_MinSize(t *testing.T) { - deps := setupTestDeps(t) - ctx := context.Background() - - c1 := model.Community{Key: "tiny", Label: "tiny", Strategy: "directory"} - c2 := model.Community{Key: "large", Label: "large", Strategy: "directory"} - deps.DB.Create(&c1) - deps.DB.Create(&c2) - - deps.Store.UpsertNodes(ctx, []model.Node{ - {QualifiedName: "t.N1", Kind: model.NodeKindFunction, Name: "N1", FilePath: "t.go", StartLine: 1, EndLine: 5, Language: "go"}, - {QualifiedName: "l.N1", Kind: model.NodeKindFunction, Name: "N1", FilePath: "l.go", StartLine: 1, EndLine: 5, Language: "go"}, - {QualifiedName: "l.N2", Kind: model.NodeKindFunction, Name: "N2", FilePath: "l.go", StartLine: 10, EndLine: 15, Language: "go"}, - {QualifiedName: "l.N3", Kind: model.NodeKindFunction, Name: "N3", FilePath: "l.go", StartLine: 20, EndLine: 25, Language: "go"}, - }) - tn1, _ := deps.Store.GetNode(ctx, "t.N1") - ln1, _ := deps.Store.GetNode(ctx, "l.N1") - ln2, _ := deps.Store.GetNode(ctx, "l.N2") - ln3, _ := deps.Store.GetNode(ctx, "l.N3") - - deps.DB.Create(&model.CommunityMembership{CommunityID: c1.ID, NodeID: tn1.ID}) - deps.DB.Create(&model.CommunityMembership{CommunityID: c2.ID, NodeID: ln1.ID}) - deps.DB.Create(&model.CommunityMembership{CommunityID: c2.ID, NodeID: ln2.ID}) - deps.DB.Create(&model.CommunityMembership{CommunityID: c2.ID, NodeID: ln3.ID}) - - result := callTool(t, deps, "list_communities", map[string]any{"min_size": 3}) - if result.IsError { - t.Fatalf("list_communities error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - comms := resp["communities"].([]any) - if len(comms) != 1 { - t.Errorf("expected 1 community with min_size=3, got %d", len(comms)) - } -} - -func TestListCommunities_Empty(t *testing.T) { - deps := setupTestDeps(t) - - result := callTool(t, deps, "list_communities", map[string]any{}) - if result.IsError { - t.Fatalf("list_communities error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - comms := resp["communities"].([]any) - if len(comms) != 0 { - t.Errorf("expected 0 communities, got %d", len(comms)) - } - derived := resp["derived_state"].(map[string]any) - communityState := derived["communities"].(map[string]any) - if communityState["freshness"] != "unknown" { - t.Fatalf("expected communities freshness=unknown, got %v", communityState["freshness"]) - } -} - -func TestListCommunities_NamespaceScopesResults(t *testing.T) { - deps := setupTestDeps(t) - ctxAlpha := ctxns.WithNamespace(context.Background(), "alpha") - ctxBeta := ctxns.WithNamespace(context.Background(), "beta") - - alphaCommunity := model.Community{Namespace: "alpha", Key: "alpha/core", Label: "alpha/core", Strategy: "directory"} - betaCommunity := model.Community{Namespace: "beta", Key: "beta/core", Label: "beta/core", Strategy: "directory"} - deps.DB.Create(&alphaCommunity) - deps.DB.Create(&betaCommunity) - - if err := deps.Store.UpsertNodes(ctxAlpha, []model.Node{{QualifiedName: "alpha.Fn", Kind: model.NodeKindFunction, Name: "Fn", FilePath: "alpha.go", StartLine: 1, EndLine: 5, Language: "go"}}); err != nil { - t.Fatal(err) - } - if err := deps.Store.UpsertNodes(ctxBeta, []model.Node{{QualifiedName: "beta.Fn", Kind: model.NodeKindFunction, Name: "Fn", FilePath: "beta.go", StartLine: 1, EndLine: 5, Language: "go"}}); err != nil { - t.Fatal(err) - } - alphaNode, _ := deps.Store.GetNode(ctxAlpha, "alpha.Fn") - betaNode, _ := deps.Store.GetNode(ctxBeta, "beta.Fn") - deps.DB.Create(&model.CommunityMembership{CommunityID: alphaCommunity.ID, NodeID: alphaNode.ID}) - deps.DB.Create(&model.CommunityMembership{CommunityID: betaCommunity.ID, NodeID: betaNode.ID}) - - result := callTool(t, deps, "list_communities", map[string]any{"namespace": "alpha"}) - if result.IsError { - t.Fatalf("list_communities error: %s", getTextContent(result)) - } - - text := getTextContent(result) - if strings.Contains(text, "beta/core") { - t.Fatalf("unexpected beta community leak: %s", text) - } - if !strings.Contains(text, "alpha/core") { - t.Fatalf("expected alpha community in scoped result: %s", text) - } -} - // ============================================================ -// 11.10 get_community // ============================================================ -func TestGetCommunity_Basic(t *testing.T) { - deps := setupTestDeps(t) - ctx := context.Background() - - c := model.Community{Key: "core", Label: "core", Strategy: "directory"} - deps.DB.Create(&c) - - deps.Store.UpsertNodes(ctx, []model.Node{ - {QualifiedName: "core.Fn", Kind: model.NodeKindFunction, Name: "Fn", FilePath: "core.go", StartLine: 1, EndLine: 5, Language: "go"}, - }) - node, _ := deps.Store.GetNode(ctx, "core.Fn") - deps.DB.Create(&model.CommunityMembership{CommunityID: c.ID, NodeID: node.ID}) - - result := callTool(t, deps, "get_community", map[string]any{"community_id": c.ID}) - if result.IsError { - t.Fatalf("get_community error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - if err := json.Unmarshal([]byte(text), &resp); err != nil { - t.Fatal(err) - } - if resp["label"] != "core" { - t.Errorf("expected label=core, got %v", resp["label"]) - } - if resp["node_count"].(float64) != 1 { - t.Errorf("expected node_count=1, got %v", resp["node_count"]) - } - if _, ok := resp["members"]; ok { - t.Fatalf("expected members to be omitted, got %v", resp["members"]) - } - if _, ok := resp["members_pagination"]; ok { - t.Fatalf("expected members_pagination to be omitted, got %v", resp["members_pagination"]) - } - if _, ok := resp["coverage"]; ok { - t.Fatalf("expected coverage to be omitted, got %v", resp["coverage"]) - } -} - -func TestGetCommunity_WithMembers(t *testing.T) { - deps := setupTestDeps(t) - ctx := context.Background() - - c := model.Community{Key: "api", Label: "api", Strategy: "directory"} - deps.DB.Create(&c) - - deps.Store.UpsertNodes(ctx, []model.Node{ - {QualifiedName: "api.Handler", Kind: model.NodeKindFunction, Name: "Handler", FilePath: "api.go", StartLine: 1, EndLine: 10, Language: "go"}, - }) - node, _ := deps.Store.GetNode(ctx, "api.Handler") - deps.DB.Create(&model.CommunityMembership{CommunityID: c.ID, NodeID: node.ID}) - - result := callTool(t, deps, "get_community", map[string]any{ - "community_id": c.ID, - "include_members": true, - }) - if result.IsError { - t.Fatalf("get_community error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - if err := json.Unmarshal([]byte(text), &resp); err != nil { - t.Fatal(err) - } - members, ok := resp["members"].([]any) - if !ok { - t.Fatalf("expected members array, got %T", resp["members"]) - } - if len(members) != 1 { - t.Errorf("expected 1 member, got %d", len(members)) - } - if _, ok := resp["members_pagination"]; !ok { - t.Fatal("expected members_pagination to be present") - } -} - -func TestGetCommunity_WithCoverage(t *testing.T) { - deps := setupTestDeps(t) - ctx := context.Background() - - mockCov := &mockCoverageAnalyzer{ - communityResult: &coverage.CommunityCoverage{ - CommunityID: 1, Label: "core", Total: 10, Tested: 7, Ratio: 0.7, - }, - } - deps.CoverageAnalyzer = mockCov - - c := model.Community{Key: "core2", Label: "core2", Strategy: "directory"} - deps.DB.Create(&c) - deps.Store.UpsertNodes(ctx, []model.Node{ - {QualifiedName: "core2.Fn", Kind: model.NodeKindFunction, Name: "Fn", FilePath: "core2.go", StartLine: 1, EndLine: 5, Language: "go"}, - }) - node, _ := deps.Store.GetNode(ctx, "core2.Fn") - deps.DB.Create(&model.CommunityMembership{CommunityID: c.ID, NodeID: node.ID}) - - result := callTool(t, deps, "get_community", map[string]any{"community_id": c.ID}) - if result.IsError { - t.Fatalf("get_community error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - if err := json.Unmarshal([]byte(text), &resp); err != nil { - t.Fatal(err) - } - coverage, ok := resp["coverage"].(float64) - if !ok { - t.Fatalf("expected coverage float, got %T", resp["coverage"]) - } - if coverage != 0.7 { - t.Errorf("expected coverage=0.7, got %v", resp["coverage"]) - } -} - -func TestGetCommunity_NotFound(t *testing.T) { - deps := setupTestDeps(t) - - result := callTool(t, deps, "get_community", map[string]any{"community_id": 999}) - if !result.IsError { - t.Fatal("expected error for nonexistent community") - } -} - -func TestGetCommunity_NamespaceRejectsForeignCommunity(t *testing.T) { - deps := setupTestDeps(t) - community := model.Community{Namespace: "beta", Key: "beta/core", Label: "beta/core", Strategy: "directory"} - deps.DB.Create(&community) - - result := callTool(t, deps, "get_community", map[string]any{"community_id": community.ID, "namespace": "alpha"}) - if !result.IsError { - t.Fatalf("expected scoped lookup to reject foreign community: %s", getTextContent(result)) - } -} - // ============================================================ -// 11.11 get_architecture_overview +// 11.12 find_suspect_fallback_edges // ============================================================ -func TestArchitectureOverview_ReturnsCommunities2(t *testing.T) { - deps := setupTestDeps(t) - - deps.DB.Create(&model.Community{Key: "mod_a", Label: "mod_a", Strategy: "directory"}) - deps.DB.Create(&model.Community{Key: "mod_b", Label: "mod_b", Strategy: "directory"}) - - result := callTool(t, deps, "get_architecture_overview", map[string]any{}) - if result.IsError { - t.Fatalf("get_architecture_overview error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - comms := resp["communities"].([]any) - if len(comms) != 2 { - t.Errorf("expected 2 communities, got %d", len(comms)) - } -} - -func TestArchitectureOverview_ReturnsCoupling2(t *testing.T) { - deps := setupTestDeps(t) - - mockCoup := &mockCouplingAnalyzer{ - result: []coupling.CouplingPair{ - {FromCommunity: "a", ToCommunity: "b", EdgeCount: 5, Strength: 0.5}, - }, - } - deps.CouplingAnalyzer = mockCoup - - deps.DB.Create(&model.Community{Key: "a", Label: "a", Strategy: "directory"}) - - result := callTool(t, deps, "get_architecture_overview", map[string]any{}) - if result.IsError { - t.Fatalf("get_architecture_overview error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - cp := resp["coupling"].([]any) - if len(cp) != 1 { - t.Errorf("expected 1 coupling pair, got %d", len(cp)) - } -} - -func TestArchitectureOverview_Warnings(t *testing.T) { - deps := setupTestDeps(t) - - mockCoup := &mockCouplingAnalyzer{ - result: []coupling.CouplingPair{ - {FromCommunity: "x", ToCommunity: "y", EdgeCount: 100, Strength: 0.95}, - }, - } - deps.CouplingAnalyzer = mockCoup - - deps.DB.Create(&model.Community{Key: "x", Label: "x", Strategy: "directory"}) - - result := callTool(t, deps, "get_architecture_overview", map[string]any{}) - if result.IsError { - t.Fatalf("get_architecture_overview error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - warnings := resp["warnings"].([]any) - if len(warnings) == 0 { - t.Error("expected warnings for high coupling") - } -} - -func TestArchitectureOverview_Empty2(t *testing.T) { - deps := setupTestDeps(t) - - result := callTool(t, deps, "get_architecture_overview", map[string]any{}) - if result.IsError { - t.Fatalf("get_architecture_overview error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - warnings := resp["warnings"].([]any) - if len(warnings) == 0 { - t.Error("expected warning message when no communities") - } -} - -// ============================================================ -// 11.12 find_dead_code -// ============================================================ - -func TestFindDeadCode_ReturnsUnusedFunctions(t *testing.T) { - deps := setupTestDeps(t) - - mockDC := &mockDeadcodeAnalyzer{ - result: []model.Node{ - {QualifiedName: "pkg.Unused", Kind: model.NodeKindFunction, Name: "Unused", FilePath: "unused.go", StartLine: 1}, - }, - } - deps.DeadcodeAnalyzer = mockDC - - result := callTool(t, deps, "find_dead_code", map[string]any{}) - if result.IsError { - t.Fatalf("find_dead_code error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - if resp["count"].(float64) != 1 { - t.Errorf("expected 1 dead code, got %v", resp["count"]) - } -} - -func TestFindDeadCode_FilterByKind(t *testing.T) { - deps := setupTestDeps(t) - - mockDC := &mockDeadcodeAnalyzer{result: []model.Node{}} - deps.DeadcodeAnalyzer = mockDC - - callTool(t, deps, "find_dead_code", map[string]any{ - "kinds": []any{"function"}, - }) - - if !mockDC.findPageCalled { - t.Error("expected FindPage to be called") - } -} - -func TestFindDeadCode_FilterByFilePattern(t *testing.T) { - deps := setupTestDeps(t) - - mockDC := &mockDeadcodeAnalyzer{result: []model.Node{}} - deps.DeadcodeAnalyzer = mockDC - - callTool(t, deps, "find_dead_code", map[string]any{ - "path": "internal/", - }) - - if !mockDC.findPageCalled { - t.Error("expected FindPage to be called") - } -} - -func TestFindDeadCode_NoDeadCode(t *testing.T) { - deps := setupTestDeps(t) - - mockDC := &mockDeadcodeAnalyzer{result: []model.Node{}} - deps.DeadcodeAnalyzer = mockDC - - result := callTool(t, deps, "find_dead_code", map[string]any{}) - if result.IsError { - t.Fatalf("find_dead_code error: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - json.Unmarshal([]byte(text), &resp) - if resp["count"].(float64) != 0 { - t.Errorf("expected 0 dead code, got %v", resp["count"]) - } -} - func TestFindSuspectFallbackEdges_ReturnsSuspects(t *testing.T) { deps := setupTestDeps(t) deps.FallbackAnalyzer = &mockFallbackAnalyzer{ @@ -3706,46 +2590,6 @@ func (f *failSearchBackend) Query(ctx context.Context, db *gorm.DB, query string return nil, nil } -func TestRunPostprocess_DegradedOnCommunityFailure(t *testing.T) { - deps := setupTestDeps(t) - deps.CommunityBuilder = &mockCommunityBuilder{ - err: errors.New("community rebuild boom"), - } - - result := callTool(t, deps, "run_postprocess", map[string]any{ - "communities": true, - "fts": false, - "flows": false, - }) - if result.IsError { - t.Fatalf("run_postprocess should not return tool error, got: %s", getTextContent(result)) - } - - text := getTextContent(result) - var resp map[string]any - if err := json.Unmarshal([]byte(text), &resp); err != nil { - t.Fatalf("expected JSON, got: %s", text) - } - - if resp["status"] != "degraded" { - t.Errorf("expected status=degraded, got %v", resp["status"]) - } - - failedSteps, ok := resp["failed_steps"].([]any) - if !ok || len(failedSteps) == 0 { - t.Fatalf("expected failed_steps to be non-empty, got %v", resp["failed_steps"]) - } - found := false - for _, s := range failedSteps { - if s == "communities" { - found = true - } - } - if !found { - t.Errorf("expected 'communities' in failed_steps, got %v", failedSteps) - } -} - func TestRunPostprocess_DegradedOnSearchFailure(t *testing.T) { deps := setupTestDeps(t) deps.SearchBackend = &failSearchBackend{err: errors.New("fts rebuild boom")} @@ -3897,12 +2741,10 @@ func MyService() {} if err := json.Unmarshal([]byte(getTextContent(result)), &resp); err != nil { t.Fatalf("expected JSON, got: %s", getTextContent(result)) } - skipped, ok := resp["skipped_steps"].([]any) - if !ok { - t.Fatalf("expected skipped_steps array, got %v", resp["skipped_steps"]) - } + // skipped_steps may be null when nothing is skipped; the assertion is only that flows is not skipped. + skipped, _ := resp["skipped_steps"].([]any) if containsString(skipped, "flows") { - t.Fatalf("expected build_or_update_graph postprocess=full not to report flows as skipped when builder is configured, got %v", resp["skipped_steps"]) + t.Fatalf("expected postprocess=full not to report flows as skipped when the builder is configured, got %v", resp["skipped_steps"]) } var docs []model.SearchDocument diff --git a/internal/mcp/prompts.go b/internal/mcp/prompts.go index f4b509c..ab7dcd6 100644 --- a/internal/mcp/prompts.go +++ b/internal/mcp/prompts.go @@ -11,10 +11,6 @@ import ( "github.com/tae2089/trace" "github.com/tae2089/code-context-graph/internal/analysis/changes" - "github.com/tae2089/code-context-graph/internal/analysis/coupling" - "github.com/tae2089/code-context-graph/internal/analysis/coverage" - "github.com/tae2089/code-context-graph/internal/analysis/deadcode" - "github.com/tae2089/code-context-graph/internal/analysis/largefunc" "github.com/tae2089/code-context-graph/internal/analysis/query" "github.com/tae2089/code-context-graph/internal/ctxns" "github.com/tae2089/code-context-graph/internal/model" @@ -66,42 +62,12 @@ func appendPromptItems[T any](sb *strings.Builder, items []T, render func(T), tr } } -// @intent preserve risk ordering while collapsing repeated files into a stable coverage worklist. -func uniqueRiskFiles(risks []changes.RiskEntry) []string { - filesSeen := make(map[string]bool, len(risks)) - files := make([]string, 0, len(risks)) - for _, r := range risks { - if filesSeen[r.Node.FilePath] { - continue - } - filesSeen[r.Node.FilePath] = true - files = append(files, r.Node.FilePath) - } - return files -} - // promptHandlers groups dependencies for MCP prompt generation. // @intent Groups dependencies so prompt handlers can reuse the shared database and analyzers. type promptHandlers struct { deps *Deps } -// @intent resolve the coverage analyzer dependency with a DB-backed default. -func (p *promptHandlers) coverageAnalyzer() CoverageAnalyzer { - if p.deps.CoverageAnalyzer != nil { - return p.deps.CoverageAnalyzer - } - return coverage.New(p.deps.DB) -} - -// @intent resolve the coupling analyzer dependency with a DB-backed default. -func (p *promptHandlers) couplingAnalyzer() CouplingAnalyzer { - if p.deps.CouplingAnalyzer != nil { - return p.deps.CouplingAnalyzer - } - return coupling.New(p.deps.DB) -} - // @intent resolve the query service dependency with a DB-backed default. func (p *promptHandlers) queryService() QueryService { if p.deps.QueryService != nil { @@ -110,22 +76,6 @@ func (p *promptHandlers) queryService() QueryService { return query.New(p.deps.DB) } -// @intent resolve the large-function analyzer dependency with a DB-backed default. -func (p *promptHandlers) largefuncAnalyzer() LargefuncAnalyzer { - if p.deps.LargefuncAnalyzer != nil { - return p.deps.LargefuncAnalyzer - } - return largefunc.New(p.deps.DB) -} - -// @intent resolve the dead-code analyzer dependency with a DB-backed default. -func (p *promptHandlers) deadcodeAnalyzer() DeadcodeAnalyzer { - if p.deps.DeadcodeAnalyzer != nil { - return p.deps.DeadcodeAnalyzer - } - return deadcode.New(p.deps.DB) -} - // promptResult wraps plain text in the MCP prompt result shape. // @intent Enables prompt handlers to generate consistent user message responses from plain strings. // @param text The user message to be returned as the prompt body. @@ -160,18 +110,17 @@ func promptNamespaceRoot(deps *Deps) string { return "namespaces" } -// reviewChanges builds a prompt summarizing change risk and coverage gaps. -// @intent Provides a single view of high-risk functions and test gaps before reviewing changes. +// reviewChanges builds a prompt summarizing change risk for the diff range. +// @intent Provides a single view of high-risk functions before reviewing changes. // @param request Defines the Git comparison range using repo_root and base arguments. // @requires ChangesGitClient must be configured to perform actual change analysis. -// @ensures Returns a prompt including risk analysis and coverage summary on success. -// @sideEffect Performs Git diff lookups and database-based coverage queries. +// @ensures Returns a prompt including risk analysis on success. +// @sideEffect Performs Git diff lookups and database-based risk queries. // @see mcp.promptHandlers.preMergeCheck func (p *promptHandlers) reviewChanges(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { args := request.Params.Arguments ctx = ctxns.WithNamespace(ctx, resolvePromptNamespace(ctx, args)) riskLimit := promptLimitArg(args, promptHardCap, promptHardCap) - coverageLimit := promptLimitArg(args, promptSectionCap, promptSectionCap) repoRoot := args["repo_root"] base := args["base"] if base == "" { @@ -208,81 +157,6 @@ func (p *promptHandlers) reviewChanges(ctx context.Context, request mcp.GetPromp r.RiskScore, r.HunkCount)) }, risksPage.Pagination.HasMore) - sb.WriteString("\n## 테스트 커버리지 갭\n\n") - - covAnalyzer := p.coverageAnalyzer() - files := uniqueRiskFiles(risks) - coverageFiles := files - coverageTruncated := len(files) > coverageLimit - if coverageTruncated { - coverageFiles = files[:coverageLimit] - } - appendPromptItems(&sb, coverageFiles, func(filePath string) { - fc, err := covAnalyzer.ByFile(ctx, filePath) - if err != nil { - return - } - sb.WriteString(fmt.Sprintf("- %s: 테스트 %d/%d (%.0f%%)\n", - fc.FilePath, fc.Tested, fc.Total, fc.Ratio*100)) - }, coverageTruncated) - - return promptResult(sb.String()), nil -} - -// architectureMap builds a prompt summarizing communities and coupling. -// @intent Aids in understanding module structure by providing a full architectural overview via a natural language prompt. -// @ensures Returns a prompt containing a list of communities and inter-module coupling on success. -// @sideEffect Queries community and coupling information from the database. -func (p *promptHandlers) architectureMap(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - args := request.Params.Arguments - ctx = ctxns.WithNamespace(ctx, resolvePromptNamespace(ctx, args)) - sectionLimit := promptLimitArg(args, promptSectionCap, promptSectionCap) - ns := ctxns.FromContext(ctx) - var communities []model.Community - query := p.deps.DB.WithContext(ctx).Where("namespace = ?", ns).Order("id ASC").Limit(sectionLimit + 1) - if err := query.Find(&communities).Error; err != nil { - return nil, trace.Wrap(err, "query communities") - } - - if len(communities) == 0 { - return promptResult("커뮤니티가 없습니다. 먼저 `community rebuild` 명령으로 커뮤니티를 생성하세요."), nil - } - - var sb strings.Builder - sb.WriteString("## 아키텍처 맵\n\n### 커뮤니티 목록\n\n") - - log := p.deps.Logger - communityTruncated := len(communities) > sectionLimit - if communityTruncated { - communities = communities[:sectionLimit] - } - appendPromptItems(&sb, communities, func(c model.Community) { - var memberCount int64 - memberQ := p.deps.DB.WithContext(ctx).Model(&model.CommunityMembership{}). - Joins("JOIN communities ON communities.id = community_memberships.community_id"). - Where("community_id = ?", c.ID). - Where("communities.namespace = ?", ns) - if err := memberQ.Count(&memberCount).Error; err != nil { - log.Warn("count community members failed", "community", c.ID, trace.SlogError(err)) - } - sb.WriteString(fmt.Sprintf("- **%s** (전략: %s, 멤버: %d)\n", c.Label, c.Strategy, memberCount)) - }, communityTruncated) - - coupAnalyzer := p.couplingAnalyzer() - pairsPage, err := coupAnalyzer.AnalyzePage(ctx, promptPageRequest(sectionLimit)) - if err != nil { - return nil, trace.Wrap(err, "coupling analyze") - } - pairs := pairsPage.Items - - if len(pairs) > 0 { - sb.WriteString("\n### 모듈 간 결합도\n\n") - appendPromptItems(&sb, pairs, func(cp coupling.CouplingPair) { - sb.WriteString(fmt.Sprintf("- %s → %s: 결합도 %.2f (%d edges)\n", - cp.FromCommunity, cp.ToCommunity, cp.Strength, cp.EdgeCount)) - }, pairsPage.Pagination.HasMore) - } - return promptResult(sb.String()), nil } @@ -443,40 +317,6 @@ func (p *promptHandlers) onboardDeveloper(ctx context.Context, request mcp.GetPr appendPromptTruncation(&sb, len(langs)) } - var communities []model.Community - commQ := p.deps.DB.WithContext(ctx).Where("namespace = ?", ns).Order("id ASC").Limit(sectionLimit + 1) - if err := commQ.Find(&communities).Error; err != nil { - log.Warn("find communities failed", trace.SlogError(err)) - } - communitiesTruncated := len(communities) > sectionLimit - if communitiesTruncated { - communities = communities[:sectionLimit] - } - if len(communities) > 0 { - sb.WriteString("\n### 커뮤니티 구조\n\n") - for _, c := range communities { - sb.WriteString(fmt.Sprintf("- %s (%s)\n", c.Label, c.Strategy)) - } - if communitiesTruncated { - appendPromptTruncation(&sb, len(communities)) - } - } - - lfAnalyzer := p.largefuncAnalyzer() - largeFuncsPage, err := lfAnalyzer.FindPage(ctx, largefunc.Options{Threshold: 50, Page: promptPageRequest(sectionLimit)}) - if err != nil { - log.Warn("find large functions failed", trace.SlogError(err)) - } - largeFuncs := largeFuncsPage.Items - if len(largeFuncs) > 0 { - sb.WriteString("\n### 대형 함수 (50줄 초과)\n\n") - appendPromptItems(&sb, largeFuncs, func(f model.Node) { - lines := f.EndLine - f.StartLine + 1 - sb.WriteString(fmt.Sprintf("- %s (%s:%d-%d, %d줄)\n", - f.QualifiedName, f.FilePath, f.StartLine, f.EndLine, lines)) - }, largeFuncsPage.Pagination.HasMore) - } - return promptResult(sb.String()), nil } @@ -488,11 +328,9 @@ func (p *promptHandlers) onboardDeveloper(ctx context.Context, request mcp.GetPr // @sideEffect Performs Git diff lookups and multiple database-based analysis queries. // @see mcp.promptHandlers.reviewChanges func (p *promptHandlers) preMergeCheck(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - log := p.deps.Logger args := request.Params.Arguments ctx = ctxns.WithNamespace(ctx, resolvePromptNamespace(ctx, args)) riskLimit := promptLimitArg(args, promptHardCap, promptHardCap) - sectionLimit := promptLimitArg(args, promptSectionCap, promptSectionCap) repoRoot := args["repo_root"] base := args["base"] if base == "" { @@ -528,59 +366,5 @@ func (p *promptHandlers) preMergeCheck(ctx context.Context, request mcp.GetPromp }, risksPage.Pagination.HasMore) } - sb.WriteString("\n### 커버리지\n\n") - covAnalyzer2 := p.coverageAnalyzer() - files := uniqueRiskFiles(risks) - coverageFiles := files - coverageTruncated := len(files) > sectionLimit - if coverageTruncated { - coverageFiles = files[:sectionLimit] - } - appendPromptItems(&sb, coverageFiles, func(filePath string) { - fc, err := covAnalyzer2.ByFile(ctx, filePath) - if err != nil { - return - } - sb.WriteString(fmt.Sprintf("- %s: %d/%d (%.0f%%)\n", - fc.FilePath, fc.Tested, fc.Total, fc.Ratio*100)) - }, false) - if len(risks) == 0 { - sb.WriteString("변경사항이 없습니다.\n") - } else if coverageTruncated { - appendPromptTruncation(&sb, len(coverageFiles)) - } - - sb.WriteString("\n### 미사용 코드\n\n") - dcAnalyzer := p.deadcodeAnalyzer() - deadPage, err := dcAnalyzer.FindPage(ctx, deadcode.Options{Page: promptPageRequest(sectionLimit)}) - if err != nil { - log.Warn("find dead code failed", trace.SlogError(err)) - } - deadNodes := deadPage.Items - if len(deadNodes) > 0 { - appendPromptItems(&sb, deadNodes, func(n model.Node) { - sb.WriteString(fmt.Sprintf("- 미사용: %s (%s)\n", n.QualifiedName, n.FilePath)) - }, deadPage.Pagination.HasMore) - } else { - sb.WriteString("미사용 코드 없음\n") - } - - sb.WriteString("\n### 대형 함수\n\n") - lfAnalyzer2 := p.largefuncAnalyzer() - var largeFuncs []model.Node - largeFuncsPage, err := lfAnalyzer2.FindPage(ctx, largefunc.Options{Threshold: 50, Page: promptPageRequest(sectionLimit)}) - if err != nil { - log.Warn("find large functions failed", trace.SlogError(err)) - } - largeFuncs = largeFuncsPage.Items - if len(largeFuncs) > 0 { - appendPromptItems(&sb, largeFuncs, func(f model.Node) { - lines := f.EndLine - f.StartLine + 1 - sb.WriteString(fmt.Sprintf("- %s (%d줄)\n", f.QualifiedName, lines)) - }, largeFuncsPage.Pagination.HasMore) - } else { - sb.WriteString("대형 함수 없음\n") - } - return promptResult(sb.String()), nil } diff --git a/internal/mcp/prompts_register.go b/internal/mcp/prompts_register.go index f954ea2..d791255 100644 --- a/internal/mcp/prompts_register.go +++ b/internal/mcp/prompts_register.go @@ -22,14 +22,6 @@ func registerPrompts(srv *server.MCPServer, p *promptHandlers) { ), Handler: p.reviewChanges, }, - server.ServerPrompt{ - Prompt: mcp.NewPrompt("architecture_map", - mcp.WithPromptDescription("아키텍처 맵: 커뮤니티 구조 및 모듈 간 결합도 분석"), - mcp.WithArgument("limit", mcp.ArgumentDescription("표시할 최대 항목 수 (hard cap 적용)")), - mcp.WithArgument("namespace", mcp.ArgumentDescription("조회할 namespace (선택)")), - ), - Handler: p.architectureMap, - }, server.ServerPrompt{ Prompt: mcp.NewPrompt("debug_issue", mcp.WithPromptDescription("이슈 디버깅: 관련 코드 검색 및 호출 그래프 분석"), diff --git a/internal/mcp/prompts_test.go b/internal/mcp/prompts_test.go index 1c08359..7d564e1 100644 --- a/internal/mcp/prompts_test.go +++ b/internal/mcp/prompts_test.go @@ -17,9 +17,6 @@ import ( gormlogger "gorm.io/gorm/logger" "github.com/tae2089/code-context-graph/internal/analysis/changes" - "github.com/tae2089/code-context-graph/internal/analysis/coupling" - "github.com/tae2089/code-context-graph/internal/analysis/coverage" - "github.com/tae2089/code-context-graph/internal/ctxns" "github.com/tae2089/code-context-graph/internal/model" "github.com/tae2089/code-context-graph/internal/store/gormstore" "github.com/tae2089/code-context-graph/internal/store/search" @@ -155,7 +152,6 @@ func TestNewServer_WithPromptCapabilities(t *testing.T) { expectedPrompts := []string{ "review_changes", - "architecture_map", "debug_issue", "onboard_developer", "pre_merge_check", @@ -215,30 +211,6 @@ func TestReviewChanges_ReturnsRiskEntries(t *testing.T) { _ = ctx } -func TestReviewChanges_IncludesTestGaps(t *testing.T) { - deps, db := setupPromptTestDeps(t) - - // Create function without test coverage - db.Create(&model.Node{QualifiedName: "pkg.Untested", Kind: model.NodeKindFunction, Name: "Untested", FilePath: "untested.go", StartLine: 1, EndLine: 10, Language: "go"}) - - deps.ChangesGitClient = &mockGitClient{ - changedFiles: []string{"untested.go"}, - hunks: []changes.Hunk{{FilePath: "untested.go", StartLine: 1, EndLine: 10}}, - } - repoRoot := t.TempDir() - deps.RepoRoot = repoRoot - - resultJSON := callPrompt(t, deps, "review_changes", map[string]string{ - "repo_root": repoRoot, - "base": "HEAD~1", - }) - text := getPromptText(t, resultJSON) - - if !strings.Contains(text, "테스트") || !strings.Contains(text, "0") { - t.Error("expected test gap information in response") - } -} - func TestReviewChanges_EmptyChanges(t *testing.T) { deps, _ := setupPromptTestDeps(t) @@ -282,40 +254,6 @@ func TestReviewChanges_DefaultBase(t *testing.T) { } } -func TestReviewChanges_TruncatesRiskAndCoverageSections(t *testing.T) { - deps, db := setupPromptTestDeps(t) - - changedFiles := make([]string, 0, 22) - hunks := make([]changes.Hunk, 0, 22) - for i := 1; i <= 22; i++ { - filePath := fmt.Sprintf("risk%02d.go", i) - qualifiedName := fmt.Sprintf("pkg.Risk%02d", i) - db.Create(&model.Node{QualifiedName: qualifiedName, Kind: model.NodeKindFunction, Name: fmt.Sprintf("Risk%02d", i), FilePath: filePath, StartLine: 1, EndLine: 20, Language: "go"}) - changedFiles = append(changedFiles, filePath) - hunks = append(hunks, changes.Hunk{FilePath: filePath, StartLine: 1, EndLine: 20}) - } - - deps.ChangesGitClient = &mockGitClient{changedFiles: changedFiles, hunks: hunks} - deps.CoverageAnalyzer = &perFileCoverageAnalyzer{} - repoRoot := t.TempDir() - deps.RepoRoot = repoRoot - - text := getPromptText(t, callPrompt(t, deps, "review_changes", map[string]string{ - "repo_root": repoRoot, - "limit": "25", - })) - - if got := countOccurrences(text, "— 리스크 점수:"); got != 20 { - t.Fatalf("expected 20 risk entries, got %d\n%s", got, text) - } - if got := countOccurrences(text, ": 테스트 1/2 (50%)"); got != 10 { - t.Fatalf("expected 10 coverage entries, got %d\n%s", got, text) - } - if !strings.Contains(text, "표시: 20건") || !strings.Contains(text, "표시: 10건") { - t.Fatalf("expected truncation markers for risk and coverage sections: %s", text) - } -} - func TestReviewChanges_RespectsSmallerLimitArgument(t *testing.T) { deps, db := setupPromptTestDeps(t) @@ -329,7 +267,6 @@ func TestReviewChanges_RespectsSmallerLimitArgument(t *testing.T) { } deps.ChangesGitClient = &mockGitClient{changedFiles: changedFiles, hunks: hunks} - deps.CoverageAnalyzer = &perFileCoverageAnalyzer{} repoRoot := t.TempDir() deps.RepoRoot = repoRoot @@ -341,132 +278,6 @@ func TestReviewChanges_RespectsSmallerLimitArgument(t *testing.T) { if got := countOccurrences(text, "— 리스크 점수:"); got != 3 { t.Fatalf("expected 3 risk entries, got %d\n%s", got, text) } - if got := countOccurrences(text, ": 테스트 1/2 (50%)"); got != 3 { - t.Fatalf("expected 3 coverage entries, got %d\n%s", got, text) - } -} - -// ============================================================ -// 10.2 architecture_map tests -// ============================================================ - -func TestArchitectureMap_ReturnsCommunities(t *testing.T) { - deps, db := setupPromptTestDeps(t) - - db.Create(&model.Community{Key: "internal/api", Label: "internal/api", Strategy: "directory"}) - db.Create(&model.Community{Key: "internal/store", Label: "internal/store", Strategy: "directory"}) - - resultJSON := callPrompt(t, deps, "architecture_map", nil) - text := getPromptText(t, resultJSON) - - if !strings.Contains(text, "internal/api") { - t.Error("expected internal/api community in output") - } - if !strings.Contains(text, "internal/store") { - t.Error("expected internal/store community in output") - } -} - -func TestArchitectureMap_IncludesCoupling(t *testing.T) { - deps, db := setupPromptTestDeps(t) - - // Create 2 communities with nodes and cross-community edges - c1 := model.Community{Key: "mod_a", Label: "mod_a", Strategy: "directory"} - c2 := model.Community{Key: "mod_b", Label: "mod_b", Strategy: "directory"} - db.Create(&c1) - db.Create(&c2) - - n1 := model.Node{QualifiedName: "mod_a.F1", Kind: model.NodeKindFunction, Name: "F1", FilePath: "mod_a/f1.go", StartLine: 1, EndLine: 5, Language: "go"} - n2 := model.Node{QualifiedName: "mod_b.F2", Kind: model.NodeKindFunction, Name: "F2", FilePath: "mod_b/f2.go", StartLine: 1, EndLine: 5, Language: "go"} - db.Create(&n1) - db.Create(&n2) - - db.Create(&model.CommunityMembership{CommunityID: c1.ID, NodeID: n1.ID}) - db.Create(&model.CommunityMembership{CommunityID: c2.ID, NodeID: n2.ID}) - - db.Create(&model.Edge{FromNodeID: n1.ID, ToNodeID: n2.ID, Kind: model.EdgeKindCalls, Fingerprint: "calls-f1-f2"}) - - resultJSON := callPrompt(t, deps, "architecture_map", nil) - text := getPromptText(t, resultJSON) - - if !strings.Contains(text, "결합도") || !strings.Contains(text, "mod_a") { - t.Errorf("expected coupling info, got: %s", text) - } -} - -func TestArchitectureMap_NoCommunities(t *testing.T) { - deps, _ := setupPromptTestDeps(t) - - resultJSON := callPrompt(t, deps, "architecture_map", nil) - text := getPromptText(t, resultJSON) - - if !strings.Contains(text, "커뮤니티가 없습니다") { - t.Errorf("expected no communities message, got: %s", text) - } -} - -func TestArchitectureMap_RespectsNamespaceArgument(t *testing.T) { - deps, db := setupPromptTestDeps(t) - db.Create(&model.Community{Namespace: "alpha", Key: "alpha/core", Label: "alpha/core", Strategy: "directory"}) - db.Create(&model.Community{Namespace: "beta", Key: "beta/core", Label: "beta/core", Strategy: "directory"}) - - resultJSON := callPrompt(t, deps, "architecture_map", map[string]string{"namespace": "alpha"}) - text := getPromptText(t, resultJSON) - - if !strings.Contains(text, "alpha/core") { - t.Fatalf("expected alpha community in output: %s", text) - } - if strings.Contains(text, "beta/core") { - t.Fatalf("unexpected beta community leak: %s", text) - } -} - -func TestArchitectureMap_FallsBackToContextNamespace(t *testing.T) { - deps, db := setupPromptTestDeps(t) - db.Create(&model.Community{Namespace: "alpha", Key: "alpha/core", Label: "alpha/core", Strategy: "directory"}) - db.Create(&model.Community{Namespace: "beta", Key: "beta/core", Label: "beta/core", Strategy: "directory"}) - - srv := NewServer(deps) - ctx := ctxns.WithNamespace(context.Background(), "beta") - resultJSON := callPromptWithContext(t, srv, ctx, "architecture_map", nil) - text := getPromptText(t, resultJSON) - - if !strings.Contains(text, "beta/core") { - t.Fatalf("expected beta community in output: %s", text) - } - if strings.Contains(text, "alpha/core") { - t.Fatalf("unexpected alpha community leak: %s", text) - } -} - -func TestArchitectureMap_TruncatesCommunityAndCouplingSections(t *testing.T) { - deps, db := setupPromptTestDeps(t) - deps.CouplingAnalyzer = &mockCouplingAnalyzer{} - - for i := 1; i <= 12; i++ { - db.Create(&model.Community{Key: fmt.Sprintf("community%02d", i), Label: fmt.Sprintf("community%02d", i), Strategy: "directory"}) - } - deps.CouplingAnalyzer.(*mockCouplingAnalyzer).result = make([]coupling.CouplingPair, 0, 12) - for i := 1; i <= 12; i++ { - deps.CouplingAnalyzer.(*mockCouplingAnalyzer).result = append(deps.CouplingAnalyzer.(*mockCouplingAnalyzer).result, coupling.CouplingPair{ - FromCommunity: fmt.Sprintf("from%02d", i), - ToCommunity: fmt.Sprintf("to%02d", i), - EdgeCount: int64(i), - Strength: 1, - }) - } - - text := getPromptText(t, callPrompt(t, deps, "architecture_map", map[string]string{"limit": "25"})) - - if got := countOccurrences(text, "(전략:"); got != 10 { - t.Fatalf("expected 10 communities, got %d\n%s", got, text) - } - if got := countOccurrences(text, ": 결합도 "); got != 10 { - t.Fatalf("expected 10 coupling pairs, got %d\n%s", got, text) - } - if got := countOccurrences(text, "표시: 10건"); got < 2 { - t.Fatalf("expected truncation markers for both sections, got %d\n%s", got, text) - } } // ============================================================ @@ -585,36 +396,6 @@ func TestOnboardDeveloper_ReturnsStats(t *testing.T) { } } -func TestOnboardDeveloper_IncludesCommunities(t *testing.T) { - deps, db := setupPromptTestDeps(t) - - c := model.Community{Key: "core", Label: "core", Strategy: "directory"} - db.Create(&c) - n := model.Node{QualifiedName: "core.Main", Kind: model.NodeKindFunction, Name: "Main", FilePath: "core/main.go", StartLine: 1, EndLine: 5, Language: "go"} - db.Create(&n) - db.Create(&model.CommunityMembership{CommunityID: c.ID, NodeID: n.ID}) - - resultJSON := callPrompt(t, deps, "onboard_developer", nil) - text := getPromptText(t, resultJSON) - - if !strings.Contains(text, "core") { - t.Error("expected community label in output") - } -} - -func TestOnboardDeveloper_IncludesLargeFunctions(t *testing.T) { - deps, db := setupPromptTestDeps(t) - - db.Create(&model.Node{QualifiedName: "pkg.BigFunc", Kind: model.NodeKindFunction, Name: "BigFunc", FilePath: "big.go", StartLine: 1, EndLine: 100, Language: "go"}) - - resultJSON := callPrompt(t, deps, "onboard_developer", nil) - text := getPromptText(t, resultJSON) - - if !strings.Contains(text, "BigFunc") { - t.Error("expected BigFunc in large functions section") - } -} - func TestOnboardDeveloper_EmptyProject(t *testing.T) { deps, _ := setupPromptTestDeps(t) @@ -626,17 +407,12 @@ func TestOnboardDeveloper_EmptyProject(t *testing.T) { } } -func TestOnboardDeveloper_TruncatesSections(t *testing.T) { +func TestOnboardDeveloper_TruncatesLanguageSection(t *testing.T) { deps, db := setupPromptTestDeps(t) - deps.LargefuncAnalyzer = &mockLargefuncAnalyzer{} for i := 1; i <= 12; i++ { lang := fmt.Sprintf("lang%02d", i) db.Create(&model.Node{QualifiedName: fmt.Sprintf("pkg.Node%02d", i), Kind: model.NodeKindFunction, Name: fmt.Sprintf("Node%02d", i), FilePath: fmt.Sprintf("node%02d.go", i), StartLine: 1, EndLine: 5, Language: lang}) - db.Create(&model.Community{Key: fmt.Sprintf("community%02d", i), Label: fmt.Sprintf("community%02d", i), Strategy: "directory"}) - deps.LargefuncAnalyzer.(*mockLargefuncAnalyzer).result = append(deps.LargefuncAnalyzer.(*mockLargefuncAnalyzer).result, - model.Node{QualifiedName: fmt.Sprintf("pkg.BigFunc%02d", i), Kind: model.NodeKindFunction, Name: fmt.Sprintf("BigFunc%02d", i), FilePath: fmt.Sprintf("big%02d.go", i), StartLine: 1, EndLine: 80, Language: "go"}, - ) } text := getPromptText(t, callPrompt(t, deps, "onboard_developer", map[string]string{"limit": "25"})) @@ -644,14 +420,8 @@ func TestOnboardDeveloper_TruncatesSections(t *testing.T) { if got := countOccurrences(text, "- lang"); got != 10 { t.Fatalf("expected 10 language entries, got %d\n%s", got, text) } - if got := countOccurrences(text, "- community"); got != 10 { - t.Fatalf("expected 10 community entries, got %d\n%s", got, text) - } - if got := countOccurrences(text, "- pkg.BigFunc"); got != 10 { - t.Fatalf("expected 10 large functions, got %d\n%s", got, text) - } - if got := countOccurrences(text, "표시: 10건"); got < 3 { - t.Fatalf("expected truncation markers for onboarding sections, got %d\n%s", got, text) + if !strings.Contains(text, "표시: 10건") { + t.Fatalf("expected truncation marker for language section, got\n%s", text) } } @@ -659,7 +429,7 @@ func TestOnboardDeveloper_TruncatesSections(t *testing.T) { // 10.5 pre_merge_check tests // ============================================================ -func TestPreMergeCheck_ReturnsRiskAndCoverage(t *testing.T) { +func TestPreMergeCheck_ReturnsRisk(t *testing.T) { deps, db := setupPromptTestDeps(t) db.Create(&model.Node{QualifiedName: "pkg.Handler", Kind: model.NodeKindFunction, Name: "Handler", FilePath: "handler.go", StartLine: 1, EndLine: 20, Language: "go"}) @@ -680,53 +450,8 @@ func TestPreMergeCheck_ReturnsRiskAndCoverage(t *testing.T) { if !strings.Contains(text, "리스크") { t.Error("expected risk section") } - if !strings.Contains(text, "커버리지") { - t.Error("expected coverage section") - } -} - -func TestPreMergeCheck_IncludesDeadCode(t *testing.T) { - deps, db := setupPromptTestDeps(t) - - // Dead code: function with no incoming edges - db.Create(&model.Node{QualifiedName: "pkg.Unused", Kind: model.NodeKindFunction, Name: "Unused", FilePath: "unused.go", StartLine: 1, EndLine: 5, Language: "go"}) - - deps.ChangesGitClient = &mockGitClient{ - changedFiles: []string{}, - hunks: []changes.Hunk{}, - } - repoRoot := t.TempDir() - deps.RepoRoot = repoRoot - - resultJSON := callPrompt(t, deps, "pre_merge_check", map[string]string{ - "repo_root": repoRoot, - }) - text := getPromptText(t, resultJSON) - - if !strings.Contains(text, "미사용") || !strings.Contains(text, "Unused") { - t.Errorf("expected dead code section with Unused, got: %s", text) - } -} - -func TestPreMergeCheck_IncludesLargeFunctions(t *testing.T) { - deps, db := setupPromptTestDeps(t) - - db.Create(&model.Node{QualifiedName: "pkg.Huge", Kind: model.NodeKindFunction, Name: "Huge", FilePath: "huge.go", StartLine: 1, EndLine: 200, Language: "go"}) - - deps.ChangesGitClient = &mockGitClient{ - changedFiles: []string{}, - hunks: []changes.Hunk{}, - } - repoRoot := t.TempDir() - deps.RepoRoot = repoRoot - - resultJSON := callPrompt(t, deps, "pre_merge_check", map[string]string{ - "repo_root": repoRoot, - }) - text := getPromptText(t, resultJSON) - - if !strings.Contains(text, "Huge") { - t.Error("expected Huge in large functions section") + if !strings.Contains(text, "pkg.Handler") { + t.Error("expected changed function in risk section") } } @@ -750,11 +475,8 @@ func TestPreMergeCheck_EmptyChanges(t *testing.T) { } } -func TestPreMergeCheck_TruncatesSections(t *testing.T) { +func TestPreMergeCheck_TruncatesRiskSection(t *testing.T) { deps, db := setupPromptTestDeps(t) - deps.CoverageAnalyzer = &perFileCoverageAnalyzer{} - deps.DeadcodeAnalyzer = &mockDeadcodeAnalyzer{} - deps.LargefuncAnalyzer = &mockLargefuncAnalyzer{} changedFiles := make([]string, 0, 22) hunks := make([]changes.Hunk, 0, 22) @@ -764,14 +486,6 @@ func TestPreMergeCheck_TruncatesSections(t *testing.T) { changedFiles = append(changedFiles, filePath) hunks = append(hunks, changes.Hunk{FilePath: filePath, StartLine: 1, EndLine: 60}) } - for i := 1; i <= 12; i++ { - deps.DeadcodeAnalyzer.(*mockDeadcodeAnalyzer).result = append(deps.DeadcodeAnalyzer.(*mockDeadcodeAnalyzer).result, - model.Node{QualifiedName: fmt.Sprintf("pkg.Unused%02d", i), Kind: model.NodeKindFunction, FilePath: fmt.Sprintf("unused%02d.go", i)}, - ) - deps.LargefuncAnalyzer.(*mockLargefuncAnalyzer).result = append(deps.LargefuncAnalyzer.(*mockLargefuncAnalyzer).result, - model.Node{QualifiedName: fmt.Sprintf("pkg.Huge%02d", i), Kind: model.NodeKindFunction, FilePath: fmt.Sprintf("huge%02d.go", i), StartLine: 1, EndLine: 60}, - ) - } deps.ChangesGitClient = &mockGitClient{changedFiles: changedFiles, hunks: hunks} repoRoot := t.TempDir() @@ -785,17 +499,8 @@ func TestPreMergeCheck_TruncatesSections(t *testing.T) { if got := countOccurrences(text, "리스크 점수:"); got != 20 { t.Fatalf("expected 20 risk entries, got %d\n%s", got, text) } - if got := countOccurrences(text, "1/2 (50%)"); got != 10 { - t.Fatalf("expected 10 coverage entries, got %d\n%s", got, text) - } - if got := countOccurrences(text, "- 미사용: "); got != 10 { - t.Fatalf("expected 10 dead-code entries, got %d\n%s", got, text) - } - if got := countOccurrences(text, "(60줄)"); got != 10 { - t.Fatalf("expected 10 large-function entries, got %d\n%s", got, text) - } - if !strings.Contains(text, "표시: 20건") || countOccurrences(text, "표시: 10건") < 3 { - t.Fatalf("expected truncation markers across pre-merge sections\n%s", text) + if !strings.Contains(text, "표시: 20건") { + t.Fatalf("expected risk truncation marker\n%s", text) } } @@ -865,16 +570,6 @@ type mockGitClient struct { lastBaseRef string } -type perFileCoverageAnalyzer struct{} - -func (p *perFileCoverageAnalyzer) ByFile(ctx context.Context, filePath string) (*coverage.FileCoverage, error) { - return &coverage.FileCoverage{FilePath: filePath, Tested: 1, Total: 2, Ratio: 0.5}, nil -} - -func (p *perFileCoverageAnalyzer) ByCommunity(ctx context.Context, communityID uint) (*coverage.CommunityCoverage, error) { - return &coverage.CommunityCoverage{}, nil -} - func (m *mockGitClient) ChangedFiles(ctx context.Context, repoDir, baseRef string) ([]string, error) { m.lastBaseRef = baseRef return m.changedFiles, nil diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 0c61488..df13a36 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -29,7 +29,7 @@ func NewServer(deps *Deps) *server.MCPServer { h := &handlers{deps: deps, cache: deps.Cache} registerTools(srv, h) - log.Info("MCP server created", "name", "code-context-graph", "version", "1.0.0", "prompts", 5) + log.Info("MCP server created", "name", "code-context-graph", "version", "1.0.0", "prompts", 4) p := &promptHandlers{deps: deps} registerPrompts(srv, p) diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index cc8f44a..3cd47d1 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -29,14 +29,9 @@ func TestMCPServer_ListTools(t *testing.T) { "run_postprocess", "query_graph", "list_graph_stats", - "find_large_functions", "detect_changes", "get_affected_flows", "list_flows", - "list_communities", - "get_community", - "get_architecture_overview", - "find_dead_code", "find_suspect_fallback_edges", "get_doc_content", "search_docs", @@ -77,8 +72,8 @@ func TestMCPServer_ListTools_Count(t *testing.T) { srv := NewServer(deps) tools := srv.ListTools() - if len(tools) != 31 { - t.Fatalf("expected 31 tools, got %d", len(tools)) + if len(tools) != 26 { + t.Fatalf("expected 26 tools, got %d", len(tools)) } } @@ -175,14 +170,9 @@ func TestMCPServer_ToolRequiredFlags(t *testing.T) { "run_postprocess": nil, "query_graph": {"pattern", "target"}, "list_graph_stats": nil, - "find_large_functions": nil, "detect_changes": {"repo_root"}, "get_affected_flows": {"repo_root"}, "list_flows": nil, - "list_communities": nil, - "get_community": {"community_id"}, - "get_architecture_overview": nil, - "find_dead_code": nil, "find_suspect_fallback_edges": nil, "get_doc_content": {"file_path"}, "search_docs": {"query"}, @@ -257,7 +247,6 @@ func TestMCPServer_ListPrompts_ExactSetAndRequiredArgs(t *testing.T) { expectedNames := []string{ "review_changes", - "architecture_map", "debug_issue", "onboard_developer", "pre_merge_check", @@ -296,7 +285,6 @@ func TestMCPServer_ListPrompts_ExactSetAndRequiredArgs(t *testing.T) { } assertRequired("review_changes", []string{"repo_root"}) - assertRequired("architecture_map", nil) assertRequired("debug_issue", []string{"description"}) assertRequired("onboard_developer", nil) assertRequired("pre_merge_check", []string{"repo_root"}) diff --git a/internal/mcp/testhelpers_test.go b/internal/mcp/testhelpers_test.go index ddc1d15..9ed7264 100644 --- a/internal/mcp/testhelpers_test.go +++ b/internal/mcp/testhelpers_test.go @@ -218,7 +218,7 @@ func setupTestDepsMinimal(t *testing.T) *Deps { }, RepoRoot: os.TempDir(), // Note: QueryService, LargefuncAnalyzer, DeadcodeAnalyzer, CouplingAnalyzer, - // CoverageAnalyzer, CommunityBuilder, FlowBuilder, and Incremental are intentionally nil + // CoverageAnalyzer, FlowBuilder, and Incremental are intentionally nil } } diff --git a/internal/mcp/testmocks_test.go b/internal/mcp/testmocks_test.go index 29bc336..5648ac5 100644 --- a/internal/mcp/testmocks_test.go +++ b/internal/mcp/testmocks_test.go @@ -3,19 +3,13 @@ package mcp import ( "context" - "github.com/tae2089/code-context-graph/internal/analysis/community" - "github.com/tae2089/code-context-graph/internal/analysis/coupling" - "github.com/tae2089/code-context-graph/internal/analysis/coverage" - "github.com/tae2089/code-context-graph/internal/analysis/deadcode" fallbackanalysis "github.com/tae2089/code-context-graph/internal/analysis/fallback" "github.com/tae2089/code-context-graph/internal/analysis/flows" "github.com/tae2089/code-context-graph/internal/analysis/incremental" - "github.com/tae2089/code-context-graph/internal/analysis/largefunc" "github.com/tae2089/code-context-graph/internal/analysis/query" "github.com/tae2089/code-context-graph/internal/ctxns" "github.com/tae2089/code-context-graph/internal/model" "github.com/tae2089/code-context-graph/internal/paging" - "github.com/tae2089/code-context-graph/internal/pathutil" ) type mockQueryService struct { @@ -203,66 +197,6 @@ func (m *mockQueryService) FindExactNameMatches(ctx context.Context, target stri return m.matchResult, m.err } -type mockLargefuncAnalyzer struct { - findCalled bool - findPageCalled bool - findPageOpts largefunc.Options - result []model.Node - err error -} - -func (m *mockLargefuncAnalyzer) Find(ctx context.Context, threshold int) ([]model.Node, error) { - m.findCalled = true - return m.result, m.err -} - -func (m *mockLargefuncAnalyzer) FindPage(ctx context.Context, opts largefunc.Options) (largefunc.Result, error) { - m.findPageCalled = true - m.findPageOpts = opts - items := m.result - if opts.PathPrefix != "" { - filtered := make([]model.Node, 0, len(items)) - for _, item := range items { - if pathutil.HasPathPrefix(item.FilePath, opts.PathPrefix) { - filtered = append(filtered, item) - } - } - items = filtered - } - items, hasMore := applyPagedResult(items, opts.Page) - return largefunc.Result{Items: items, Pagination: paging.BuildPage(opts.Page, len(items), hasMore)}, m.err -} - -type mockDeadcodeAnalyzer struct { - findCalled bool - findPageCalled bool - findPageOpts deadcode.Options - result []model.Node - err error -} - -func (m *mockDeadcodeAnalyzer) Find(ctx context.Context, opts deadcode.Options) ([]model.Node, error) { - m.findCalled = true - return m.result, m.err -} - -func (m *mockDeadcodeAnalyzer) FindPage(ctx context.Context, opts deadcode.Options) (deadcode.Result, error) { - m.findPageCalled = true - m.findPageOpts = opts - items := m.result - if opts.FilePattern != "" { - filtered := make([]model.Node, 0, len(items)) - for _, item := range items { - if pathutil.HasPathPrefix(item.FilePath, opts.FilePattern) { - filtered = append(filtered, item) - } - } - items = filtered - } - items, hasMore := applyPagedResult(items, opts.Page) - return deadcode.Result{Items: items, Pagination: paging.BuildPage(opts.Page, len(items), hasMore)}, m.err -} - type mockFallbackAnalyzer struct { findCalled bool findPageCalled bool @@ -284,68 +218,6 @@ func (m *mockFallbackAnalyzer) FindSuspectsPage(ctx context.Context, opts fallba return fallbackanalysis.Result{Items: items, Pagination: paging.BuildPage(opts.Page, len(items), hasMore)}, m.err } -type mockCouplingAnalyzer struct { - analyzeCalled bool - analyzePageCalled bool - result []coupling.CouplingPair - err error -} - -func (m *mockCouplingAnalyzer) Analyze(ctx context.Context) ([]coupling.CouplingPair, error) { - m.analyzeCalled = true - return m.result, m.err -} - -func (m *mockCouplingAnalyzer) AnalyzePage(ctx context.Context, req paging.Request) (coupling.Result, error) { - m.analyzePageCalled = true - if m.err != nil { - return coupling.Result{}, m.err - } - normalized, err := paging.Normalize(req) - if err != nil { - return coupling.Result{}, err - } - total := len(m.result) - if normalized.Offset >= total { - return coupling.Result{Items: []coupling.CouplingPair{}, Pagination: paging.BuildPage(normalized, 0, false)}, nil - } - end := normalized.Offset + normalized.Limit - hasMore := end < total - if end > total { - end = total - } - items := append([]coupling.CouplingPair(nil), m.result[normalized.Offset:end]...) - return coupling.Result{Items: items, Pagination: paging.BuildPage(normalized, len(items), hasMore)}, nil -} - -type mockCoverageAnalyzer struct { - byFileCalled bool - byCommunCalled bool - fileResult *coverage.FileCoverage - communityResult *coverage.CommunityCoverage - err error -} - -func (m *mockCoverageAnalyzer) ByFile(ctx context.Context, filePath string) (*coverage.FileCoverage, error) { - m.byFileCalled = true - return m.fileResult, m.err -} -func (m *mockCoverageAnalyzer) ByCommunity(ctx context.Context, communityID uint) (*coverage.CommunityCoverage, error) { - m.byCommunCalled = true - return m.communityResult, m.err -} - -type mockCommunityBuilder struct { - rebuildCalled bool - result []community.Stats - err error -} - -func (m *mockCommunityBuilder) Rebuild(ctx context.Context, cfg community.Config) ([]community.Stats, error) { - m.rebuildCalled = true - return m.result, m.err -} - type mockFlowBuilder struct { rebuildCalled bool result []flows.Stats diff --git a/internal/mcp/tools_analysis.go b/internal/mcp/tools_analysis.go index 2c43dd0..a7acdb6 100644 --- a/internal/mcp/tools_analysis.go +++ b/internal/mcp/tools_analysis.go @@ -49,15 +49,6 @@ func analysisTools(h *handlers) []server.ServerTool { )...), Handler: h.getAffectedFlows, }, - { - Tool: mcp.NewTool("find_dead_code", withNamespaceParam( - mcp.WithDescription("Find unused code with no incoming edges"), - mcp.WithNumber("limit", mcp.Description("Maximum number of results (default: 50, max: 500)")), - mcp.WithNumber("offset", mcp.Description("Zero-based result offset for pagination (default: 0)")), - mcp.WithString("path", mcp.Description("Filter results to file paths starting with this prefix")), - )...), - Handler: h.findDeadCode, - }, { Tool: mcp.NewTool("find_suspect_fallback_edges", withNamespaceParam( mcp.WithDescription("Find fallback call edges whose source/target annotations have no overlapping intent/domainRule context"), diff --git a/internal/mcp/tools_graph.go b/internal/mcp/tools_graph.go index 1169f0a..11c8488 100644 --- a/internal/mcp/tools_graph.go +++ b/internal/mcp/tools_graph.go @@ -19,35 +19,5 @@ func graphTools(h *handlers) []server.ServerTool { )...), Handler: h.listFlows, }, - { - Tool: mcp.NewTool("list_communities", withNamespaceParam( - mcp.WithDescription("List communities with node counts and optional filtering"), - mcp.WithString("sort_by", mcp.Description("Sort order: size, name, or cohesion (default: size)")), - mcp.WithNumber("min_size", mcp.Description("Minimum node count filter (default: 0)")), - mcp.WithNumber("limit", mcp.Description("Maximum number of results (default: 50)")), - mcp.WithNumber("offset", mcp.Description("Zero-based offset for pagination (default: 0)")), - )...), - Handler: h.listCommunities, - }, - { - Tool: mcp.NewTool("get_community", withNamespaceParam( - mcp.WithDescription("Get community details with optional member listing and coverage"), - mcp.WithNumber("community_id", mcp.Description("Community ID"), mcp.Required()), - mcp.WithBoolean("include_members", mcp.Description("Include member nodes in response (default: false)")), - mcp.WithNumber("member_limit", mcp.Description("Maximum number of members to return when include_members=true (default: 100)")), - mcp.WithNumber("member_offset", mcp.Description("Zero-based member offset when include_members=true (default: 0)")), - )...), - Handler: h.getCommunity, - }, - { - Tool: mcp.NewTool("get_architecture_overview", withNamespaceParam( - mcp.WithDescription("Get architecture overview: communities, coupling analysis, and warnings"), - mcp.WithNumber("community_limit", mcp.Description("Maximum number of communities to return (default: 50)")), - mcp.WithNumber("community_offset", mcp.Description("Zero-based community offset for pagination (default: 0)")), - mcp.WithNumber("coupling_limit", mcp.Description("Maximum number of coupling pairs to return (default: 50)")), - mcp.WithNumber("coupling_offset", mcp.Description("Zero-based coupling offset for pagination (default: 0)")), - )...), - Handler: h.getArchitectureOverview, - }, } } diff --git a/internal/mcp/tools_query.go b/internal/mcp/tools_query.go index d03e7c4..5d8bac9 100644 --- a/internal/mcp/tools_query.go +++ b/internal/mcp/tools_query.go @@ -58,15 +58,5 @@ func queryTools(h *handlers) []server.ServerTool { )...), Handler: h.listGraphStats, }, - { - Tool: mcp.NewTool("find_large_functions", withNamespaceParam( - mcp.WithDescription("Find functions exceeding a line count threshold"), - mcp.WithNumber("min_lines", mcp.Description("Minimum line count threshold (default: 50)")), - mcp.WithNumber("limit", mcp.Description("Maximum number of results (default: 50, max: 500)")), - mcp.WithNumber("offset", mcp.Description("Zero-based result offset for pagination (default: 0)")), - mcp.WithString("path", mcp.Description("Filter results to file paths starting with this prefix")), - )...), - Handler: h.findLargeFunctions, - }, } } diff --git a/internal/mcpruntime/runtime.go b/internal/mcpruntime/runtime.go index 20fb33a..b5b8c7b 100644 --- a/internal/mcpruntime/runtime.go +++ b/internal/mcpruntime/runtime.go @@ -14,13 +14,8 @@ import ( "gorm.io/gorm" "github.com/tae2089/code-context-graph/internal/analysis/changes" - "github.com/tae2089/code-context-graph/internal/analysis/community" - "github.com/tae2089/code-context-graph/internal/analysis/coupling" - "github.com/tae2089/code-context-graph/internal/analysis/coverage" - "github.com/tae2089/code-context-graph/internal/analysis/deadcode" "github.com/tae2089/code-context-graph/internal/analysis/flows" "github.com/tae2089/code-context-graph/internal/analysis/impact" - "github.com/tae2089/code-context-graph/internal/analysis/largefunc" "github.com/tae2089/code-context-graph/internal/analysis/query" "github.com/tae2089/code-context-graph/internal/core" "github.com/tae2089/code-context-graph/internal/mcp" @@ -93,11 +88,6 @@ func New(rt *core.Runtime, opts Options) (*Instance, error) { FlowTracer: flows.New(rt.Store), ChangesGitClient: changes.NewExecGitClient(), QueryService: query.New(rt.DB), - LargefuncAnalyzer: largefunc.New(rt.DB), - DeadcodeAnalyzer: deadcode.New(rt.DB), - CouplingAnalyzer: coupling.New(rt.DB), - CoverageAnalyzer: coverage.New(rt.DB), - CommunityBuilder: community.New(rt.DB), FlowBuilder: flows.NewBuilder(rt.DB, rt.Store), Incremental: rt.Syncer, PostprocessPolicy: NewPostprocessPolicy(rt.DB), diff --git a/internal/server/assertions.go b/internal/server/assertions.go index c8413ca..81c70d6 100644 --- a/internal/server/assertions.go +++ b/internal/server/assertions.go @@ -2,14 +2,9 @@ package server import ( - "github.com/tae2089/code-context-graph/internal/analysis/community" - "github.com/tae2089/code-context-graph/internal/analysis/coupling" - "github.com/tae2089/code-context-graph/internal/analysis/coverage" - "github.com/tae2089/code-context-graph/internal/analysis/deadcode" "github.com/tae2089/code-context-graph/internal/analysis/flows" "github.com/tae2089/code-context-graph/internal/analysis/impact" "github.com/tae2089/code-context-graph/internal/analysis/incremental" - "github.com/tae2089/code-context-graph/internal/analysis/largefunc" "github.com/tae2089/code-context-graph/internal/analysis/query" "github.com/tae2089/code-context-graph/internal/mcp" ) @@ -19,10 +14,5 @@ var ( _ mcp.ImpactAnalyzer = (*impact.Analyzer)(nil) _ mcp.FlowTracer = (*flows.Tracer)(nil) _ mcp.QueryService = (*query.Service)(nil) - _ mcp.LargefuncAnalyzer = (*largefunc.Service)(nil) - _ mcp.DeadcodeAnalyzer = (*deadcode.Service)(nil) - _ mcp.CouplingAnalyzer = (*coupling.Service)(nil) - _ mcp.CoverageAnalyzer = (*coverage.Service)(nil) - _ mcp.CommunityBuilder = (*community.Builder)(nil) _ mcp.IncrementalSyncer = (*incremental.Syncer)(nil) )