From fe97ffaf8ee641f70723299f520810a18eb2a145 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Tue, 7 Apr 2026 15:53:23 -0400 Subject: [PATCH 1/3] Show graph stats summary after watch generates initial files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/files/daemon.go | 31 +++++++++++++++++++++++-------- internal/files/graph.go | 25 +++++++++++++++++++++++++ internal/files/handler.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/internal/files/daemon.go b/internal/files/daemon.go index 1575a12..65203b8 100644 --- a/internal/files/daemon.go +++ b/internal/files/daemon.go @@ -25,7 +25,10 @@ type DaemonConfig struct { FSWatch bool PollInterval time.Duration LogFunc func(string, ...interface{}) - OnReady func() + // OnReady is called once after the initial generate completes. + OnReady func(GraphStats) + // OnUpdate is called after each incremental update completes. + OnUpdate func(GraphStats) } // Daemon watches for file changes and keeps sidecars fresh. @@ -35,9 +38,10 @@ type Daemon struct { cache *Cache logf func(string, ...interface{}) - mu sync.Mutex - ir *api.SidecarIR - notifyCh chan string + mu sync.Mutex + ir *api.SidecarIR + notifyCh chan string + loadedCache bool // true if startup data came from local cache } // NewDaemon creates a daemon with the given config and API client. @@ -70,6 +74,11 @@ func (d *Daemon) Run(ctx context.Context) error { if err := d.loadOrGenerate(ctx); err != nil { return fmt.Errorf("startup: %w", err) } + + d.mu.Lock() + stats := computeStats(d.ir, d.cache) + stats.FromCache = d.loadedCache + d.mu.Unlock() d.writeStatus(fmt.Sprintf("ready — %s — %d nodes", time.Now().Format(time.RFC3339), len(d.ir.Graph.Nodes))) @@ -90,7 +99,7 @@ func (d *Daemon) Run(ctx context.Context) error { d.logf("[step:3] Ready — listening on UDP :%d (debounce %s)", d.cfg.NotifyPort, d.cfg.Debounce) } if d.cfg.OnReady != nil { - d.cfg.OnReady() + d.cfg.OnReady(stats) } var ( @@ -147,6 +156,7 @@ func (d *Daemon) loadOrGenerate(ctx context.Context) error { d.ir = &ir d.cache = NewCache() d.cache.Build(&ir) + d.loadedCache = true d.mu.Unlock() files := d.cache.SourceFiles() @@ -164,6 +174,7 @@ func (d *Daemon) loadOrGenerate(ctx context.Context) error { return d.fullGenerate(ctx) } + // fullGenerate does a complete fetch + render. func (d *Daemon) fullGenerate(ctx context.Context) error { d.logf("Fetching full graph from Supermodel API...") @@ -241,16 +252,20 @@ func (d *Daemon) incrementalUpdate(ctx context.Context, changedFiles []string) { d.logf("Updated %d sidecars", written) - var nodeCount int + var updateStats GraphStats func() { d.mu.Lock() defer d.mu.Unlock() d.saveCache() - nodeCount = len(d.ir.Graph.Nodes) + updateStats = computeStats(d.ir, d.cache) }() d.writeStatus(fmt.Sprintf("ready — %s — %d nodes", - time.Now().Format(time.RFC3339), nodeCount)) + time.Now().Format(time.RFC3339), updateStats.SourceFiles)) + + if d.cfg.OnUpdate != nil { + d.cfg.OnUpdate(updateStats) + } } // saveCache writes the current merged SidecarIR to the cache file. Must be called with d.mu held. diff --git a/internal/files/graph.go b/internal/files/graph.go index be22f5d..dfe2191 100644 --- a/internal/files/graph.go +++ b/internal/files/graph.go @@ -45,6 +45,31 @@ type Cache struct { FileDomain map[string]string // filePath → domain name } +// GraphStats summarises what was mapped after a generate or incremental update. +type GraphStats struct { + SourceFiles int + Functions int + Relationships int + FromCache bool // true when data was loaded from a local cache +} + +// computeStats derives a GraphStats from a SidecarIR and its built Cache. +func computeStats(ir *api.SidecarIR, c *Cache) GraphStats { + s := GraphStats{ + Relationships: len(ir.Graph.Relationships), + } + for _, n := range ir.Graph.Nodes { + switch { + case n.HasLabel("File"): + s.SourceFiles++ + case n.HasLabel("Function"): + s.Functions++ + } + } + _ = c // reserved for future per-file breakdown + return s +} + // NewCache creates an empty Cache. func NewCache() *Cache { return &Cache{ diff --git a/internal/files/handler.go b/internal/files/handler.go index 63df2ac..b291dca 100644 --- a/internal/files/handler.go +++ b/internal/files/handler.go @@ -16,6 +16,15 @@ import ( "github.com/supermodeltools/cli/internal/ui" ) +// ANSI helpers used only for watch summary output. +const ( + ansiReset = "\033[0m" + ansiBold = "\033[1m" + ansiGreen = "\033[32m" + ansiBGreen = "\033[1;32m" + ansiDim = "\033[2m" +) + // GenerateOptions configures the generate command. type GenerateOptions struct { Force bool @@ -161,6 +170,27 @@ func Watch(ctx context.Context, cfg *config.Config, dir string, opts WatchOption FSWatch: opts.FSWatch, PollInterval: pollInterval, LogFunc: logf, + OnReady: func(s GraphStats) { + src := "fetched" + if s.FromCache { + src = "cached" + } + fmt.Printf("\n %s✓%s %s%d files%s · %s%d functions%s · %s%d relationships%s %s(%s)%s\n\n", + ansiBGreen, ansiReset, + ansiBold, s.SourceFiles, ansiReset, + ansiBold, s.Functions, ansiReset, + ansiBold, s.Relationships, ansiReset, + ansiDim, src, ansiReset, + ) + }, + OnUpdate: func(s GraphStats) { + fmt.Printf(" %s✓%s Updated — %s%d files%s · %s%d functions%s · %s%d relationships%s\n", + ansiGreen, ansiReset, + ansiBold, s.SourceFiles, ansiReset, + ansiBold, s.Functions, ansiReset, + ansiBold, s.Relationships, ansiReset, + ) + }, } d := NewDaemon(daemonCfg, client) From 6fa1193a353eefc2d49da4f16636308a0e225691 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Tue, 7 Apr 2026 16:51:59 -0400 Subject: [PATCH 2/3] Fix goimports: remove stray blank line in daemon.go Co-Authored-By: Claude Sonnet 4.6 --- internal/files/daemon.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/files/daemon.go b/internal/files/daemon.go index 65203b8..335188a 100644 --- a/internal/files/daemon.go +++ b/internal/files/daemon.go @@ -174,7 +174,6 @@ func (d *Daemon) loadOrGenerate(ctx context.Context) error { return d.fullGenerate(ctx) } - // fullGenerate does a complete fetch + render. func (d *Daemon) fullGenerate(ctx context.Context) error { d.logf("Fetching full graph from Supermodel API...") From 570852ac5061e201ac98a58b60544dccb5a536c4 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Tue, 7 Apr 2026 17:00:03 -0400 Subject: [PATCH 3/3] Address CodeRabbit review: dead function count, label fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/files/daemon.go | 4 ++-- internal/files/graph.go | 13 ++++++++----- internal/files/handler.go | 8 ++++++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/internal/files/daemon.go b/internal/files/daemon.go index 335188a..8cc1f74 100644 --- a/internal/files/daemon.go +++ b/internal/files/daemon.go @@ -79,7 +79,7 @@ func (d *Daemon) Run(ctx context.Context) error { stats := computeStats(d.ir, d.cache) stats.FromCache = d.loadedCache d.mu.Unlock() - d.writeStatus(fmt.Sprintf("ready — %s — %d nodes", + d.writeStatus(fmt.Sprintf("ready — %s — %d files", time.Now().Format(time.RFC3339), len(d.ir.Graph.Nodes))) d.logf("[step:2] Starting listeners") @@ -259,7 +259,7 @@ func (d *Daemon) incrementalUpdate(ctx context.Context, changedFiles []string) { updateStats = computeStats(d.ir, d.cache) }() - d.writeStatus(fmt.Sprintf("ready — %s — %d nodes", + d.writeStatus(fmt.Sprintf("ready — %s — %d files", time.Now().Format(time.RFC3339), updateStats.SourceFiles)) if d.cfg.OnUpdate != nil { diff --git a/internal/files/graph.go b/internal/files/graph.go index dfe2191..1bf3096 100644 --- a/internal/files/graph.go +++ b/internal/files/graph.go @@ -47,10 +47,11 @@ type Cache struct { // GraphStats summarises what was mapped after a generate or incremental update. type GraphStats struct { - SourceFiles int - Functions int - Relationships int - FromCache bool // true when data was loaded from a local cache + SourceFiles int + Functions int + Relationships int + DeadFunctionCount int // functions with no callers (proxy for unreachable code) + FromCache bool // true when data was loaded from a local cache } // computeStats derives a GraphStats from a SidecarIR and its built Cache. @@ -64,9 +65,11 @@ func computeStats(ir *api.SidecarIR, c *Cache) GraphStats { s.SourceFiles++ case n.HasLabel("Function"): s.Functions++ + if len(c.Callers[n.ID]) == 0 { + s.DeadFunctionCount++ + } } } - _ = c // reserved for future per-file breakdown return s } diff --git a/internal/files/handler.go b/internal/files/handler.go index b291dca..3e40897 100644 --- a/internal/files/handler.go +++ b/internal/files/handler.go @@ -175,13 +175,17 @@ func Watch(ctx context.Context, cfg *config.Config, dir string, opts WatchOption if s.FromCache { src = "cached" } - fmt.Printf("\n %s✓%s %s%d files%s · %s%d functions%s · %s%d relationships%s %s(%s)%s\n\n", + line := fmt.Sprintf("\n %s✓%s %s%d files%s · %s%d functions%s · %s%d relationships%s", ansiBGreen, ansiReset, ansiBold, s.SourceFiles, ansiReset, ansiBold, s.Functions, ansiReset, ansiBold, s.Relationships, ansiReset, - ansiDim, src, ansiReset, ) + if s.DeadFunctionCount > 0 { + line += fmt.Sprintf(" · %s%d uncalled%s", ansiBold, s.DeadFunctionCount, ansiReset) + } + line += fmt.Sprintf(" %s(%s)%s\n\n", ansiDim, src, ansiReset) + fmt.Print(line) }, OnUpdate: func(s GraphStats) { fmt.Printf(" %s✓%s Updated — %s%d files%s · %s%d functions%s · %s%d relationships%s\n",