Skip to content

Commit 9d05370

Browse files
jonathanpophamgreynewellclaude
authored
feat: wire dead-code to /v1/analysis/dead-code endpoint (#23)
* feat: wire dead-code command to /v1/analysis/dead-code endpoint Replace naive graph-edge counting with the API's dedicated dead code analysis endpoint which provides multi-phase reachability, transitive propagation, confidence levels, line numbers, and reasons. New flags: --min-confidence (high/medium/low), --limit Removed: --include-exports (handled by API) Closes #22 * test: add unit tests for dead-code output formatting and JSON * feat: add --ignore flag and SUPERMODEL_API_KEY env var support - dead-code: --ignore <glob> filters candidates client-side (repeatable, supports ** across segments). Implemented without new dependencies. - config: SUPERMODEL_API_KEY and SUPERMODEL_API_BASE env vars override config file values, enabling CI/CD usage without a config file. - dead-code summary line now reflects post-filter candidate count. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Grey Newell <greyshipscode@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6ccd233 commit 9d05370

File tree

9 files changed

+497
-151
lines changed

9 files changed

+497
-151
lines changed

cmd/deadcode.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ func init() {
1313
c := &cobra.Command{
1414
Use: "dead-code [path]",
1515
Aliases: []string{"dc"},
16-
Short: "Find functions with no callers",
17-
Long: `Analyses the call graph and reports functions that are never called
18-
from anywhere in the repository.
16+
Short: "Find unreachable functions using static analysis",
17+
Long: `Uploads the repository to the Supermodel API and runs multi-phase dead
18+
code analysis including call graph reachability, entry point detection,
19+
and transitive propagation.
1920
20-
Exported functions, entry points (main, init), and test functions are
21-
excluded by default because they are reachable by external callers.
22-
Pass --include-exports to include them.`,
21+
Results include confidence levels (high/medium/low), line numbers, and
22+
explanations for why each function was flagged.`,
2323
Args: cobra.MaximumNArgs(1),
2424
RunE: func(cmd *cobra.Command, args []string) error {
2525
cfg, err := config.Load()
@@ -38,7 +38,9 @@ Pass --include-exports to include them.`,
3838
}
3939

4040
c.Flags().BoolVar(&opts.Force, "force", false, "re-analyze even if a cached result exists")
41-
c.Flags().BoolVar(&opts.IncludeExports, "include-exports", false, "include exported functions in results")
41+
c.Flags().StringVar(&opts.MinConfidence, "min-confidence", "", "minimum confidence: high, medium, or low")
42+
c.Flags().IntVar(&opts.Limit, "limit", 0, "maximum number of candidates to return")
43+
c.Flags().StringArrayVar(&opts.Ignore, "ignore", nil, "glob pattern to exclude from results (repeatable, supports **)")
4244
c.Flags().StringVarP(&opts.Output, "output", "o", "", "output format: human|json")
4345

4446
rootCmd.AddCommand(c)

internal/api/client.go

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,63 @@ func (c *Client) pollUntilComplete(ctx context.Context, zipPath, idempotencyKey
110110
// postZip sends the repository ZIP to the analyze endpoint and returns the
111111
// raw job response (which may be pending, processing, or completed).
112112
func (c *Client) postZip(ctx context.Context, zipPath, idempotencyKey string) (*JobResponse, error) {
113+
return c.postZipTo(ctx, zipPath, idempotencyKey, analyzeEndpoint)
114+
}
115+
116+
// deadCodeEndpoint is the API path for dead code analysis.
117+
const deadCodeEndpoint = "/v1/analysis/dead-code"
118+
119+
// DeadCode uploads a repository ZIP and runs dead code analysis,
120+
// polling until the async job completes and returning the result.
121+
func (c *Client) DeadCode(ctx context.Context, zipPath, idempotencyKey string, minConfidence string, limit int) (*DeadCodeResult, error) {
122+
endpoint := deadCodeEndpoint
123+
sep := "?"
124+
if minConfidence != "" {
125+
endpoint += sep + "min_confidence=" + minConfidence
126+
sep = "&"
127+
}
128+
if limit > 0 {
129+
endpoint += sep + fmt.Sprintf("limit=%d", limit)
130+
}
131+
132+
job, err := c.postZipTo(ctx, zipPath, idempotencyKey, endpoint)
133+
if err != nil {
134+
return nil, err
135+
}
136+
137+
for job.Status == "pending" || job.Status == "processing" {
138+
wait := time.Duration(job.RetryAfter) * time.Second
139+
if wait <= 0 {
140+
wait = 5 * time.Second
141+
}
142+
select {
143+
case <-ctx.Done():
144+
return nil, ctx.Err()
145+
case <-time.After(wait):
146+
}
147+
148+
job, err = c.postZipTo(ctx, zipPath, idempotencyKey, endpoint)
149+
if err != nil {
150+
return nil, err
151+
}
152+
}
153+
154+
if job.Error != nil {
155+
return nil, fmt.Errorf("dead code analysis failed: %s", *job.Error)
156+
}
157+
if job.Status != "completed" {
158+
return nil, fmt.Errorf("unexpected job status: %s", job.Status)
159+
}
160+
161+
var result DeadCodeResult
162+
if err := json.Unmarshal(job.Result, &result); err != nil {
163+
return nil, fmt.Errorf("decode dead code result: %w", err)
164+
}
165+
return &result, nil
166+
}
167+
168+
// postZipTo sends a repository ZIP to the given endpoint and returns the job response.
169+
func (c *Client) postZipTo(ctx context.Context, zipPath, idempotencyKey, endpoint string) (*JobResponse, error) {
113170
f, err := os.Open(zipPath)
114171
if err != nil {
115172
return nil, err
@@ -128,7 +185,7 @@ func (c *Client) postZip(ctx context.Context, zipPath, idempotencyKey string) (*
128185
mw.Close()
129186

130187
var job JobResponse
131-
if err := c.request(ctx, http.MethodPost, analyzeEndpoint, mw.FormDataContentType(), &buf, idempotencyKey, &job); err != nil {
188+
if err := c.request(ctx, http.MethodPost, endpoint, mw.FormDataContentType(), &buf, idempotencyKey, &job); err != nil {
132189
return nil, err
133190
}
134191
return &job, nil

internal/api/types.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,48 @@ type jobResult struct {
158158
Graph Graph `json:"graph"`
159159
}
160160

161+
// DeadCodeResult is the result from /v1/analysis/dead-code.
162+
type DeadCodeResult struct {
163+
Metadata DeadCodeMetadata `json:"metadata"`
164+
DeadCodeCandidates []DeadCodeCandidate `json:"deadCodeCandidates"`
165+
AliveCode []AliveCode `json:"aliveCode"`
166+
EntryPoints []EntryPoint `json:"entryPoints"`
167+
}
168+
169+
// DeadCodeMetadata holds summary stats for a dead code analysis.
170+
type DeadCodeMetadata struct {
171+
TotalDeclarations int `json:"totalDeclarations"`
172+
DeadCodeCandidates int `json:"deadCodeCandidates"`
173+
AliveCode int `json:"aliveCode"`
174+
AnalysisMethod string `json:"analysisMethod"`
175+
AnalysisStartTime string `json:"analysisStartTime"`
176+
AnalysisEndTime string `json:"analysisEndTime"`
177+
}
178+
179+
// DeadCodeCandidate is a function flagged as unreachable.
180+
type DeadCodeCandidate struct {
181+
File string `json:"file"`
182+
Name string `json:"name"`
183+
Line int `json:"line"`
184+
Type string `json:"type"`
185+
Confidence string `json:"confidence"`
186+
Reason string `json:"reason"`
187+
}
188+
189+
// AliveCode is a function confirmed as reachable.
190+
type AliveCode struct {
191+
File string `json:"file"`
192+
Name string `json:"name"`
193+
Line int `json:"line"`
194+
Type string `json:"type"`
195+
CallerCount int `json:"callerCount"`
196+
}
197+
198+
// EntryPoint is a detected entry point that should not be flagged as dead.
199+
type EntryPoint struct {
200+
File string `json:"file"`
201+
}
202+
161203
// Error represents a non-2xx response from the API.
162204
type Error struct {
163205
StatusCode int `json:"-"`

internal/config/config.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,15 @@ func Path() string {
3232
}
3333

3434
// Load reads the config file. Returns defaults when the file does not exist.
35+
// Environment variables override file values:
36+
// - SUPERMODEL_API_KEY overrides api_key
37+
// - SUPERMODEL_API_BASE overrides api_base
3538
func Load() (*Config, error) {
3639
data, err := os.ReadFile(Path())
3740
if os.IsNotExist(err) {
38-
return defaults(), nil
41+
cfg := defaults()
42+
cfg.applyEnv()
43+
return cfg, nil
3944
}
4045
if err != nil {
4146
return nil, fmt.Errorf("read config: %w", err)
@@ -45,6 +50,7 @@ func Load() (*Config, error) {
4550
return nil, fmt.Errorf("parse config: %w", err)
4651
}
4752
cfg.applyDefaults()
53+
cfg.applyEnv()
4854
return &cfg, nil
4955
}
5056

@@ -84,3 +90,12 @@ func (c *Config) applyDefaults() {
8490
c.Output = defaultOutput
8591
}
8692
}
93+
94+
func (c *Config) applyEnv() {
95+
if key := os.Getenv("SUPERMODEL_API_KEY"); key != "" {
96+
c.APIKey = key
97+
}
98+
if base := os.Getenv("SUPERMODEL_API_BASE"); base != "" {
99+
c.APIBase = base
100+
}
101+
}

internal/config/config_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99

1010
func TestLoadDefaults(t *testing.T) {
1111
t.Setenv("HOME", t.TempDir())
12+
t.Setenv("SUPERMODEL_API_KEY", "")
13+
t.Setenv("SUPERMODEL_API_BASE", "")
1214
cfg, err := Load()
1315
if err != nil {
1416
t.Fatal(err)
@@ -23,6 +25,8 @@ func TestLoadDefaults(t *testing.T) {
2325

2426
func TestSaveAndLoad(t *testing.T) {
2527
t.Setenv("HOME", t.TempDir())
28+
t.Setenv("SUPERMODEL_API_KEY", "")
29+
t.Setenv("SUPERMODEL_API_BASE", "")
2630
cfg := &Config{APIKey: "test-key", APIBase: DefaultAPIBase, Output: "json"}
2731
if err := cfg.Save(); err != nil {
2832
t.Fatal(err)

internal/deadcode/glob.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package deadcode
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
)
7+
8+
// matchGlob reports whether filePath matches the glob pattern.
9+
// Supports *, ?, [...] within a single path segment (via filepath.Match)
10+
// and ** to match zero or more path segments.
11+
//
12+
// Examples:
13+
//
14+
// matchGlob("dist/**", "dist/index.js") → true
15+
// matchGlob("**/generated/**", "src/generated/api.go") → true
16+
// matchGlob("**/*.test.ts", "src/foo.test.ts") → true
17+
func matchGlob(pattern, filePath string) bool {
18+
pattern = filepath.ToSlash(pattern)
19+
filePath = filepath.ToSlash(filePath)
20+
return matchSegments(strings.Split(pattern, "/"), strings.Split(filePath, "/"))
21+
}
22+
23+
func matchSegments(pat, path []string) bool {
24+
for len(pat) > 0 {
25+
seg := pat[0]
26+
pat = pat[1:]
27+
28+
if seg == "**" {
29+
// ** at the end matches one or more remaining segments (mirrors minimatch behaviour).
30+
if len(pat) == 0 {
31+
return len(path) > 0
32+
}
33+
// Try consuming 0, 1, 2, … path segments before resuming.
34+
for i := 0; i <= len(path); i++ {
35+
if matchSegments(pat, path[i:]) {
36+
return true
37+
}
38+
}
39+
return false
40+
}
41+
42+
if len(path) == 0 {
43+
return false
44+
}
45+
46+
ok, err := filepath.Match(seg, path[0])
47+
if err != nil || !ok {
48+
return false
49+
}
50+
path = path[1:]
51+
}
52+
return len(path) == 0
53+
}

0 commit comments

Comments
 (0)