@@ -219,9 +219,20 @@ type Domain struct {
219219
220220// Stats holds codebase statistics.
221221type 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.
0 commit comments