Skip to content

Commit ea3280e

Browse files
feat: add bounded pagination contracts across analysis and MCP
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 1c0c20d commit ea3280e

22 files changed

Lines changed: 890 additions & 145 deletions

internal/analysis/changes/service.go

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package changes
33

44
import (
55
"context"
6+
"sort"
67

78
"github.com/tae2089/code-context-graph/internal/ctxns"
89
"github.com/tae2089/code-context-graph/internal/model"
10+
"github.com/tae2089/code-context-graph/internal/paging"
911
"gorm.io/gorm"
1012
)
1113

@@ -32,6 +34,13 @@ type RiskEntry struct {
3234
RiskScore float64
3335
}
3436

37+
// Result carries one bounded page of risk entries plus pagination metadata.
38+
// @intent expose paged change-risk results while keeping legacy callers working with []RiskEntry.
39+
type Result struct {
40+
Items []RiskEntry
41+
Pagination paging.Page
42+
}
43+
3544
// Service coordinates git-based change detection and graph-backed scoring.
3645
// @intent identify changed nodes and score how risky they are to modify
3746
type Service struct {
@@ -66,7 +75,59 @@ func (s *Service) Analyze(ctx context.Context, repoDir, baseRef string) ([]RiskE
6675
return nil, err
6776
}
6877

69-
return computeRiskScores(s.db, ctx, hits)
78+
risks, err := computeRiskScores(s.db, ctx, hits)
79+
if err != nil {
80+
return nil, err
81+
}
82+
sortRiskEntries(risks)
83+
return risks, nil
84+
}
85+
86+
// AnalyzePage detects changed functions and returns one bounded page of risk entries.
87+
// @intent push pagination into the change-risk service so handlers expose stable limit/offset windows.
88+
// @domainRule entries are sorted by descending risk_score, then file_path, then qualified_name for stable ordering.
89+
func (s *Service) AnalyzePage(ctx context.Context, repoDir, baseRef string, req paging.Request) (Result, error) {
90+
normalized, err := paging.Normalize(req)
91+
if err != nil {
92+
return Result{}, err
93+
}
94+
all, err := s.Analyze(ctx, repoDir, baseRef)
95+
if err != nil {
96+
return Result{}, err
97+
}
98+
total := len(all)
99+
if normalized.Offset >= total {
100+
return Result{Items: []RiskEntry{}, Pagination: paging.BuildPage(normalized, 0, false)}, nil
101+
}
102+
end := normalized.Offset + normalized.Limit + 1
103+
if end > total {
104+
end = total
105+
}
106+
window := all[normalized.Offset:end]
107+
hasMore := len(window) > normalized.Limit
108+
if hasMore {
109+
window = window[:normalized.Limit]
110+
}
111+
out := make([]RiskEntry, len(window))
112+
copy(out, window)
113+
return Result{Items: out, Pagination: paging.BuildPage(normalized, len(out), hasMore)}, nil
114+
}
115+
116+
// sortRiskEntries orders entries deterministically for stable pagination windows.
117+
// @intent guarantee identical limit/offset slices regardless of map iteration order in computeRiskScores.
118+
func sortRiskEntries(entries []RiskEntry) {
119+
sort.SliceStable(entries, func(i, j int) bool {
120+
if entries[i].RiskScore != entries[j].RiskScore {
121+
return entries[i].RiskScore > entries[j].RiskScore
122+
}
123+
if entries[i].Node.FilePath != entries[j].Node.FilePath {
124+
return entries[i].Node.FilePath < entries[j].Node.FilePath
125+
}
126+
if entries[i].Node.StartLine != entries[j].Node.StartLine {
127+
return entries[i].Node.StartLine < entries[j].Node.StartLine
128+
}
129+
return entries[i].Node.QualifiedName < entries[j].Node.QualifiedName
130+
})
70131
}
71132

72133
// collectDiffHunks retrieves changed files and their diff hunks from git,

