Skip to content
This repository was archived by the owner on Apr 2, 2026. It is now read-only.

Commit e64d4c3

Browse files
greynewellclaude
andcommitted
feat: show circular dependency warning in context bomb
- Add CircularDependencyCycles field to Stats struct - Add CircularDependencyCycle and CircularDependencyResponse types - Add GetCircularDependencies API client method with proper error handling - Add fetchGraphWithCircularDeps helper in cmd/ that calls both endpoints - Render circular dependency warning in context bomb template - Preserve warning in truncateToTokenBudget fallback path Closes #12 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0c72264 commit e64d4c3

File tree

4 files changed

+184
-13
lines changed

4 files changed

+184
-13
lines changed

cmd/pregen.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ func pregenHandler(cmd *cobra.Command, args []string) error {
9090
}
9191

9292
apiClient := api.New(cfg.BaseURL, cfg.APIKey, debug, logFn)
93-
graph, err := apiClient.GetGraph(ctx, proj.Name, zipData)
93+
graph, err := fetchGraphWithCircularDeps(ctx, apiClient, proj.Name, zipData, logFn)
9494
if err != nil {
9595
logFn("[warn] API error: %v", err)
9696
return nil
@@ -117,7 +117,7 @@ func pregenFetch(cfg *config.Config, proj *project.Info, logFn func(string, ...i
117117
}
118118

119119
apiClient := api.New(cfg.BaseURL, cfg.APIKey, debug, logFn)
120-
_, err = apiClient.GetGraph(ctx, proj.Name, zipData)
120+
_, err = fetchGraphWithCircularDeps(ctx, apiClient, proj.Name, zipData, logFn)
121121
if err != nil {
122122
logFn("[warn] API error: %v", err)
123123
}

cmd/run.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ func runHandler(cmd *cobra.Command, args []string) error {
124124
// else: fall through to use stale cache
125125
} else {
126126
apiClient := api.New(cfg.BaseURL, cfg.APIKey, debug, logFn)
127-
freshGraph, err := apiClient.GetGraph(ctx, proj.Name, zipData)
127+
freshGraph, err := fetchGraphWithCircularDeps(ctx, apiClient, proj.Name, zipData, logFn)
128128
if err != nil {
129129
logFn("[warn] API error: %v", err)
130130
if graph == nil {
@@ -195,7 +195,7 @@ func runWithoutCache(cfg *config.Config, proj *project.Info, logFn func(string,
195195
}
196196

197197
apiClient := api.New(cfg.BaseURL, cfg.APIKey, debug, logFn)
198-
graph, err := apiClient.GetGraph(ctx, proj.Name, zipData)
198+
graph, err := fetchGraphWithCircularDeps(ctx, apiClient, proj.Name, zipData, logFn)
199199
if err != nil {
200200
logFn("[warn] API error: %v", err)
201201
if fallback {
@@ -218,6 +218,31 @@ func runWithoutCache(cfg *config.Config, proj *project.Info, logFn func(string,
218218
return nil
219219
}
220220

221+
// fetchGraphWithCircularDeps calls GetGraph and GetCircularDependencies, storing
222+
// cycle count in Stats so it is cached alongside the graph.
223+
func fetchGraphWithCircularDeps(
224+
ctx context.Context,
225+
client *api.Client,
226+
projectName string,
227+
repoZip []byte,
228+
logFn func(string, ...interface{}),
229+
) (*api.ProjectGraph, error) {
230+
graph, err := client.GetGraph(ctx, projectName, repoZip)
231+
if err != nil {
232+
return nil, err
233+
}
234+
235+
circDeps, err := client.GetCircularDependencies(ctx, projectName, repoZip)
236+
if err != nil {
237+
logFn("[warn] circular dependency check failed: %v", err)
238+
} else if circDeps != nil {
239+
graph.Stats.CircularDependencyCycles = len(circDeps.Cycles)
240+
logFn("[debug] circular dependency cycles found: %d", graph.Stats.CircularDependencyCycles)
241+
}
242+
243+
return graph, nil
244+
}
245+
221246
// silentExit returns nil (success) so we never block Claude Code sessions.
222247
func silentExit() error {
223248
return nil

internal/api/client.go

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,20 @@ type Domain struct {
219219

220220
// Stats holds codebase statistics.
221221
type Stats struct {
222-
TotalFiles int `json:"total_files"`
223-
TotalFunctions int `json:"total_functions"`
224-
Languages []string `json:"languages,omitempty"`
222+
TotalFiles int `json:"total_files"`
223+
TotalFunctions int `json:"total_functions"`
224+
Languages []string `json:"languages,omitempty"`
225+
CircularDependencyCycles int `json:"circular_dependency_cycles,omitempty"`
226+
}
227+
228+
// CircularDependencyCycle represents a single circular import chain.
229+
type CircularDependencyCycle struct {
230+
Cycle []string `json:"cycle"`
231+
}
232+
233+
// CircularDependencyResponse is the result from the circular dependency endpoint.
234+
type CircularDependencyResponse struct {
235+
Cycles []CircularDependencyCycle `json:"cycles"`
225236
}
226237

227238
// JobStatus is the async envelope returned by the Supermodel API.
@@ -370,6 +381,128 @@ func (c *Client) GetGraph(ctx context.Context, projectName string, repoZip []byt
370381
return nil, fmt.Errorf("job did not complete after %d attempts", maxPollAttempts)
371382
}
372383

384+
// GetCircularDependencies submits the repo zip to the circular dependency endpoint
385+
// and returns the list of detected import cycles. Returns nil, nil if the endpoint
386+
// is unavailable. If available but no cycles are found, returns an empty response.
387+
func (c *Client) GetCircularDependencies(ctx context.Context, projectName string, repoZip []byte) (*CircularDependencyResponse, error) {
388+
c.logFn("[debug] checking circular dependencies (%d bytes)", len(repoZip))
389+
390+
idempotencyKey := uuid.NewString()
391+
deadline := time.Now().Add(maxPollDuration)
392+
393+
for attempt := 0; attempt < maxPollAttempts; attempt++ {
394+
if time.Now().After(deadline) {
395+
return nil, fmt.Errorf("circular dependency job timed out after %v", maxPollDuration)
396+
}
397+
select {
398+
case <-ctx.Done():
399+
return nil, ctx.Err()
400+
default:
401+
}
402+
403+
var body bytes.Buffer
404+
mw := multipart.NewWriter(&body)
405+
_ = mw.WriteField("project_name", projectName)
406+
fw, err := mw.CreateFormFile("file", "repo.zip")
407+
if err != nil {
408+
return nil, fmt.Errorf("creating multipart field: %w", err)
409+
}
410+
if _, err := fw.Write(repoZip); err != nil {
411+
return nil, fmt.Errorf("writing zip: %w", err)
412+
}
413+
if err := mw.Close(); err != nil {
414+
return nil, fmt.Errorf("closing multipart: %w", err)
415+
}
416+
417+
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
418+
c.baseURL+"/v1/graphs/circular-dependencies", &body)
419+
if err != nil {
420+
return nil, err
421+
}
422+
req.Header.Set("Content-Type", mw.FormDataContentType())
423+
req.Header.Set("X-Api-Key", c.apiKey)
424+
req.Header.Set("Accept", "application/json")
425+
req.Header.Set("User-Agent", "uncompact/1.0")
426+
req.Header.Set("Idempotency-Key", idempotencyKey)
427+
428+
resp, err := c.httpClient.Do(req)
429+
if err != nil {
430+
return nil, fmt.Errorf("circular dependency request failed: %w", err)
431+
}
432+
respBody, readErr := io.ReadAll(resp.Body)
433+
resp.Body.Close()
434+
if readErr != nil {
435+
return nil, fmt.Errorf("reading response: %w", readErr)
436+
}
437+
438+
c.logFn("[debug] circular dep poll attempt %d: HTTP %d", attempt+1, resp.StatusCode)
439+
440+
switch resp.StatusCode {
441+
case http.StatusUnauthorized:
442+
return nil, fmt.Errorf("authentication failed: check your API key at %s", config.DashboardURL)
443+
case http.StatusPaymentRequired:
444+
return nil, fmt.Errorf("subscription required: visit %s to subscribe", config.DashboardURL)
445+
case http.StatusTooManyRequests:
446+
return nil, fmt.Errorf("rate limit exceeded: please wait before retrying")
447+
case http.StatusNotFound, http.StatusMethodNotAllowed:
448+
// Endpoint not available — treat as no data
449+
return nil, nil
450+
case http.StatusOK, http.StatusAccepted:
451+
// Continue to parse
452+
default:
453+
var errResp struct {
454+
Message string `json:"message"`
455+
Error string `json:"error"`
456+
}
457+
_ = json.Unmarshal(respBody, &errResp)
458+
msg := errResp.Message
459+
if msg == "" {
460+
msg = errResp.Error
461+
}
462+
if msg == "" {
463+
msg = string(respBody)
464+
}
465+
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, msg)
466+
}
467+
468+
var jobResp JobStatus
469+
if err := json.Unmarshal(respBody, &jobResp); err != nil {
470+
return nil, fmt.Errorf("parsing response: %w", err)
471+
}
472+
473+
c.logFn("[debug] circular dep job %s status: %s", jobResp.JobID, jobResp.Status)
474+
475+
switch jobResp.Status {
476+
case "completed":
477+
if jobResp.Result == nil {
478+
return &CircularDependencyResponse{}, nil
479+
}
480+
var result CircularDependencyResponse
481+
if err := json.Unmarshal(*jobResp.Result, &result); err != nil {
482+
return nil, fmt.Errorf("parsing circular dependency result: %w", err)
483+
}
484+
return &result, nil
485+
case "failed":
486+
return nil, fmt.Errorf("circular dependency job failed: %s", jobResp.Error)
487+
case "pending", "processing":
488+
retryAfter := time.Duration(jobResp.RetryAfter) * time.Second
489+
if retryAfter <= 0 {
490+
retryAfter = 10 * time.Second
491+
}
492+
c.logFn("[debug] waiting %v before next circular dep poll", retryAfter)
493+
select {
494+
case <-ctx.Done():
495+
return nil, ctx.Err()
496+
case <-time.After(retryAfter):
497+
}
498+
default:
499+
c.logFn("[debug] unknown circular dep job status: %s", jobResp.Status)
500+
}
501+
}
502+
503+
return nil, fmt.Errorf("circular dependency job did not complete after %d attempts", maxPollAttempts)
504+
}
505+
373506
// ValidateKey checks if the API key is valid by probing the graphs endpoint.
374507
// A GET to /v1/graphs/supermodel returns 405 (Method Not Allowed) for valid keys
375508
// and 401/403 for invalid ones.

internal/template/render.go

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import (
1414
const contextBombTmpl = `# Uncompact Context — {{.ProjectName}}
1515
1616
> Injected by Uncompact at {{.Timestamp}}{{if .Stale}} | ⚠️ STALE: last updated {{.StaleDuration}}{{end}}
17+
{{- if .Graph.Stats.CircularDependencyCycles}}
18+
> ⚠️ {{.Graph.Stats.CircularDependencyCycles}} circular dependency {{if eq .Graph.Stats.CircularDependencyCycles 1}}cycle{{else}}cycles{{end}} detected
19+
{{- end}}
1720
1821
## Project Overview
1922
@@ -103,22 +106,32 @@ func Render(graph *api.ProjectGraph, projectName string, opts RenderOptions) (st
103106
}
104107

105108
// If over budget, truncate domains to fit
106-
return truncateToTokenBudget(graph, projectName, opts.MaxTokens)
109+
return truncateToTokenBudget(graph, projectName, opts.MaxTokens, graph.Stats.CircularDependencyCycles)
107110
}
108111

109112
// truncateToTokenBudget progressively drops lower-priority content to fit the token budget.
110113
func truncateToTokenBudget(
111114
graph *api.ProjectGraph,
112115
projectName string,
113116
maxTokens int,
117+
circularCycles int,
114118
) (string, int, error) {
115119

116120
// Build a minimal required header
117-
required := fmt.Sprintf(`# Uncompact Context — %s
118-
119-
**Language:** %s · **Files:** %d · **Functions:** %d`,
120-
projectName, graph.Language, graph.Stats.TotalFiles, graph.Stats.TotalFunctions,
121-
)
121+
var hdr strings.Builder
122+
hdr.WriteString(fmt.Sprintf("# Uncompact Context — %s\n\n", projectName))
123+
if circularCycles > 0 {
124+
label := "cycles"
125+
if circularCycles == 1 {
126+
label = "cycle"
127+
}
128+
hdr.WriteString(fmt.Sprintf("> ⚠️ %d circular dependency %s detected\n\n", circularCycles, label))
129+
}
130+
hdr.WriteString(fmt.Sprintf(
131+
"**Language:** %s · **Files:** %d · **Functions:** %d",
132+
graph.Language, graph.Stats.TotalFiles, graph.Stats.TotalFunctions,
133+
))
134+
required := hdr.String()
122135

123136
reqTokens := countTokens(required)
124137
if reqTokens > maxTokens {

0 commit comments

Comments
 (0)