Skip to content

Commit 1ca605e

Browse files
greynewellclaude
andauthored
Show graph stats summary after watch generates initial files (#55)
* Show graph stats summary after watch generates initial files When `supermodel watch` completes its initial generate (or loads from cache), it now prints a human-readable summary line: ✓ 847 files · 12,340 functions · 4,521 relationships (fetched) Incremental updates after hook notifications print a shorter update: ✓ Updated — 847 files · 12,340 functions · 4,521 relationships Implementation: - Add GraphStats struct and computeStats() to internal/files/graph.go - Add OnReady(GraphStats) and OnUpdate(GraphStats) callbacks to DaemonConfig - Track loadedCache bool on Daemon to distinguish API fetch vs cache hit - Wire styled stdout callbacks in files.Watch() Closes #51 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix goimports: remove stray blank line in daemon.go Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address CodeRabbit review: dead function count, label fix - Add DeadFunctionCount to GraphStats (functions with no callers), computed via Cache.Callers in computeStats() - Show uncalled count in OnReady summary when non-zero: ✓ 847 files · 12,340 functions · 4,521 relationships · 23 uncalled - Fix writeStatus label: was "nodes", now "files" to match SourceFiles value Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a195339 commit 1ca605e

3 files changed

Lines changed: 86 additions & 10 deletions

File tree

internal/files/daemon.go

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ type DaemonConfig struct {
2525
FSWatch bool
2626
PollInterval time.Duration
2727
LogFunc func(string, ...interface{})
28-
OnReady func()
28+
// OnReady is called once after the initial generate completes.
29+
OnReady func(GraphStats)
30+
// OnUpdate is called after each incremental update completes.
31+
OnUpdate func(GraphStats)
2932
}
3033

3134
// Daemon watches for file changes and keeps sidecars fresh.
@@ -35,9 +38,10 @@ type Daemon struct {
3538
cache *Cache
3639
logf func(string, ...interface{})
3740

38-
mu sync.Mutex
39-
ir *api.SidecarIR
40-
notifyCh chan string
41+
mu sync.Mutex
42+
ir *api.SidecarIR
43+
notifyCh chan string
44+
loadedCache bool // true if startup data came from local cache
4145
}
4246

4347
// NewDaemon creates a daemon with the given config and API client.
@@ -70,7 +74,12 @@ func (d *Daemon) Run(ctx context.Context) error {
7074
if err := d.loadOrGenerate(ctx); err != nil {
7175
return fmt.Errorf("startup: %w", err)
7276
}
73-
d.writeStatus(fmt.Sprintf("ready — %s — %d nodes",
77+
78+
d.mu.Lock()
79+
stats := computeStats(d.ir, d.cache)
80+
stats.FromCache = d.loadedCache
81+
d.mu.Unlock()
82+
d.writeStatus(fmt.Sprintf("ready — %s — %d files",
7483
time.Now().Format(time.RFC3339), len(d.ir.Graph.Nodes)))
7584

7685
d.logf("[step:2] Starting listeners")
@@ -90,7 +99,7 @@ func (d *Daemon) Run(ctx context.Context) error {
9099
d.logf("[step:3] Ready — listening on UDP :%d (debounce %s)", d.cfg.NotifyPort, d.cfg.Debounce)
91100
}
92101
if d.cfg.OnReady != nil {
93-
d.cfg.OnReady()
102+
d.cfg.OnReady(stats)
94103
}
95104

96105
var (
@@ -147,6 +156,7 @@ func (d *Daemon) loadOrGenerate(ctx context.Context) error {
147156
d.ir = &ir
148157
d.cache = NewCache()
149158
d.cache.Build(&ir)
159+
d.loadedCache = true
150160
d.mu.Unlock()
151161

152162
files := d.cache.SourceFiles()
@@ -241,16 +251,20 @@ func (d *Daemon) incrementalUpdate(ctx context.Context, changedFiles []string) {
241251

242252
d.logf("Updated %d sidecars", written)
243253

244-
var nodeCount int
254+
var updateStats GraphStats
245255
func() {
246256
d.mu.Lock()
247257
defer d.mu.Unlock()
248258
d.saveCache()
249-
nodeCount = len(d.ir.Graph.Nodes)
259+
updateStats = computeStats(d.ir, d.cache)
250260
}()
251261

252-
d.writeStatus(fmt.Sprintf("ready — %s — %d nodes",
253-
time.Now().Format(time.RFC3339), nodeCount))
262+
d.writeStatus(fmt.Sprintf("ready — %s — %d files",
263+
time.Now().Format(time.RFC3339), updateStats.SourceFiles))
264+
265+
if d.cfg.OnUpdate != nil {
266+
d.cfg.OnUpdate(updateStats)
267+
}
254268
}
255269

256270
// saveCache writes the current merged SidecarIR to the cache file. Must be called with d.mu held.

internal/files/graph.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,34 @@ type Cache struct {
4545
FileDomain map[string]string // filePath → domain name
4646
}
4747

48+
// GraphStats summarises what was mapped after a generate or incremental update.
49+
type GraphStats struct {
50+
SourceFiles int
51+
Functions int
52+
Relationships int
53+
DeadFunctionCount int // functions with no callers (proxy for unreachable code)
54+
FromCache bool // true when data was loaded from a local cache
55+
}
56+
57+
// computeStats derives a GraphStats from a SidecarIR and its built Cache.
58+
func computeStats(ir *api.SidecarIR, c *Cache) GraphStats {
59+
s := GraphStats{
60+
Relationships: len(ir.Graph.Relationships),
61+
}
62+
for _, n := range ir.Graph.Nodes {
63+
switch {
64+
case n.HasLabel("File"):
65+
s.SourceFiles++
66+
case n.HasLabel("Function"):
67+
s.Functions++
68+
if len(c.Callers[n.ID]) == 0 {
69+
s.DeadFunctionCount++
70+
}
71+
}
72+
}
73+
return s
74+
}
75+
4876
// NewCache creates an empty Cache.
4977
func NewCache() *Cache {
5078
return &Cache{

internal/files/handler.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ import (
1616
"github.com/supermodeltools/cli/internal/ui"
1717
)
1818

19+
// ANSI helpers used only for watch summary output.
20+
const (
21+
ansiReset = "\033[0m"
22+
ansiBold = "\033[1m"
23+
ansiGreen = "\033[32m"
24+
ansiBGreen = "\033[1;32m"
25+
ansiDim = "\033[2m"
26+
)
27+
1928
// GenerateOptions configures the generate command.
2029
type GenerateOptions struct {
2130
Force bool
@@ -161,6 +170,31 @@ func Watch(ctx context.Context, cfg *config.Config, dir string, opts WatchOption
161170
FSWatch: opts.FSWatch,
162171
PollInterval: pollInterval,
163172
LogFunc: logf,
173+
OnReady: func(s GraphStats) {
174+
src := "fetched"
175+
if s.FromCache {
176+
src = "cached"
177+
}
178+
line := fmt.Sprintf("\n %s✓%s %s%d files%s · %s%d functions%s · %s%d relationships%s",
179+
ansiBGreen, ansiReset,
180+
ansiBold, s.SourceFiles, ansiReset,
181+
ansiBold, s.Functions, ansiReset,
182+
ansiBold, s.Relationships, ansiReset,
183+
)
184+
if s.DeadFunctionCount > 0 {
185+
line += fmt.Sprintf(" · %s%d uncalled%s", ansiBold, s.DeadFunctionCount, ansiReset)
186+
}
187+
line += fmt.Sprintf(" %s(%s)%s\n\n", ansiDim, src, ansiReset)
188+
fmt.Print(line)
189+
},
190+
OnUpdate: func(s GraphStats) {
191+
fmt.Printf(" %s✓%s Updated — %s%d files%s · %s%d functions%s · %s%d relationships%s\n",
192+
ansiGreen, ansiReset,
193+
ansiBold, s.SourceFiles, ansiReset,
194+
ansiBold, s.Functions, ansiReset,
195+
ansiBold, s.Relationships, ansiReset,
196+
)
197+
},
164198
}
165199

166200
d := NewDaemon(daemonCfg, client)

0 commit comments

Comments
 (0)