internal/analysis/changes/service_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/tae2089/code-context-graph/internal/ctxns"
99
"github.com/tae2089/code-context-graph/internal/model"
10+
"github.com/tae2089/code-context-graph/internal/paging"
1011
"gorm.io/driver/sqlite"
1112
"gorm.io/gorm"
1213
gormlogger "gorm.io/gorm/logger"
@@ -204,3 +205,88 @@ func TestAnalyze_RespectsNamespace(t *testing.T) {
204205
t.Errorf("expected FooA, got %s", got[0].Node.Name)
205206
}
206207
}
208+
209+
func TestAnalyzePage_AppliesLimitOffsetAndHasMore(t *testing.T) {
210+
db := setupDB(t)
211+
for i := 1; i <= 5; i++ {
212+
seedNode(t, db, uint(i), fmt.Sprintf("Fn%d", i), fmt.Sprintf("f%d.go", i), 1, 50)
213+
}
214+
215+
hunks := []Hunk{}
216+
files := []string{}
217+
for i := 1; i <= 5; i++ {
218+
file := fmt.Sprintf("f%d.go", i)
219+
files = append(files, file)
220+
hunks = append(hunks, Hunk{FilePath: file, StartLine: 10, EndLine: 20})
221+
}
222+
for i := 1; i <= 5; i++ {
223+
for j := 0; j < i; j++ {
224+
seedEdge(t, db, uint(i), uint(100+j))
225+
}
226+
}
227+
228+
svc := New(db, &mockGit{files: files, hunks: hunks})
229+
230+
page1, err := svc.AnalyzePage(context.Background(), ".", "main", paging.Request{Limit: 2, Offset: 0})
231+
if err != nil {
232+
t.Fatalf("page1: %v", err)
233+
}
234+
if len(page1.Items) != 2 {
235+
t.Fatalf("page1 items = %d, want 2", len(page1.Items))
236+
}
237+
if !page1.Pagination.HasMore {
238+
t.Fatalf("page1 has_more = false, want true")
239+
}
240+
if page1.Items[0].RiskScore < page1.Items[1].RiskScore {
241+
t.Fatalf("page1 not sorted by risk desc: %v", page1.Items)
242+
}
243+
244+
page2, err := svc.AnalyzePage(context.Background(), ".", "main", paging.Request{Limit: 2, Offset: 2})
245+
if err != nil {
246+
t.Fatalf("page2: %v", err)
247+
}
248+
if len(page2.Items) != 2 {
249+
t.Fatalf("page2 items = %d, want 2", len(page2.Items))
250+
}
251+
if !page2.Pagination.HasMore {
252+
t.Fatalf("page2 has_more = false, want true")
253+
}
254+
255+
page3, err := svc.AnalyzePage(context.Background(), ".", "main", paging.Request{Limit: 2, Offset: 4})
256+
if err != nil {
257+
t.Fatalf("page3: %v", err)
258+
}
259+
if len(page3.Items) != 1 {
260+
t.Fatalf("page3 items = %d, want 1", len(page3.Items))
261+
}
262+
if page3.Pagination.HasMore {
263+
t.Fatalf("page3 has_more = true, want false")
264+
}
265+
}
266+
267+
func TestAnalyzePage_RejectsLimitAboveMax(t *testing.T) {
268+
db := setupDB(t)
269+
svc := New(db, &mockGit{})
270+
if _, err := svc.AnalyzePage(context.Background(), ".", "main", paging.Request{Limit: paging.MaxLimit + 1}); err == nil {
271+
t.Fatal("expected error for over-max limit")
272+
}
273+
}
274+
275+
func TestAnalyzePage_OffsetBeyondTotalReturnsEmpty(t *testing.T) {
276+
db := setupDB(t)
277+
seedNode(t, db, 1, "Foo", "a.go", 10, 30)
278+
svc := New(db, &mockGit{
279+
files: []string{"a.go"},
280+
hunks: []Hunk{{FilePath: "a.go", StartLine: 12, EndLine: 15}},
281+
})
282+
page, err := svc.AnalyzePage(context.Background(), ".", "main", paging.Request{Limit: 10, Offset: 50})
283+
if err != nil {
284+
t.Fatal(err)
285+
}
286+
if len(page.Items) != 0 {
287+
t.Fatalf("items = %d, want 0", len(page.Items))
288+
}
289+
if page.Pagination.HasMore {
290+
t.Fatal("has_more = true, want false")
291+
}
292+
}

internal/analysis/coupling/service.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package coupling
33

44
import (
55
"context"
6+
"sort"
67

78
"github.com/tae2089/code-context-graph/internal/ctxns"
89
"github.com/tae2089/code-context-graph/internal/model"
10+
"github.com/tae2089/code-context-graph/internal/paging"
911
"gorm.io/gorm"
1012
)
1113

@@ -18,6 +20,13 @@ type CouplingPair struct {
1820
Strength float64
1921
}
2022

23+
// Result carries one bounded page of coupling pairs plus pagination metadata.
24+
// @intent expose paged architecture-coupling results so MCP handlers stop slicing unbounded slices in memory.
25+
type Result struct {
26+
Items []CouplingPair
27+
Pagination paging.Page
28+
}
29+
2130
// Service analyzes architectural coupling from graph edges.
2231
// @intent measure dependency strength between detected communities
2332
type Service struct {
@@ -104,5 +113,53 @@ func (s *Service) Analyze(ctx context.Context) ([]CouplingPair, error) {
104113
})
105114
}
106115

116+
sortCouplingPairs(result)
107117
return result, nil
108118
}
119+
120+
// AnalyzePage returns one bounded page of coupling pairs.
121+
// @intent push pagination into the coupling service so handlers expose stable limit/offset windows without slicing unbounded slices.
122+
// @domainRule pairs are sorted by descending strength, then descending edge count, then from/to community for stable pagination.
123+
func (s *Service) AnalyzePage(ctx context.Context, req paging.Request) (Result, error) {
124+
normalized, err := paging.Normalize(req)
125+
if err != nil {
126+
return Result{}, err
127+
}
128+
all, err := s.Analyze(ctx)
129+
if err != nil {
130+
return Result{}, err
131+
}
132+
total := len(all)
133+
if normalized.Offset >= total {
134+
return Result{Items: []CouplingPair{}, Pagination: paging.BuildPage(normalized, 0, false)}, nil
135+
}
136+
end := normalized.Offset + normalized.Limit + 1
137+
if end > total {
138+
end = total
139+
}
140+
window := all[normalized.Offset:end]
141+
hasMore := len(window) > normalized.Limit
142+
if hasMore {
143+
window = window[:normalized.Limit]
144+
}
145+
out := make([]CouplingPair, len(window))
146+
copy(out, window)
147+
return Result{Items: out, Pagination: paging.BuildPage(normalized, len(out), hasMore)}, nil
148+
}
149+
150+
// sortCouplingPairs orders pairs deterministically for stable pagination windows.
151+
// @intent guarantee identical limit/offset slices regardless of map iteration order in Analyze.
152+
func sortCouplingPairs(pairs []CouplingPair) {
153+
sort.SliceStable(pairs, func(i, j int) bool {
154+
if pairs[i].Strength != pairs[j].Strength {
155+
return pairs[i].Strength > pairs[j].Strength
156+
}
157+
if pairs[i].EdgeCount != pairs[j].EdgeCount {
158+
return pairs[i].EdgeCount > pairs[j].EdgeCount
159+
}
160+
if pairs[i].FromCommunity != pairs[j].FromCommunity {
161+
return pairs[i].FromCommunity < pairs[j].FromCommunity
162+
}
163+
return pairs[i].ToCommunity < pairs[j].ToCommunity
164+
})
165+
}

internal/analysis/coupling/service_test.go

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

99
"github.com/tae2089/code-context-graph/internal/model"
10+
"github.com/tae2089/code-context-graph/internal/paging"
1011
"gorm.io/driver/sqlite"
1112
"gorm.io/gorm"
1213
gormlogger "gorm.io/gorm/logger"
@@ -205,3 +206,80 @@ func TestAnalyze_NoCommunities(t *testing.T) {
205206
t.Fatalf("expected 0, got %d", len(got))
206207
}
207208
}
209+
210+
func seedCrossPair(t *testing.T, db *gorm.DB, fromNode, toNode uint, count int, tag string) {
211+
t.Helper()
212+
for i := 0; i < count; i++ {
213+
if err := db.Create(&model.Edge{FromNodeID: fromNode, ToNodeID: toNode, Kind: model.EdgeKindCalls, Fingerprint: fmt.Sprintf("%s-%d", tag, i)}).Error; err != nil {
214+
t.Fatalf("seed edge: %v", err)
215+
}
216+
}
217+
}
218+
219+
func TestAnalyzePage_AppliesLimitOffsetAndHasMore(t *testing.T) {
220+
db := setupDB(t)
221+
for i := uint(1); i <= 4; i++ {
222+
seedNode(t, db, i, fmt.Sprintf("N%d", i), fmt.Sprintf("c%d/c.go", i))
223+
seedCommunity(t, db, i, fmt.Sprintf("c%d", i), i)
224+
}
225+
seedCrossPair(t, db, 1, 2, 10, "ab")
226+
seedCrossPair(t, db, 1, 3, 7, "ac")
227+
seedCrossPair(t, db, 1, 4, 4, "ad")
228+
seedCrossPair(t, db, 2, 3, 2, "bc")
229+
230+
svc := New(db)
231+
232+
page1, err := svc.AnalyzePage(context.Background(), paging.Request{Limit: 2, Offset: 0})
233+
if err != nil {
234+
t.Fatalf("page1: %v", err)
235+
}
236+
if len(page1.Items) != 2 {
237+
t.Fatalf("page1 items=%d, want 2", len(page1.Items))
238+
}
239+
if !page1.Pagination.HasMore {
240+
t.Fatalf("page1 has_more=false, want true")
241+
}
242+
if page1.Items[0].Strength < page1.Items[1].Strength {
243+
t.Fatalf("page1 not sorted by strength desc: %+v", page1.Items)
244+
}
245+
246+
page2, err := svc.AnalyzePage(context.Background(), paging.Request{Limit: 2, Offset: 2})
247+
if err != nil {
248+
t.Fatalf("page2: %v", err)
249+
}
250+
if len(page2.Items) != 2 {
251+
t.Fatalf("page2 items=%d, want 2", len(page2.Items))
252+
}
253+
if page2.Pagination.HasMore {
254+
t.Fatalf("page2 has_more=true, want false")
255+
}
256+
}
257+
258+
func TestAnalyzePage_RejectsLimitAboveMax(t *testing.T) {
259+
db := setupDB(t)
260+
svc := New(db)
261+
if _, err := svc.AnalyzePage(context.Background(), paging.Request{Limit: paging.MaxLimit + 1}); err == nil {
262+
t.Fatal("expected error for over-max limit")
263+
}
264+
}
265+
266+
func TestAnalyzePage_OffsetBeyondTotalReturnsEmpty(t *testing.T) {
267+
db := setupDB(t)
268+
seedNode(t, db, 1, "A1", "a/a.go")
269+
seedNode(t, db, 2, "B1", "b/b.go")
270+
seedCommunity(t, db, 1, "a", 1)
271+
seedCommunity(t, db, 2, "b", 2)
272+
seedCrossPair(t, db, 1, 2, 3, "ab")
273+
274+
svc := New(db)
275+
page, err := svc.AnalyzePage(context.Background(), paging.Request{Limit: 10, Offset: 50})
276+
if err != nil {
277+
t.Fatal(err)
278+
}
279+
if len(page.Items) != 0 {
280+
t.Fatalf("items=%d, want 0", len(page.Items))
281+
}
282+
if page.Pagination.HasMore {
283+
t.Fatal("has_more=true, want false")
284+
}
285+
}

0 commit comments

Comments
 (0)