From 1e330bb06f5ba0f4eb260cc2d874e7499dce185c Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Mon, 6 Apr 2026 17:38:08 -0400 Subject: [PATCH 1/4] feat: add sidecars vertical slice with generate/watch/clean/hook/render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the supermodel-sidecars CLI from graph-fusion into the supermodel CLI as a new `sidecars` subcommand group. Sidecars are lightweight .graph.* files placed next to each source file containing dependency, call graph, and blast-radius data for AI coding agents. New commands: supermodel sidecars generate [path] zip+upload, build cache, render all supermodel sidecars watch [path] generate on start, then daemon mode supermodel sidecars clean [path] remove all .graph.* sidecar files supermodel sidecars hook forward Claude Code PostToolUse events supermodel sidecars render [path] render from existing cache (offline) supermodel sidecars setup print quick-start instructions Architecture: - All business logic in internal/sidecars/ (package sidecars) - Only imports internal/api/, internal/config/, internal/ui/ - Cobra wiring in cmd/sidecars.go following cmd/analyze.go pattern API additions to internal/api/: - SidecarIR / SidecarGraph / SidecarDomain / SidecarSubdomain types that preserve full Node/Relationship data (IDs, labels, properties) needed for graph indexing — unlike the simplified IRNode/IRRelationship stubs - Client.AnalyzeSidecars() — full graph fetch returning SidecarIR - Client.AnalyzeIncremental() — incremental update with changedFiles field Co-Authored-By: Claude Sonnet 4.6 --- cmd/sidecars.go | 218 +++++++++++++ internal/api/client.go | 82 +++++ internal/api/types.go | 35 +++ internal/sidecars/daemon.go | 585 +++++++++++++++++++++++++++++++++++ internal/sidecars/graph.go | 309 ++++++++++++++++++ internal/sidecars/handler.go | 370 ++++++++++++++++++++++ internal/sidecars/render.go | 283 +++++++++++++++++ internal/sidecars/watcher.go | 151 +++++++++ internal/sidecars/zip.go | 303 ++++++++++++++++++ 9 files changed, 2336 insertions(+) create mode 100644 cmd/sidecars.go create mode 100644 internal/sidecars/daemon.go create mode 100644 internal/sidecars/graph.go create mode 100644 internal/sidecars/handler.go create mode 100644 internal/sidecars/render.go create mode 100644 internal/sidecars/watcher.go create mode 100644 internal/sidecars/zip.go diff --git a/cmd/sidecars.go b/cmd/sidecars.go new file mode 100644 index 0000000..80dcc71 --- /dev/null +++ b/cmd/sidecars.go @@ -0,0 +1,218 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/supermodeltools/cli/internal/config" + "github.com/supermodeltools/cli/internal/sidecars" +) + +func init() { + sidecarsCmd := &cobra.Command{ + Use: "sidecars", + Short: "Generate and manage AI-readable code graph sidecars", + Long: `Sidecars are lightweight .graph.* files placed next to each source file, +containing dependency, call graph, and blast-radius data extracted by the +Supermodel API. AI coding agents (Claude Code, Cursor, etc.) read these files +automatically to understand cross-file relationships. + +Run 'supermodel sidecars generate' to create sidecars for your repo. +Run 'supermodel sidecars watch' to keep them updated as you code.`, + } + + // --- generate --- + { + var opts sidecars.GenerateOptions + + c := &cobra.Command{ + Use: "generate [path]", + Short: "Zip, upload, and render sidecars for the repository", + Long: `Archives the repository, uploads it to the Supermodel API, builds a local +graph cache, and writes .graph.* sidecar files next to every source file. + +Subsequent runs reuse the local cache automatically unless --force is given.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + if err := cfg.RequireAPIKey(); err != nil { + return err + } + dir := "." + if len(args) > 0 { + dir = args[0] + } + return sidecars.Generate(cmd.Context(), cfg, dir, opts) + }, + } + + c.Flags().BoolVar(&opts.Force, "force", false, "re-fetch from API even if a cached graph exists") + c.Flags().BoolVar(&opts.DryRun, "dry-run", false, "show what would be written without writing") + c.Flags().StringVar(&opts.CacheFile, "cache-file", "", "override cache file location (default: /.supermodel/graph.json)") + + sidecarsCmd.AddCommand(c) + } + + // --- watch --- + { + var opts sidecars.WatchOptions + + c := &cobra.Command{ + Use: "watch [path]", + Short: "Generate sidecars on startup, then watch for file changes", + Long: `Runs a full generate on startup (using cached graph if available), then +enters daemon mode. The daemon listens for UDP notifications from the +'supermodel sidecars hook' command (or git-poll when --fs-watch is set) +and incrementally re-renders affected sidecars. + +Press Ctrl+C to stop.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + if err := cfg.RequireAPIKey(); err != nil { + return err + } + dir := "." + if len(args) > 0 { + dir = args[0] + } + return sidecars.Watch(cmd.Context(), cfg, dir, opts) + }, + } + + c.Flags().StringVar(&opts.CacheFile, "cache-file", "", "override cache file location") + c.Flags().DurationVar(&opts.Debounce, "debounce", 2*time.Second, "debounce duration before processing changes") + c.Flags().IntVar(&opts.NotifyPort, "notify-port", 7734, "UDP port to listen for hook notifications") + c.Flags().BoolVar(&opts.FSWatch, "fs-watch", false, "enable git-poll fallback for environments without hooks") + c.Flags().DurationVar(&opts.PollInterval, "poll-interval", 3*time.Second, "git poll interval when --fs-watch is enabled") + + sidecarsCmd.AddCommand(c) + } + + // --- clean --- + { + var opts sidecars.CleanOptions + + c := &cobra.Command{ + Use: "clean [path]", + Short: "Remove all .graph.* sidecar files", + Long: `Walks the directory tree and removes every generated .graph.* sidecar file.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir := "." + if len(args) > 0 { + dir = args[0] + } + return sidecars.Clean(dir, opts) + }, + } + + c.Flags().BoolVar(&opts.DryRun, "dry-run", false, "show what would be removed without removing") + + sidecarsCmd.AddCommand(c) + } + + // --- hook --- + { + var opts sidecars.HookOptions + + c := &cobra.Command{ + Use: "hook", + Short: "Forward Claude Code PostToolUse events to the watch daemon", + Long: `Reads a Claude Code PostToolUse JSON event from stdin and sends a UDP +notification to the watch daemon when a source file is written or edited. + +Configure this as a Claude Code hook in .claude/settings.json: + + { + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [{"type": "command", "command": "supermodel sidecars hook"}] + } + ] + } + }`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return sidecars.Hook(opts) + }, + } + + c.Flags().IntVar(&opts.Port, "port", 7734, "UDP port of the watch daemon") + + sidecarsCmd.AddCommand(c) + } + + // --- render --- + { + var opts sidecars.RenderOptions + + c := &cobra.Command{ + Use: "render [path]", + Short: "Render sidecars from the existing local cache (offline)", + Long: `Re-renders sidecars using the locally cached graph without fetching from +the API. Useful after a git pull or branch switch when the graph is still valid.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir := "." + if len(args) > 0 { + dir = args[0] + } + return sidecars.Render(dir, opts) + }, + } + + c.Flags().StringVar(&opts.CacheFile, "cache-file", "", "override cache file location") + c.Flags().BoolVar(&opts.DryRun, "dry-run", false, "show what would be written without writing") + + sidecarsCmd.AddCommand(c) + } + + // --- setup (stub) --- + { + c := &cobra.Command{ + Use: "setup", + Short: "Show setup instructions", + Long: `Prints a quick-start guide for configuring Supermodel sidecars.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("Supermodel Sidecars — Quick Setup") + fmt.Println() + fmt.Println("1. Authenticate:") + fmt.Println(" supermodel login") + fmt.Println() + fmt.Println("2. Generate sidecars for your repo:") + fmt.Println(" supermodel sidecars generate") + fmt.Println() + fmt.Println("3. Keep sidecars updated while coding:") + fmt.Println(" supermodel sidecars watch") + fmt.Println() + fmt.Println("4. (Optional) Add the hook to .claude/settings.json so sidecars") + fmt.Println(" update automatically when Claude Code writes files:") + fmt.Println(` {`) + fmt.Println(` "hooks": {`) + fmt.Println(` "PostToolUse": [{`) + fmt.Println(` "matcher": "Write|Edit|MultiEdit",`) + fmt.Println(` "hooks": [{"type": "command", "command": "supermodel sidecars hook"}]`) + fmt.Println(` }]`) + fmt.Println(` }`) + fmt.Println(` }`) + return nil + }, + } + + sidecarsCmd.AddCommand(c) + } + + rootCmd.AddCommand(sidecarsCmd) +} diff --git a/internal/api/client.go b/internal/api/client.go index f9d4f83..e68b326 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -107,6 +107,88 @@ func (c *Client) pollUntilComplete(ctx context.Context, zipPath, idempotencyKey return job, nil } +// AnalyzeSidecars uploads a repository ZIP and runs the full analysis pipeline, +// returning the complete SidecarIR response with full Node/Relationship data +// required for sidecar rendering (IDs, labels, properties preserved). +func (c *Client) AnalyzeSidecars(ctx context.Context, zipPath, idempotencyKey string) (*SidecarIR, error) { + job, err := c.pollUntilComplete(ctx, zipPath, idempotencyKey) + if err != nil { + return nil, err + } + var ir SidecarIR + if err := json.Unmarshal(job.Result, &ir); err != nil { + return nil, fmt.Errorf("decode sidecar result: %w", err) + } + return &ir, nil +} + +// AnalyzeIncremental uploads a zip of changed files and requests an incremental +// graph update from the API. changedFiles is sent as a form field so the server +// can scope its analysis to only those files. +func (c *Client) AnalyzeIncremental(ctx context.Context, zipPath string, changedFiles []string, idempotencyKey string) (*SidecarIR, error) { + f, err := os.Open(zipPath) + if err != nil { + return nil, err + } + defer f.Close() + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + + fw, err := mw.CreateFormFile("file", filepath.Base(zipPath)) + if err != nil { + return nil, err + } + if _, err = io.Copy(fw, f); err != nil { + return nil, err + } + + // Encode changed files as a JSON array in a form field. + changedJSON, err := json.Marshal(changedFiles) + if err != nil { + return nil, err + } + if err := mw.WriteField("changedFiles", string(changedJSON)); err != nil { + return nil, err + } + mw.Close() + + var job JobResponse + if err := c.request(ctx, http.MethodPost, analyzeEndpoint, mw.FormDataContentType(), &buf, idempotencyKey, &job); err != nil { + return nil, err + } + + // Poll until complete (reuse pollUntilComplete logic inline since we already have the first response). + for job.Status == "pending" || job.Status == "processing" { + wait := time.Duration(job.RetryAfter) * time.Second + if wait <= 0 { + wait = 5 * time.Second + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } + nextJob, err := c.postZipTo(ctx, zipPath, idempotencyKey, analyzeEndpoint) + if err != nil { + return nil, err + } + job = *nextJob + } + if job.Error != nil { + return nil, fmt.Errorf("incremental analysis failed: %s", *job.Error) + } + if job.Status != "completed" { + return nil, fmt.Errorf("unexpected job status: %s", job.Status) + } + + var ir SidecarIR + if err := json.Unmarshal(job.Result, &ir); err != nil { + return nil, fmt.Errorf("decode incremental sidecar result: %w", err) + } + return &ir, nil +} + // postZip sends the repository ZIP to the analyze endpoint and returns the // raw job response (which may be pending, processing, or completed). func (c *Client) postZip(ctx context.Context, zipPath, idempotencyKey string) (*JobResponse, error) { diff --git a/internal/api/types.go b/internal/api/types.go index 8b69b97..47b8420 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -144,6 +144,41 @@ type IRSubdomain struct { DescriptionSummary string `json:"descriptionSummary"` } +// SidecarIR is the full structured response from /v1/graphs/supermodel used +// by the sidecars vertical slice. Unlike SupermodelIR (which uses simplified +// IRNode/IRRelationship stubs), SidecarIR preserves the complete node graph +// with IDs, labels, and properties required for sidecar rendering. +type SidecarIR struct { + Repo string `json:"repo"` + Summary map[string]any `json:"summary"` + Metadata IRMetadata `json:"metadata"` + Domains []SidecarDomain `json:"domains"` + Graph SidecarGraph `json:"graph"` +} + +// SidecarGraph is the full node/relationship graph embedded in SidecarIR. +type SidecarGraph struct { + Nodes []Node `json:"nodes"` + Relationships []Relationship `json:"relationships"` +} + +// SidecarDomain is a semantic domain from the API with file references. +type SidecarDomain struct { + Name string `json:"name"` + DescriptionSummary string `json:"descriptionSummary"` + KeyFiles []string `json:"keyFiles"` + Responsibilities []string `json:"responsibilities"` + Subdomains []SidecarSubdomain `json:"subdomains"` +} + +// SidecarSubdomain is a named sub-area within a SidecarDomain. +type SidecarSubdomain struct { + Name string `json:"name"` + DescriptionSummary string `json:"descriptionSummary"` + Files []string `json:"files"` + KeyFiles []string `json:"keyFiles"` +} + // JobResponse is the async envelope returned by the API for long-running jobs. type JobResponse struct { Status string `json:"status"` diff --git a/internal/sidecars/daemon.go b/internal/sidecars/daemon.go new file mode 100644 index 0000000..d0c27be --- /dev/null +++ b/internal/sidecars/daemon.go @@ -0,0 +1,585 @@ +package sidecars + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/supermodeltools/cli/internal/api" +) + +// DaemonConfig holds watch daemon configuration. +type DaemonConfig struct { + RepoDir string + CacheFile string + Debounce time.Duration + NotifyPort int + FSWatch bool + PollInterval time.Duration + LogFunc func(string, ...interface{}) + OnReady func() +} + +// Daemon watches for file changes and keeps sidecars fresh. +type Daemon struct { + cfg DaemonConfig + client *api.Client + cache *Cache + logf func(string, ...interface{}) + + mu sync.Mutex + ir *api.SidecarIR + notifyCh chan string +} + +// NewDaemon creates a daemon with the given config and API client. +func NewDaemon(cfg DaemonConfig, client *api.Client) *Daemon { + if cfg.Debounce <= 0 { + cfg.Debounce = 2 * time.Second + } + if cfg.NotifyPort <= 0 { + cfg.NotifyPort = 7734 + } + if cfg.LogFunc == nil { + cfg.LogFunc = func(f string, a ...interface{}) { + fmt.Printf(f+"\n", a...) + } + } + return &Daemon{ + cfg: cfg, + client: client, + cache: NewCache(), + logf: cfg.LogFunc, + notifyCh: make(chan string, 256), + } +} + +// Run starts the daemon. Blocks until context is cancelled. +// Loads existing cache if available, otherwise does a full generate. +// Then waits for triggers (UDP and/or fs-watch) to perform incremental updates. +func (d *Daemon) Run(ctx context.Context) error { + d.logf("[step:1] Building code graph") + if err := d.loadOrGenerate(ctx); err != nil { + return fmt.Errorf("startup: %w", err) + } + d.writeStatus(fmt.Sprintf("ready — %s — %d nodes", + time.Now().Format(time.RFC3339), len(d.ir.Graph.Nodes))) + + d.logf("[step:2] Starting listeners") + if d.cfg.NotifyPort > 0 { + go d.listenUDP(ctx) + } + + if d.cfg.FSWatch { + w := NewWatcher(d.cfg.RepoDir, d.cfg.PollInterval) + go w.Run(ctx) + go d.forwardWatcherEvents(w) + } + + if d.cfg.FSWatch { + d.logf("[step:3] Ready — watching for file changes (git poll every %s)", d.cfg.PollInterval) + } else { + 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() + } + + var ( + pendingFiles = make(map[string]bool) + debounceTimer *time.Timer + debounceCh <-chan time.Time + ) + + for { + select { + case <-ctx.Done(): + if debounceTimer != nil { + debounceTimer.Stop() + } + d.logf("Shutting down...") + return nil + + case filePath, ok := <-d.notifyCh: + if !ok { + return nil + } + pendingFiles[filePath] = true + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.NewTimer(d.cfg.Debounce) + debounceCh = debounceTimer.C + + case <-debounceCh: + debounceCh = nil + if len(pendingFiles) == 0 { + continue + } + files := daemonSortedKeys(pendingFiles) + pendingFiles = make(map[string]bool) + d.incrementalUpdate(ctx, files) + } + } +} + +// loadOrGenerate loads an existing cache if available and re-renders sidecars. +// If no cache exists, it does a full API fetch. +func (d *Daemon) loadOrGenerate(ctx context.Context) error { + data, err := os.ReadFile(d.cfg.CacheFile) + if err == nil { + var ir api.SidecarIR + if unmarshalErr := json.Unmarshal(data, &ir); unmarshalErr != nil { + d.logf("Warning: cache file invalid, regenerating: %v", unmarshalErr) + } else if len(ir.Graph.Nodes) > 0 { + d.logf("Loaded existing cache (%d nodes, %d relationships)", + len(ir.Graph.Nodes), len(ir.Graph.Relationships)) + + d.mu.Lock() + d.ir = &ir + d.cache = NewCache() + d.cache.Build(&ir) + d.mu.Unlock() + + files := d.cache.SourceFiles() + written, renderErr := RenderAll(d.cfg.RepoDir, d.cache, files, false) + if renderErr != nil { + return renderErr + } + d.logf("Rendered %d sidecars for %d source files", written, len(files)) + return nil + } + } + + d.logf("No existing cache found — generating from scratch...") + d.writeStatus("building graph...") + 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...") + idemKey := newUUID() + + zipPath, err := CreateZipFile(d.cfg.RepoDir, nil) + if err != nil { + return fmt.Errorf("creating zip: %w", err) + } + defer os.Remove(zipPath) + + ir, err := d.client.AnalyzeSidecars(ctx, zipPath, idemKey) + if err != nil { + return err + } + + d.mu.Lock() + d.ir = ir + d.cache = NewCache() + d.cache.Build(ir) + d.saveCache() + d.mu.Unlock() + + files := d.cache.SourceFiles() + written, err := RenderAll(d.cfg.RepoDir, d.cache, files, false) + if err != nil { + return err + } + d.logf("Rendered %d sidecars for %d source files", written, len(files)) + return nil +} + +// incrementalUpdate fetches graph for only changed files and re-renders affected sidecars. +func (d *Daemon) incrementalUpdate(ctx context.Context, changedFiles []string) { + d.logf("Incremental update: %d files changed [%s]", + len(changedFiles), strings.Join(changedFiles, ", ")) + + d.writeStatus(fmt.Sprintf("updating %d files — last ready %s", + len(changedFiles), time.Now().Format(time.RFC3339))) + + idemKey := newUUID() + + zipPath, err := CreateZipFile(d.cfg.RepoDir, changedFiles) + if err != nil { + d.logf("Incremental zip error: %v", err) + return + } + defer os.Remove(zipPath) + + ir, err := d.client.AnalyzeIncremental(ctx, zipPath, changedFiles, idemKey) + if err != nil { + d.logf("Incremental API error: %v", err) + return + } + + var affected []string + var cacheSnapshot *Cache + func() { + d.mu.Lock() + defer d.mu.Unlock() + d.mergeGraph(ir, changedFiles) + d.cache = NewCache() + d.cache.Build(d.ir) + affected = d.computeAffectedFiles(changedFiles) + cacheSnapshot = d.cache + }() + + d.logf("Re-rendering %d affected sidecars", len(affected)) + + written, err := RenderAll(d.cfg.RepoDir, cacheSnapshot, affected, false) + if err != nil { + d.logf("Render error: %v", err) + return + } + + d.logf("Updated %d sidecars", written) + + var nodeCount int + func() { + d.mu.Lock() + defer d.mu.Unlock() + d.saveCache() + nodeCount = len(d.ir.Graph.Nodes) + }() + + d.writeStatus(fmt.Sprintf("ready — %s — %d nodes", + time.Now().Format(time.RFC3339), nodeCount)) +} + +// saveCache writes the current merged SidecarIR to the cache file. Must be called with d.mu held. +func (d *Daemon) saveCache() { + if d.ir == nil { + return + } + // Ensure cache directory exists + if err := os.MkdirAll(filepath.Dir(d.cfg.CacheFile), 0o755); err != nil { + d.logf("Error creating cache directory: %v", err) + return + } + cacheJSON, err := json.MarshalIndent(d.ir, "", " ") + if err != nil { + d.logf("Error marshaling cache: %v", err) + return + } + tmp := d.cfg.CacheFile + ".tmp" + if err := os.WriteFile(tmp, cacheJSON, 0o644); err != nil { + d.logf("Error writing cache: %v", err) + return + } + if err := os.Rename(tmp, d.cfg.CacheFile); err != nil { + d.logf("Error renaming cache: %v", err) + return + } + d.logf("Saved merged cache (%d nodes, %d relationships)", + len(d.ir.Graph.Nodes), len(d.ir.Graph.Relationships)) +} + +// mergeGraph integrates incremental API results into the existing SidecarIR. +func (d *Daemon) mergeGraph(incremental *api.SidecarIR, changedFiles []string) { + if d.ir == nil { + d.ir = incremental + return + } + + changedSet := make(map[string]bool, len(changedFiles)) + for _, f := range changedFiles { + changedSet[f] = true + } + + newNodeIDs := make(map[string]bool) + for _, n := range incremental.Graph.Nodes { + newNodeIDs[n.ID] = true + } + + existingFileByPath := make(map[string]string) + var existingFilePaths []struct { + path string + id string + } + + for _, n := range d.ir.Graph.Nodes { + if n.HasLabel("File") { + fp := n.Prop("filePath") + if fp != "" { + existingFileByPath[fp] = n.ID + existingFilePaths = append(existingFilePaths, struct { + path string + id string + }{fp, n.ID}) + } + } + } + + extRemap := make(map[string]string) + resolvedSet := make(map[string]bool) + + for _, n := range incremental.Graph.Nodes { + if !n.HasLabel("LocalDependency") && !n.HasLabel("ExternalDependency") { + continue + } + fp := n.Prop("filePath") + if fp == "" { + fp = n.Prop("name") + } + if fp == "" { + fp = n.Prop("importPath") + } + if fp == "" { + continue + } + + if existing, ok := existingFileByPath[fp]; ok { + extRemap[n.ID] = existing + resolvedSet[n.ID] = true + continue + } + + importPath := fp + if strings.HasPrefix(importPath, "@/") { + importPath = importPath[2:] + } else if strings.HasPrefix(importPath, "~/") { + importPath = importPath[2:] + } + + suffixes := []string{ + "/" + importPath + ".ts", + "/" + importPath + ".tsx", + "/" + importPath + ".js", + "/" + importPath + ".jsx", + "/" + importPath + "/index.ts", + "/" + importPath + "/index.js", + "/" + importPath + "/index.tsx", + "/" + importPath + ".go", + "/" + importPath + ".py", + "/" + importPath + ".rs", + } + for _, ef := range existingFilePaths { + matched := false + for _, suffix := range suffixes { + if strings.HasSuffix(ef.path, suffix) { + matched = true + break + } + } + if matched { + extRemap[n.ID] = ef.id + resolvedSet[n.ID] = true + break + } + } + } + + incFileByPath := make(map[string]string) + incFnByKey := make(map[string]string) + for _, n := range incremental.Graph.Nodes { + fp := n.Prop("filePath") + if n.HasLabel("File") && fp != "" { + incFileByPath[fp] = n.ID + } else if n.HasLabel("Function") && fp != "" { + name := n.Prop("name") + if name != "" { + incFnByKey[fp+":"+name] = n.ID + } + } + } + + oldToNew := make(map[string]string) + for _, n := range d.ir.Graph.Nodes { + fp := n.Prop("filePath") + if fp == "" { + continue + } + if n.HasLabel("File") { + if newID, ok := incFileByPath[fp]; ok && newID != n.ID { + oldToNew[n.ID] = newID + } + } else if n.HasLabel("Function") { + name := n.Prop("name") + key := fp + ":" + name + if newID, ok := incFnByKey[key]; ok && newID != n.ID { + oldToNew[n.ID] = newID + } + } + } + + var keptNodes []api.Node + for _, n := range d.ir.Graph.Nodes { + fp := n.Prop("filePath") + if fp == "" { + fp = n.Prop("path") + } + if changedSet[fp] || changedSet[n.ID] { + continue + } + if newNodeIDs[n.ID] { + continue + } + keptNodes = append(keptNodes, n) + } + + removedOldIDs := make(map[string]bool) + for oldID := range oldToNew { + removedOldIDs[oldID] = true + } + + var keptRels []api.Relationship + for _, r := range d.ir.Graph.Relationships { + startIsNew := newNodeIDs[r.StartNode] + endIsNew := newNodeIDs[r.EndNode] + if startIsNew && endIsNew { + continue + } + + if startIsNew || removedOldIDs[r.StartNode] { + continue + } + + rel := r + if newID, ok := oldToNew[rel.StartNode]; ok { + rel.StartNode = newID + } + if newID, ok := oldToNew[rel.EndNode]; ok { + rel.EndNode = newID + } + keptRels = append(keptRels, rel) + } + + var newNodes []api.Node + for _, n := range incremental.Graph.Nodes { + if resolvedSet[n.ID] { + continue + } + newNodes = append(newNodes, n) + } + + var newRels []api.Relationship + for _, r := range incremental.Graph.Relationships { + rel := r + if mapped, ok := extRemap[rel.StartNode]; ok { + rel.StartNode = mapped + } + if mapped, ok := extRemap[rel.EndNode]; ok { + rel.EndNode = mapped + } + newRels = append(newRels, rel) + } + + d.ir.Graph.Nodes = append(keptNodes, newNodes...) + d.ir.Graph.Relationships = append(keptRels, newRels...) + + if len(incremental.Domains) > 0 { + d.ir.Domains = incremental.Domains + } + + if len(extRemap) > 0 { + d.logf("Resolved %d external references to internal nodes", len(extRemap)) + } +} + +// computeAffectedFiles returns changed files plus their 1-hop dependents. +func (d *Daemon) computeAffectedFiles(changedFiles []string) []string { + affected := make(map[string]bool) + + for _, f := range changedFiles { + affected[f] = true + + for _, imp := range d.cache.Importers[f] { + affected[imp] = true + } + + for id, fn := range d.cache.FnByID { + if fn.File != f { + continue + } + for _, caller := range d.cache.Callers[id] { + if caller.File != "" { + affected[caller.File] = true + } + } + } + } + + return daemonSortedKeys(affected) +} + +func (d *Daemon) listenUDP(ctx context.Context) { + addr := fmt.Sprintf("127.0.0.1:%d", d.cfg.NotifyPort) + conn, err := net.ListenPacket("udp", addr) + if err != nil { + d.logf("UDP listener failed: %v", err) + return + } + defer conn.Close() + d.logf("UDP listener on %s", addr) + + go func() { + <-ctx.Done() + conn.SetReadDeadline(time.Now()) + }() + + buf := make([]byte, 4096) + for { + n, _, err := conn.ReadFrom(buf) + if err != nil { + return + } + filePath := strings.TrimSpace(string(buf[:n])) + if filePath != "" { + d.logf("UDP trigger: %s", filePath) + select { + case d.notifyCh <- filePath: + default: + d.logf("Notify channel full, dropping: %s", filePath) + } + } + } +} + +func (d *Daemon) writeStatus(status string) { + path := filepath.Join(d.cfg.RepoDir, ".supermodel", "status") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + if err := os.WriteFile(path, []byte(status+"\n"), 0o644); err != nil { + d.logf("Warning: could not write status file %s: %v", path, err) + } +} + +func (d *Daemon) forwardWatcherEvents(w *Watcher) { + for events := range w.Events() { + for _, ev := range events { + select { + case d.notifyCh <- ev.Path: + default: + d.logf("Notify channel full, dropping: %s", ev.Path) + } + } + } +} + +func daemonSortedKeys(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func newUUID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(fmt.Sprintf("crypto/rand unavailable: %v", err)) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/internal/sidecars/graph.go b/internal/sidecars/graph.go new file mode 100644 index 0000000..7de061e --- /dev/null +++ b/internal/sidecars/graph.go @@ -0,0 +1,309 @@ +package sidecars + +import ( + "path/filepath" + "strings" + + "github.com/supermodeltools/cli/internal/api" +) + +// SourceExtensions are the file extensions that get sidecars. +var SourceExtensions = map[string]bool{ + ".ts": true, ".js": true, ".mjs": true, ".jsx": true, ".tsx": true, + ".go": true, ".py": true, ".rb": true, ".rs": true, + ".java": true, ".cs": true, ".cpp": true, ".c": true, ".h": true, + ".swift": true, ".kt": true, +} + +// SidecarExt is the extension tag for the combined sidecar file. +const SidecarExt = "graph" + +// FuncInfo holds metadata about a function node. +type FuncInfo struct { + ID string + Name string + File string + Line int + Domain string +} + +// CallerRef is a reference to a calling/called function. +type CallerRef struct { + FuncID string + File string + Line int +} + +// Cache holds the indexed graph data. +type Cache struct { + FnByID map[string]*FuncInfo + IDToPath map[string]string + Callers map[string][]CallerRef // fnID → callers + Callees map[string][]CallerRef // fnID → callees + Imports map[string][]string // filePath → imported paths + Importers map[string][]string // filePath → importer paths + FileDomain map[string]string // filePath → domain name +} + +// NewCache creates an empty Cache. +func NewCache() *Cache { + return &Cache{ + FnByID: make(map[string]*FuncInfo), + IDToPath: make(map[string]string), + Callers: make(map[string][]CallerRef), + Callees: make(map[string][]CallerRef), + Imports: make(map[string][]string), + Importers: make(map[string][]string), + FileDomain: make(map[string]string), + } +} + +// Build populates the cache from a SidecarIR result. +// SidecarIR preserves the full Node/Relationship data (IDs, labels, properties) +// required for sidecar rendering. +func (c *Cache) Build(ir *api.SidecarIR) { + nodes := ir.Graph.Nodes + rels := ir.Graph.Relationships + + // Pass 1: index nodes + for i := range nodes { + n := nodes[i] + props := n.Properties + + if n.HasLabel("File") { + path := firstString(props, "filePath", "path", "name", n.ID) + c.IDToPath[n.ID] = path + } else if n.HasLabel("LocalDependency") { + path := firstString(props, "filePath", "name", n.ID) + c.IDToPath[n.ID] = path + } else if n.HasLabel("ExternalDependency") { + name := n.Prop("name") + if name == "" { + name = n.ID + } + c.IDToPath[n.ID] = "[ext]" + name + } + + // Any node with filePath gets registered + if fp := n.Prop("filePath"); fp != "" { + if _, ok := c.IDToPath[n.ID]; !ok { + c.IDToPath[n.ID] = fp + } + } + + if n.HasLabel("Function") { + filePath := firstString(props, "filePath", "file", "path", "") + name := n.Prop("name") + if name == "" { + // Extract from ID like "fn:src/foo.ts:bar" + parts := strings.Split(n.ID, ":") + name = parts[len(parts)-1] + } + c.FnByID[n.ID] = &FuncInfo{ + ID: n.ID, + Name: name, + File: filePath, + Line: intProp(n, "startLine"), + } + } + } + + // Pass 2: index relationships + for i := range rels { + rel := rels[i] + + switch rel.Type { + case "calls": + srcFn := c.FnByID[rel.StartNode] + dstFn := c.FnByID[rel.EndNode] + + c.Callers[rel.EndNode] = append(c.Callers[rel.EndNode], CallerRef{ + FuncID: rel.StartNode, + File: fnFile(srcFn), + Line: fnLine(srcFn), + }) + c.Callees[rel.StartNode] = append(c.Callees[rel.StartNode], CallerRef{ + FuncID: rel.EndNode, + File: fnFile(dstFn), + Line: fnLine(dstFn), + }) + + case "imports": + srcPath := c.IDToPath[rel.StartNode] + dstPath := c.IDToPath[rel.EndNode] + if strings.HasPrefix(dstPath, "[ext]") { + continue + } + if srcPath != "" && dstPath != "" { + c.Imports[srcPath] = append(c.Imports[srcPath], dstPath) + c.Importers[dstPath] = append(c.Importers[dstPath], srcPath) + } + + case "defines_function": + filePath := c.IDToPath[rel.StartNode] + if fn, ok := c.FnByID[rel.EndNode]; ok && fn.File == "" && filePath != "" { + fn.File = filePath + } + + case "belongsTo": + nodePath := c.IDToPath[rel.StartNode] + if nodePath == "" { + if fn, ok := c.FnByID[rel.StartNode]; ok { + nodePath = fn.File + } + } + if nodePath == "" { + continue + } + // Find the domain node + for j := range nodes { + if nodes[j].ID == rel.EndNode { + domainName := nodes[j].Prop("name") + if domainName == "" { + parts := strings.Split(rel.EndNode, ":") + domainName = parts[len(parts)-1] + } + c.FileDomain[nodePath] = domainName + break + } + } + } + } + + // Domain assignments from top-level domains[] array + for _, domain := range ir.Domains { + for _, kf := range domain.KeyFiles { + if _, ok := c.FileDomain[kf]; !ok { + c.FileDomain[kf] = domain.Name + } + } + for _, sub := range domain.Subdomains { + files := sub.Files + if len(files) == 0 { + files = sub.KeyFiles + } + for _, sf := range files { + if _, ok := c.FileDomain[sf]; !ok { + c.FileDomain[sf] = domain.Name + "/" + sub.Name + } + } + } + } +} + +// SourceFiles returns all source file paths known to the graph. +func (c *Cache) SourceFiles() []string { + seen := make(map[string]bool) + + for _, fn := range c.FnByID { + if fn.File != "" { + seen[fn.File] = true + } + } + for p := range c.Imports { + seen[p] = true + } + for p := range c.Importers { + seen[p] = true + } + for _, p := range c.IDToPath { + if !strings.HasPrefix(p, "[ext]") { + seen[p] = true + } + } + + var files []string + for f := range seen { + ext := strings.ToLower(filepath.Ext(f)) + if !SourceExtensions[ext] { + continue + } + if isSidecarPath(f) { + continue + } + files = append(files, f) + } + return files +} + +// FuncName returns the short name for a function ID. +func (c *Cache) FuncName(fnID string) string { + if fn, ok := c.FnByID[fnID]; ok { + return fn.Name + } + parts := strings.Split(fnID, ":") + return parts[len(parts)-1] +} + +// TransitiveDependents returns all files transitively importing the given file. +func (c *Cache) TransitiveDependents(filePath string) map[string]bool { + visited := make(map[string]bool) + c.walkImporters(filePath, visited) + delete(visited, filePath) + return visited +} + +func (c *Cache) walkImporters(filePath string, visited map[string]bool) { + if visited[filePath] { + return + } + visited[filePath] = true + for _, imp := range c.Importers[filePath] { + c.walkImporters(imp, visited) + } +} + +func isSidecarPath(name string) bool { + base := filepath.Base(name) + ext := filepath.Ext(base) + stem := strings.TrimSuffix(base, ext) + stemExt := filepath.Ext(stem) + if stemExt == "" { + return false + } + tag := strings.TrimPrefix(stemExt, ".") + return tag == SidecarExt +} + +// firstString returns the first non-empty string value from props for the given keys. +// Returns the last key as a fallback string when none match. +func firstString(props map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := props[k]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + } + return keys[len(keys)-1] +} + +// intProp returns an integer property from a node or 0. +func intProp(n api.Node, key string) int { + v, ok := n.Properties[key] + if !ok { + return 0 + } + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + default: + return 0 + } +} + +func fnFile(fn *FuncInfo) string { + if fn == nil { + return "" + } + return fn.File +} + +func fnLine(fn *FuncInfo) int { + if fn == nil { + return 0 + } + return fn.Line +} diff --git a/internal/sidecars/handler.go b/internal/sidecars/handler.go new file mode 100644 index 0000000..52ccebf --- /dev/null +++ b/internal/sidecars/handler.go @@ -0,0 +1,370 @@ +package sidecars + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" + + "github.com/supermodeltools/cli/internal/api" + "github.com/supermodeltools/cli/internal/config" + "github.com/supermodeltools/cli/internal/ui" +) + +// GenerateOptions configures the generate command. +type GenerateOptions struct { + Force bool + DryRun bool + CacheFile string +} + +// WatchOptions configures the watch command. +type WatchOptions struct { + CacheFile string + Debounce time.Duration + NotifyPort int + FSWatch bool + PollInterval time.Duration +} + +// CleanOptions configures the clean command. +type CleanOptions struct { + DryRun bool +} + +// HookOptions configures the hook command. +type HookOptions struct { + Port int +} + +// RenderOptions configures the render command. +type RenderOptions struct { + CacheFile string + DryRun bool +} + +// Generate uploads a zip, builds the graph cache, and renders all sidecars. +func Generate(ctx context.Context, cfg *config.Config, dir string, opts GenerateOptions) error { + repoDir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("resolving path: %w", err) + } + + cacheFile := opts.CacheFile + if cacheFile == "" { + cacheFile = filepath.Join(repoDir, ".supermodel", "graph.json") + } + + // Check for existing cache unless --force + if !opts.Force { + if data, err := os.ReadFile(cacheFile); err == nil { + var ir api.SidecarIR + if err := json.Unmarshal(data, &ir); err == nil && len(ir.Graph.Nodes) > 0 { + ui.Success("Using cached graph (%d nodes) — use --force to re-fetch", len(ir.Graph.Nodes)) + cache := NewCache() + cache.Build(&ir) + files := cache.SourceFiles() + spin := ui.Start("Rendering sidecars…") + written, err := RenderAll(repoDir, cache, files, opts.DryRun) + spin.Stop() + if err != nil { + return err + } + ui.Success("Wrote %d sidecars for %d source files", written, len(files)) + return updateGitignore(repoDir) + } + } + } + + spin := ui.Start("Creating repository archive…") + zipPath, err := CreateZipFile(repoDir, nil) + spin.Stop() + if err != nil { + return fmt.Errorf("create archive: %w", err) + } + defer os.Remove(zipPath) + + client := api.New(cfg) + idemKey := newUUID() + + spin = ui.Start("Uploading and analyzing repository…") + ir, err := client.AnalyzeSidecars(ctx, zipPath, "sidecars-"+idemKey[:8]) + spin.Stop() + if err != nil { + return err + } + + // Persist cache + if err := os.MkdirAll(filepath.Dir(cacheFile), 0o755); err != nil { + return fmt.Errorf("create cache dir: %w", err) + } + cacheJSON, err := json.MarshalIndent(ir, "", " ") + if err != nil { + return fmt.Errorf("marshal cache: %w", err) + } + tmp := cacheFile + ".tmp" + if err := os.WriteFile(tmp, cacheJSON, 0o644); err != nil { + return fmt.Errorf("write cache: %w", err) + } + if err := os.Rename(tmp, cacheFile); err != nil { + return fmt.Errorf("finalize cache: %w", err) + } + + cache := NewCache() + cache.Build(ir) + files := cache.SourceFiles() + + spin = ui.Start("Rendering sidecars…") + written, err := RenderAll(repoDir, cache, files, opts.DryRun) + spin.Stop() + if err != nil { + return err + } + + ui.Success("Wrote %d sidecars for %d source files (%d nodes, %d relationships)", + written, len(files), len(ir.Graph.Nodes), len(ir.Graph.Relationships)) + + return updateGitignore(repoDir) +} + +// Watch runs generate on startup, then enters daemon mode. +func Watch(ctx context.Context, cfg *config.Config, dir string, opts WatchOptions) error { + repoDir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("resolving path: %w", err) + } + + cacheFile := opts.CacheFile + if cacheFile == "" { + cacheFile = filepath.Join(repoDir, ".supermodel", "graph.json") + } + + client := api.New(cfg) + + debounce := opts.Debounce + if debounce <= 0 { + debounce = 2 * time.Second + } + pollInterval := opts.PollInterval + if pollInterval <= 0 { + pollInterval = 3 * time.Second + } + notifyPort := opts.NotifyPort + if notifyPort <= 0 { + notifyPort = 7734 + } + + logf := func(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "[sidecars] "+format+"\n", args...) + } + + daemonCfg := DaemonConfig{ + RepoDir: repoDir, + CacheFile: cacheFile, + Debounce: debounce, + NotifyPort: notifyPort, + FSWatch: opts.FSWatch, + PollInterval: pollInterval, + LogFunc: logf, + } + + d := NewDaemon(daemonCfg, client) + return d.Run(ctx) +} + +// Clean removes all .graph.* sidecar files from the directory tree. +func Clean(dir string, opts CleanOptions) error { + repoDir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("resolving path: %w", err) + } + + var removed int + err = filepath.Walk(repoDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + name := info.Name() + // Skip hidden dirs and common build dirs + if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" { + return filepath.SkipDir + } + return nil + } + if !isSidecarFile(info.Name()) { + return nil + } + if opts.DryRun { + fmt.Printf(" [dry-run] would remove %s\n", path) + removed++ + return nil + } + if err := os.Remove(path); err != nil { + fmt.Fprintf(os.Stderr, "warning: remove %s: %v\n", path, err) + return nil + } + removed++ + return nil + }) + if err != nil { + return err + } + + if opts.DryRun { + fmt.Printf("Would remove %d sidecar files\n", removed) + } else { + fmt.Printf("Removed %d sidecar files\n", removed) + } + return nil +} + +// postToolUseEvent is the Claude Code PostToolUse hook payload. +type postToolUseEvent struct { + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` + ToolResult json.RawMessage `json:"tool_result"` +} + +// toolInput captures the file_path from Write/Edit tool inputs. +type toolInput struct { + FilePath string `json:"file_path"` + Path string `json:"path"` +} + +// Hook reads a Claude Code PostToolUse JSON event from stdin and sends a UDP +// notification to the watch daemon for any source file written or edited. +func Hook(opts HookOptions) error { + port := opts.Port + if port <= 0 { + port = 7734 + } + + scanner := bufio.NewScanner(os.Stdin) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("reading stdin: %w", err) + } + + raw := strings.Join(lines, "\n") + + var ev postToolUseEvent + if err := json.Unmarshal([]byte(raw), &ev); err != nil { + // Not a valid event — silently exit (hooks must not break the agent) + return nil + } + + // Only handle write/edit-type tools + name := strings.ToLower(ev.ToolName) + if name != "write" && name != "edit" && name != "multiedit" && + name != "writefile" && name != "editfile" { + return nil + } + + var input toolInput + if err := json.Unmarshal(ev.ToolInput, &input); err != nil { + return nil + } + + filePath := input.FilePath + if filePath == "" { + filePath = input.Path + } + if filePath == "" { + return nil + } + + // Only notify for source files, not sidecars + ext := strings.ToLower(filepath.Ext(filePath)) + if !SourceExtensions[ext] || isSidecarPath(filePath) { + return nil + } + + addr := fmt.Sprintf("127.0.0.1:%d", port) + conn, err := net.Dial("udp", addr) + if err != nil { + // Daemon not running — silently exit + return nil + } + defer conn.Close() + conn.SetWriteDeadline(time.Now().Add(500 * time.Millisecond)) + _, _ = conn.Write([]byte(filePath)) + return nil +} + +// Render renders sidecars from the existing cache without fetching from the API. +func Render(dir string, opts RenderOptions) error { + repoDir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("resolving path: %w", err) + } + + cacheFile := opts.CacheFile + if cacheFile == "" { + cacheFile = filepath.Join(repoDir, ".supermodel", "graph.json") + } + + data, err := os.ReadFile(cacheFile) + if err != nil { + return fmt.Errorf("reading cache %s: %w (run `supermodel sidecars generate` first)", cacheFile, err) + } + + var ir api.SidecarIR + if err := json.Unmarshal(data, &ir); err != nil { + return fmt.Errorf("parsing cache: %w", err) + } + + cache := NewCache() + cache.Build(&ir) + files := cache.SourceFiles() + + written, err := RenderAll(repoDir, cache, files, opts.DryRun) + if err != nil { + return err + } + + if opts.DryRun { + fmt.Printf("Would write %d sidecars for %d source files\n", written, len(files)) + } else { + ui.Success("Wrote %d sidecars for %d source files", written, len(files)) + } + return nil +} + +// updateGitignore ensures .supermodel/ is in the repo's .gitignore. +func updateGitignore(repoDir string) error { + gitignorePath := filepath.Join(repoDir, ".gitignore") + const entry = ".supermodel/" + + data, err := os.ReadFile(gitignorePath) + if err != nil && !os.IsNotExist(err) { + return nil // can't read, skip silently + } + + content := string(data) + for _, line := range strings.Split(content, "\n") { + if strings.TrimSpace(line) == entry || strings.TrimSpace(line) == ".supermodel" { + return nil // already present + } + } + + f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return nil // can't write, skip silently + } + defer f.Close() + + if len(content) > 0 && !strings.HasSuffix(content, "\n") { + fmt.Fprintln(f) + } + fmt.Fprintln(f, entry) + return nil +} diff --git a/internal/sidecars/render.go b/internal/sidecars/render.go new file mode 100644 index 0000000..9d2b29f --- /dev/null +++ b/internal/sidecars/render.go @@ -0,0 +1,283 @@ +package sidecars + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// CommentPrefix returns the language-appropriate comment prefix. +func CommentPrefix(ext string) string { + switch ext { + case ".py", ".rb": + return "#" + default: + return "//" + } +} + +// SidecarFilename generates the .graph sidecar path. +// Example: "src/Foo.tsx" → "src/Foo.graph.tsx" +func SidecarFilename(sourcePath string) string { + ext := filepath.Ext(sourcePath) + stem := strings.TrimSuffix(sourcePath, ext) + return stem + ".graph" + ext +} + +// Header returns the @generated header line. +func Header(prefix string) string { + return prefix + " @generated supermodel-sidecar — do not edit\n" +} + +// RenderGraph produces a combined .graph sidecar with deps, calls, and impact sections. +func RenderGraph(filePath string, cache *Cache, prefix string) string { + deps := renderDepsSection(filePath, cache, prefix) + calls := renderCallsSection(filePath, cache, prefix) + impact := renderImpactSection(filePath, cache, prefix) + + var sections []string + if deps != "" { + sections = append(sections, deps) + } + if calls != "" { + sections = append(sections, calls) + } + if impact != "" { + sections = append(sections, impact) + } + + if len(sections) == 0 { + return "" + } + return strings.Join(sections, "\n") + "\n" +} + +func renderCallsSection(filePath string, cache *Cache, prefix string) string { + type fnEntry struct { + id string + name string + } + var fns []fnEntry + for id, fn := range cache.FnByID { + if fn.File == filePath { + fns = append(fns, fnEntry{id, fn.Name}) + } + } + if len(fns) == 0 { + return "" + } + + sort.Slice(fns, func(i, j int) bool { return fns[i].name < fns[j].name }) + + var lines []string + lines = append(lines, fmt.Sprintf("%s [calls]", prefix)) + for _, fe := range fns { + for _, caller := range cache.Callers[fe.id] { + callerName := cache.FuncName(caller.FuncID) + loc := formatLoc(caller.File, caller.Line) + lines = append(lines, fmt.Sprintf("%s %s ← %s %s", prefix, fe.name, callerName, loc)) + } + for _, callee := range cache.Callees[fe.id] { + calleeName := cache.FuncName(callee.FuncID) + loc := formatLoc(callee.File, callee.Line) + lines = append(lines, fmt.Sprintf("%s %s → %s %s", prefix, fe.name, calleeName, loc)) + } + } + + if len(lines) == 1 { // only the header + return "" + } + return strings.Join(lines, "\n") +} + +func renderDepsSection(filePath string, cache *Cache, prefix string) string { + imported := cache.Imports[filePath] + importedBy := cache.Importers[filePath] + + if len(imported) == 0 && len(importedBy) == 0 { + return "" + } + + var lines []string + lines = append(lines, fmt.Sprintf("%s [deps]", prefix)) + for _, imp := range sortedUnique(imported) { + lines = append(lines, fmt.Sprintf("%s imports %s", prefix, imp)) + } + for _, imp := range sortedUnique(importedBy) { + lines = append(lines, fmt.Sprintf("%s imported-by %s", prefix, imp)) + } + + return strings.Join(lines, "\n") +} + +func renderImpactSection(filePath string, cache *Cache, prefix string) string { + directImporters := cache.Importers[filePath] + directCallerFiles := make(map[string]bool) + + for id, fn := range cache.FnByID { + if fn.File != filePath { + continue + } + for _, caller := range cache.Callers[id] { + if caller.File != "" && caller.File != filePath { + directCallerFiles[caller.File] = true + } + } + } + + directFiles := make(map[string]bool) + for _, f := range directImporters { + directFiles[f] = true + } + for f := range directCallerFiles { + directFiles[f] = true + } + directCount := len(directFiles) + + transitive := cache.TransitiveDependents(filePath) + transitiveCount := len(transitive) + + if directCount == 0 && transitiveCount == 0 { + return "" + } + + domains := make(map[string]bool) + if d := cache.FileDomain[filePath]; d != "" { + domains[d] = true + } + allAffected := make(map[string]bool) + for f := range directFiles { + allAffected[f] = true + } + for f := range transitive { + allAffected[f] = true + } + for f := range allAffected { + if d := cache.FileDomain[f]; d != "" { + domains[d] = true + } + } + + var risk string + switch { + case transitiveCount > 20 || len(domains) > 2: + risk = "HIGH" + case transitiveCount > 5 || len(domains) > 1: + risk = "MEDIUM" + default: + risk = "LOW" + } + + var lines []string + lines = append(lines, fmt.Sprintf("%s [impact]", prefix)) + lines = append(lines, fmt.Sprintf("%s risk %s", prefix, risk)) + if len(domains) > 0 { + lines = append(lines, fmt.Sprintf("%s domains %s", prefix, strings.Join(sortedBoolKeys(domains), " · "))) + } + lines = append(lines, fmt.Sprintf("%s direct %d", prefix, directCount)) + lines = append(lines, fmt.Sprintf("%s transitive %d", prefix, transitiveCount)) + if directCount > 0 { + lines = append(lines, fmt.Sprintf("%s affects %s", prefix, strings.Join(sortedBoolKeys(directFiles), " · "))) + } + + return strings.Join(lines, "\n") +} + +// WriteSidecar writes a sidecar file with path traversal protection. +func WriteSidecar(repoDir, sidecarPath, content string, dryRun bool) error { + full, err := filepath.Abs(filepath.Join(repoDir, sidecarPath)) + if err != nil { + return err + } + repoAbs, err := filepath.Abs(repoDir) + if err != nil { + return err + } + if !strings.HasPrefix(full, repoAbs+string(filepath.Separator)) && full != repoAbs { + return fmt.Errorf("path traversal blocked: %s", sidecarPath) + } + + if dryRun { + fmt.Printf(" [dry-run] would write %s\n", full) + return nil + } + + dir := filepath.Dir(full) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + tmp := full + ".tmp" + if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { + return err + } + return os.Rename(tmp, full) +} + +// RenderAll generates and writes .graph sidecars for the given source files. +// Returns the count of sidecars written. +func RenderAll(repoDir string, cache *Cache, files []string, dryRun bool) (int, error) { + sort.Strings(files) + written := 0 + + for _, srcFile := range files { + ext := filepath.Ext(srcFile) + prefix := CommentPrefix(ext) + header := Header(prefix) + + content := RenderGraph(srcFile, cache, prefix) + if content == "" { + continue + } + + fullContent := header + content + if ext == ".go" { + fullContent = "//go:build ignore\n\npackage ignore\n" + fullContent + } + + scPath := SidecarFilename(srcFile) + if err := WriteSidecar(repoDir, scPath, fullContent, dryRun); err != nil { + if strings.Contains(err.Error(), "path traversal") { + continue + } + return written, err + } + written++ + } + + return written, nil +} + +func formatLoc(file string, line int) string { + if file != "" && line > 0 { + return fmt.Sprintf("%s:%d", file, line) + } + if file != "" { + return file + } + return "?" +} + +func sortedUnique(ss []string) []string { + seen := make(map[string]bool, len(ss)) + var out []string + for _, s := range ss { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + sort.Strings(out) + return out +} + +func sortedBoolKeys(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/sidecars/watcher.go b/internal/sidecars/watcher.go new file mode 100644 index 0000000..c21831c --- /dev/null +++ b/internal/sidecars/watcher.go @@ -0,0 +1,151 @@ +package sidecars + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +// WatchEvent represents a detected file change. +type WatchEvent struct { + Path string + Time time.Time +} + +// Watcher detects source file changes using git as the source of truth. +type Watcher struct { + repoDir string + pollInterval time.Duration + + mu sync.Mutex + lastKnownFiles map[string]struct{} + lastIndexMod time.Time + + eventCh chan []WatchEvent +} + +// NewWatcher creates a watcher for the given repo directory. +func NewWatcher(repoDir string, pollInterval time.Duration) *Watcher { + if pollInterval <= 0 { + pollInterval = 3 * time.Second + } + return &Watcher{ + repoDir: repoDir, + pollInterval: pollInterval, + lastKnownFiles: make(map[string]struct{}), + eventCh: make(chan []WatchEvent, 16), + } +} + +// Events returns the channel that receives batches of change events. +func (w *Watcher) Events() <-chan []WatchEvent { + return w.eventCh +} + +// Run starts the watcher loop. It blocks until the context is cancelled. +func (w *Watcher) Run(ctx context.Context) { + ticker := time.NewTicker(w.pollInterval) + defer ticker.Stop() + defer close(w.eventCh) + + w.lastIndexMod = w.gitIndexMtime() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + w.poll() + } + } +} + +func (w *Watcher) poll() { + indexMod := w.gitIndexMtime() + indexChanged := indexMod != w.lastIndexMod + if indexChanged { + w.lastIndexMod = indexMod + } + + currentDirty := w.gitDirtyFiles() + + w.mu.Lock() + defer w.mu.Unlock() + + var newEvents []WatchEvent + now := time.Now() + for f := range currentDirty { + if _, known := w.lastKnownFiles[f]; !known { + newEvents = append(newEvents, WatchEvent{Path: f, Time: now}) + } + } + + if indexChanged { + for f := range w.lastKnownFiles { + if _, stillDirty := currentDirty[f]; !stillDirty { + newEvents = append(newEvents, WatchEvent{Path: f, Time: now}) + } + } + } + + w.lastKnownFiles = currentDirty + + if len(newEvents) > 0 { + w.eventCh <- newEvents + } +} + +func (w *Watcher) gitDirtyFiles() map[string]struct{} { + files := make(map[string]struct{}) + + out := w.runGit("diff", "--name-only", "HEAD") + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if isWatchSourceFile(line) { + files[line] = struct{}{} + } + } + + out = w.runGit("ls-files", "--others", "--exclude-standard") + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if isWatchSourceFile(line) { + files[line] = struct{}{} + } + } + + return files +} + +func (w *Watcher) gitIndexMtime() time.Time { + info, err := os.Stat(filepath.Join(w.repoDir, ".git", "index")) + if err != nil { + return time.Time{} + } + return info.ModTime() +} + +func (w *Watcher) runGit(args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = w.repoDir + out, _ := cmd.Output() + return string(out) +} + +func isWatchSourceFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + if !SourceExtensions[ext] { + return false + } + return !isSidecarPath(path) +} diff --git a/internal/sidecars/zip.go b/internal/sidecars/zip.go new file mode 100644 index 0000000..0ac9476 --- /dev/null +++ b/internal/sidecars/zip.go @@ -0,0 +1,303 @@ +package sidecars + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// Security-critical: files/dirs that must never be zipped. +var hardBlocked = map[string]bool{ + ".aws": true, ".ssh": true, ".gnupg": true, ".terraform": true, +} + +var hardBlockedPatterns = []string{ + ".env", "*.key", "*.pem", "*.p12", "*.pfx", "*.crt", "*.cert", + "*.tfstate", "*.tfstate.backup", "*secret*", "*credential*", "*password*", +} + +var zipSkipDirs = map[string]bool{ + ".git": true, "node_modules": true, "vendor": true, ".venv": true, + "venv": true, "__pycache__": true, "dist": true, "build": true, + ".next": true, ".nuxt": true, ".cache": true, ".turbo": true, + "coverage": true, ".nyc_output": true, "__snapshots__": true, + ".terraform": true, +} + +var zipSkipFiles = map[string]bool{ + "package-lock.json": true, "yarn.lock": true, "pnpm-lock.yaml": true, + "bun.lockb": true, "Gemfile.lock": true, "poetry.lock": true, + "go.sum": true, "Cargo.lock": true, +} + +var zipSkipExtensions = map[string]bool{ + ".min.js": true, ".min.css": true, ".bundle.js": true, ".map": true, + ".ico": true, ".woff": true, ".woff2": true, ".ttf": true, + ".eot": true, ".otf": true, ".mp4": true, ".mp3": true, + ".wav": true, ".png": true, ".jpg": true, ".jpeg": true, + ".gif": true, ".webp": true, +} + +const maxFileSize = 500 * 1024 // 500KB + +// loadCustomExclusions reads .supermodel.json from repoDir and adds any +// custom exclude_dirs and exclude_exts to the skip lists. +func loadCustomExclusions(repoDir string) { + cfgPath := filepath.Join(repoDir, ".supermodel.json") + if abs, err := filepath.Abs(cfgPath); err == nil { + cfgPath = abs + } + data, err := os.ReadFile(cfgPath) + if err != nil { + return + } + var cfg struct { + ExcludeDirs []string `json:"exclude_dirs"` + ExcludeExts []string `json:"exclude_exts"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to parse %s: %v — custom exclusions will be ignored\n", + cfgPath, err) + return + } + for _, d := range cfg.ExcludeDirs { + zipSkipDirs[d] = true + } + for _, e := range cfg.ExcludeExts { + zipSkipExtensions[e] = true + } +} + +// matchPattern does simple glob matching (*, ?). +func matchPattern(pattern, name string) bool { + pattern = strings.ToLower(pattern) + name = strings.ToLower(name) + + if !strings.ContainsAny(pattern, "*?") { + return strings.Contains(name, pattern) + } + + parts := strings.Split(pattern, "*") + if len(parts) == 1 { + return name == pattern + } + + if parts[0] != "" && !strings.HasPrefix(name, parts[0]) { + return false + } + last := parts[len(parts)-1] + if last != "" && !strings.HasSuffix(name, last) { + return false + } + remaining := name + for _, part := range parts { + if part == "" { + continue + } + idx := strings.Index(remaining, part) + if idx < 0 { + return false + } + remaining = remaining[idx+len(part):] + } + return true +} + +func shouldInclude(relPath string, fileSize int64) bool { + parts := strings.Split(filepath.ToSlash(relPath), "/") + + for _, part := range parts[:len(parts)-1] { + if zipSkipDirs[part] || hardBlocked[part] { + return false + } + if strings.HasPrefix(part, ".") { + return false + } + } + + filename := parts[len(parts)-1] + + if isSidecarFile(filename) { + return false + } + + for _, pat := range hardBlockedPatterns { + if matchPattern(pat, filename) { + return false + } + } + + if zipSkipFiles[filename] { + return false + } + + ext := strings.ToLower(filepath.Ext(filename)) + if zipSkipExtensions[ext] { + return false + } + + if strings.HasSuffix(filename, ".min.js") || strings.HasSuffix(filename, ".min.css") { + return false + } + + if fileSize > maxFileSize { + return false + } + + return true +} + +// CreateZipFile creates a zip archive of the repo directory, respecting filters, +// writes it to a temporary file, and returns the path. The caller is responsible +// for removing the file. +// If onlyFiles is non-nil, only those relative paths are included (incremental mode). +func CreateZipFile(repoDir string, onlyFiles []string) (string, error) { + loadCustomExclusions(repoDir) + + f, err := os.CreateTemp("", "supermodel-sidecars-*.zip") + if err != nil { + return "", fmt.Errorf("create temp zip: %w", err) + } + dest := f.Name() + + zw := zip.NewWriter(f) + + if onlyFiles != nil { + for _, rel := range onlyFiles { + full := filepath.Join(repoDir, rel) + info, err := os.Lstat(full) + if err != nil { + continue + } + if info.Mode()&os.ModeSymlink != 0 { + continue + } + if !shouldInclude(rel, info.Size()) { + continue + } + if err := addFileToZip(zw, full, rel); err != nil { + zw.Close() + f.Close() + os.Remove(dest) + return "", err + } + } + } else { + err = filepath.Walk(repoDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + rel, _ := filepath.Rel(repoDir, path) + if rel == "." { + return nil + } + + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + + if info.IsDir() { + name := info.Name() + if zipSkipDirs[name] || hardBlocked[name] || strings.HasPrefix(name, ".") { + return filepath.SkipDir + } + return nil + } + + if !shouldInclude(rel, info.Size()) { + return nil + } + + return addFileToZip(zw, path, rel) + }) + if err != nil { + zw.Close() + f.Close() + os.Remove(dest) + return "", err + } + } + + if err := zw.Close(); err != nil { + f.Close() + os.Remove(dest) + return "", err + } + if err := f.Close(); err != nil { + os.Remove(dest) + return "", err + } + return dest, nil +} + +// DryRunList returns the list of files that would be included in the zip. +func DryRunList(repoDir string) ([]string, error) { + loadCustomExclusions(repoDir) + var files []string + + err := filepath.Walk(repoDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + rel, _ := filepath.Rel(repoDir, path) + if rel == "." { + return nil + } + + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + + if info.IsDir() { + name := info.Name() + if zipSkipDirs[name] || hardBlocked[name] || strings.HasPrefix(name, ".") { + return filepath.SkipDir + } + return nil + } + + if shouldInclude(rel, info.Size()) { + files = append(files, rel) + } + return nil + }) + + return files, err +} + +// isSidecarFile checks if a filename is a generated sidecar (e.g. foo.graph.ts). +func isSidecarFile(filename string) bool { + ext := filepath.Ext(filename) + if ext == "" { + return false + } + stem := strings.TrimSuffix(filename, ext) + tag := filepath.Ext(stem) + if tag == "" { + return false + } + tag = strings.TrimPrefix(tag, ".") + return tag == SidecarExt +} + +func addFileToZip(w *zip.Writer, fullPath, relPath string) error { + f, err := os.Open(fullPath) + if err != nil { + return err + } + defer f.Close() + + zw, err := w.Create(filepath.ToSlash(relPath)) + if err != nil { + return err + } + + _, err = io.Copy(zw, f) + return err +} From c97b1bd3a7c0db100fa9a2bceed9ef388c840fa7 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Tue, 7 Apr 2026 13:26:26 -0400 Subject: [PATCH 2/4] refactor: replace sidecars subcommand with integrated file mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - supermodel analyze now writes .graph files by default (--no-files to opt out) - new top-level: watch, clean, hook - internal/sidecars → internal/files - files: false in config disables file mode globally Co-Authored-By: Claude Sonnet 4.6 --- cmd/analyze.go | 16 +- cmd/clean.go | 32 ++++ cmd/hook.go | 24 +++ cmd/sidecars.go | 218 ------------------------ cmd/watch.go | 47 +++++ internal/config/config.go | 14 ++ internal/{sidecars => files}/daemon.go | 2 +- internal/{sidecars => files}/graph.go | 2 +- internal/{sidecars => files}/handler.go | 25 +-- internal/{sidecars => files}/render.go | 2 +- internal/{sidecars => files}/watcher.go | 2 +- internal/{sidecars => files}/zip.go | 2 +- 12 files changed, 143 insertions(+), 243 deletions(-) create mode 100644 cmd/clean.go create mode 100644 cmd/hook.go delete mode 100644 cmd/sidecars.go create mode 100644 cmd/watch.go rename internal/{sidecars => files}/daemon.go (99%) rename internal/{sidecars => files}/graph.go (99%) rename internal/{sidecars => files}/handler.go (94%) rename internal/{sidecars => files}/render.go (99%) rename internal/{sidecars => files}/watcher.go (99%) rename internal/{sidecars => files}/zip.go (99%) diff --git a/cmd/analyze.go b/cmd/analyze.go index 73bb92e..21b9f66 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -5,10 +5,12 @@ import ( "github.com/supermodeltools/cli/internal/analyze" "github.com/supermodeltools/cli/internal/config" + "github.com/supermodeltools/cli/internal/files" ) func init() { var opts analyze.Options + var noFiles bool c := &cobra.Command{ Use: "analyze [path]", @@ -17,7 +19,10 @@ func init() { call graph generation, dependency analysis, and domain classification. Results are cached locally by content hash. Subsequent commands -(dead-code, blast-radius, graph) reuse the cache automatically.`, +(dead-code, blast-radius, graph) reuse the cache automatically. + +By default, .graph.* sidecar files are written next to each source file. +Use --no-files to skip writing graph files.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() @@ -31,12 +36,19 @@ Results are cached locally by content hash. Subsequent commands if len(args) > 0 { dir = args[0] } - return analyze.Run(cmd.Context(), cfg, dir, opts) + if err := analyze.Run(cmd.Context(), cfg, dir, opts); err != nil { + return err + } + if cfg.FilesEnabled() && !noFiles { + return files.Generate(cmd.Context(), cfg, dir, files.GenerateOptions{}) + } + return nil }, } c.Flags().BoolVar(&opts.Force, "force", false, "re-analyze even if a cached result exists") c.Flags().StringVarP(&opts.Output, "output", "o", "", "output format: human|json") + c.Flags().BoolVar(&noFiles, "no-files", false, "skip writing .graph.* sidecar files") rootCmd.AddCommand(c) } diff --git a/cmd/clean.go b/cmd/clean.go new file mode 100644 index 0000000..f9e56d3 --- /dev/null +++ b/cmd/clean.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/supermodeltools/cli/internal/config" + "github.com/supermodeltools/cli/internal/files" +) + +func init() { + var dryRun bool + + c := &cobra.Command{ + Use: "clean [path]", + Short: "Remove all .graph.* files from the repository", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + dir := "." + if len(args) > 0 { + dir = args[0] + } + return files.Clean(cmd.Context(), cfg, dir, dryRun) + }, + } + + c.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be removed without removing") + rootCmd.AddCommand(c) +} diff --git a/cmd/hook.go b/cmd/hook.go new file mode 100644 index 0000000..25611f9 --- /dev/null +++ b/cmd/hook.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/supermodeltools/cli/internal/files" +) + +func init() { + var port int + + c := &cobra.Command{ + Use: "hook", + Short: "Forward Claude Code file-change events to the watch daemon", + Long: `Reads a Claude Code PostToolUse JSON payload from stdin and forwards the file path to the running watch daemon via UDP. Install as a PostToolUse hook in .claude/settings.json.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return files.Hook(port) + }, + } + + c.Flags().IntVar(&port, "port", 7734, "UDP port of the watch daemon") + rootCmd.AddCommand(c) +} diff --git a/cmd/sidecars.go b/cmd/sidecars.go deleted file mode 100644 index 80dcc71..0000000 --- a/cmd/sidecars.go +++ /dev/null @@ -1,218 +0,0 @@ -package cmd - -import ( - "fmt" - "time" - - "github.com/spf13/cobra" - - "github.com/supermodeltools/cli/internal/config" - "github.com/supermodeltools/cli/internal/sidecars" -) - -func init() { - sidecarsCmd := &cobra.Command{ - Use: "sidecars", - Short: "Generate and manage AI-readable code graph sidecars", - Long: `Sidecars are lightweight .graph.* files placed next to each source file, -containing dependency, call graph, and blast-radius data extracted by the -Supermodel API. AI coding agents (Claude Code, Cursor, etc.) read these files -automatically to understand cross-file relationships. - -Run 'supermodel sidecars generate' to create sidecars for your repo. -Run 'supermodel sidecars watch' to keep them updated as you code.`, - } - - // --- generate --- - { - var opts sidecars.GenerateOptions - - c := &cobra.Command{ - Use: "generate [path]", - Short: "Zip, upload, and render sidecars for the repository", - Long: `Archives the repository, uploads it to the Supermodel API, builds a local -graph cache, and writes .graph.* sidecar files next to every source file. - -Subsequent runs reuse the local cache automatically unless --force is given.`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := config.Load() - if err != nil { - return err - } - if err := cfg.RequireAPIKey(); err != nil { - return err - } - dir := "." - if len(args) > 0 { - dir = args[0] - } - return sidecars.Generate(cmd.Context(), cfg, dir, opts) - }, - } - - c.Flags().BoolVar(&opts.Force, "force", false, "re-fetch from API even if a cached graph exists") - c.Flags().BoolVar(&opts.DryRun, "dry-run", false, "show what would be written without writing") - c.Flags().StringVar(&opts.CacheFile, "cache-file", "", "override cache file location (default: /.supermodel/graph.json)") - - sidecarsCmd.AddCommand(c) - } - - // --- watch --- - { - var opts sidecars.WatchOptions - - c := &cobra.Command{ - Use: "watch [path]", - Short: "Generate sidecars on startup, then watch for file changes", - Long: `Runs a full generate on startup (using cached graph if available), then -enters daemon mode. The daemon listens for UDP notifications from the -'supermodel sidecars hook' command (or git-poll when --fs-watch is set) -and incrementally re-renders affected sidecars. - -Press Ctrl+C to stop.`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := config.Load() - if err != nil { - return err - } - if err := cfg.RequireAPIKey(); err != nil { - return err - } - dir := "." - if len(args) > 0 { - dir = args[0] - } - return sidecars.Watch(cmd.Context(), cfg, dir, opts) - }, - } - - c.Flags().StringVar(&opts.CacheFile, "cache-file", "", "override cache file location") - c.Flags().DurationVar(&opts.Debounce, "debounce", 2*time.Second, "debounce duration before processing changes") - c.Flags().IntVar(&opts.NotifyPort, "notify-port", 7734, "UDP port to listen for hook notifications") - c.Flags().BoolVar(&opts.FSWatch, "fs-watch", false, "enable git-poll fallback for environments without hooks") - c.Flags().DurationVar(&opts.PollInterval, "poll-interval", 3*time.Second, "git poll interval when --fs-watch is enabled") - - sidecarsCmd.AddCommand(c) - } - - // --- clean --- - { - var opts sidecars.CleanOptions - - c := &cobra.Command{ - Use: "clean [path]", - Short: "Remove all .graph.* sidecar files", - Long: `Walks the directory tree and removes every generated .graph.* sidecar file.`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - dir := "." - if len(args) > 0 { - dir = args[0] - } - return sidecars.Clean(dir, opts) - }, - } - - c.Flags().BoolVar(&opts.DryRun, "dry-run", false, "show what would be removed without removing") - - sidecarsCmd.AddCommand(c) - } - - // --- hook --- - { - var opts sidecars.HookOptions - - c := &cobra.Command{ - Use: "hook", - Short: "Forward Claude Code PostToolUse events to the watch daemon", - Long: `Reads a Claude Code PostToolUse JSON event from stdin and sends a UDP -notification to the watch daemon when a source file is written or edited. - -Configure this as a Claude Code hook in .claude/settings.json: - - { - "hooks": { - "PostToolUse": [ - { - "matcher": "Write|Edit|MultiEdit", - "hooks": [{"type": "command", "command": "supermodel sidecars hook"}] - } - ] - } - }`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - return sidecars.Hook(opts) - }, - } - - c.Flags().IntVar(&opts.Port, "port", 7734, "UDP port of the watch daemon") - - sidecarsCmd.AddCommand(c) - } - - // --- render --- - { - var opts sidecars.RenderOptions - - c := &cobra.Command{ - Use: "render [path]", - Short: "Render sidecars from the existing local cache (offline)", - Long: `Re-renders sidecars using the locally cached graph without fetching from -the API. Useful after a git pull or branch switch when the graph is still valid.`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - dir := "." - if len(args) > 0 { - dir = args[0] - } - return sidecars.Render(dir, opts) - }, - } - - c.Flags().StringVar(&opts.CacheFile, "cache-file", "", "override cache file location") - c.Flags().BoolVar(&opts.DryRun, "dry-run", false, "show what would be written without writing") - - sidecarsCmd.AddCommand(c) - } - - // --- setup (stub) --- - { - c := &cobra.Command{ - Use: "setup", - Short: "Show setup instructions", - Long: `Prints a quick-start guide for configuring Supermodel sidecars.`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Supermodel Sidecars — Quick Setup") - fmt.Println() - fmt.Println("1. Authenticate:") - fmt.Println(" supermodel login") - fmt.Println() - fmt.Println("2. Generate sidecars for your repo:") - fmt.Println(" supermodel sidecars generate") - fmt.Println() - fmt.Println("3. Keep sidecars updated while coding:") - fmt.Println(" supermodel sidecars watch") - fmt.Println() - fmt.Println("4. (Optional) Add the hook to .claude/settings.json so sidecars") - fmt.Println(" update automatically when Claude Code writes files:") - fmt.Println(` {`) - fmt.Println(` "hooks": {`) - fmt.Println(` "PostToolUse": [{`) - fmt.Println(` "matcher": "Write|Edit|MultiEdit",`) - fmt.Println(` "hooks": [{"type": "command", "command": "supermodel sidecars hook"}]`) - fmt.Println(` }]`) - fmt.Println(` }`) - fmt.Println(` }`) - return nil - }, - } - - sidecarsCmd.AddCommand(c) - } - - rootCmd.AddCommand(sidecarsCmd) -} diff --git a/cmd/watch.go b/cmd/watch.go new file mode 100644 index 0000000..987e1bf --- /dev/null +++ b/cmd/watch.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "time" + + "github.com/spf13/cobra" + + "github.com/supermodeltools/cli/internal/config" + "github.com/supermodeltools/cli/internal/files" +) + +func init() { + var opts files.WatchOptions + + c := &cobra.Command{ + Use: "watch [path]", + Short: "Generate graph files on startup, then keep them updated as you code", + Long: `Runs a full generate on startup (using cached graph if available), then +enters daemon mode. Listens for file-change notifications from the +'supermodel hook' command and incrementally re-renders affected files. + +Press Ctrl+C to stop and remove graph files.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + if err := cfg.RequireAPIKey(); err != nil { + return err + } + dir := "." + if len(args) > 0 { + dir = args[0] + } + return files.Watch(cmd.Context(), cfg, dir, opts) + }, + } + + c.Flags().StringVar(&opts.CacheFile, "cache-file", "", "override cache file path") + c.Flags().DurationVar(&opts.Debounce, "debounce", 2*time.Second, "debounce duration before processing changes") + c.Flags().IntVar(&opts.NotifyPort, "notify-port", 7734, "UDP port for hook notifications") + c.Flags().BoolVar(&opts.FSWatch, "fs-watch", false, "enable git-poll fallback") + c.Flags().DurationVar(&opts.PollInterval, "poll-interval", 3*time.Second, "git poll interval when --fs-watch is enabled") + + rootCmd.AddCommand(c) +} diff --git a/internal/config/config.go b/internal/config/config.go index 6d2e5fb..f1a413e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ type Config struct { APIKey string `yaml:"api_key,omitempty"` APIBase string `yaml:"api_base,omitempty"` Output string `yaml:"output,omitempty"` // "human" | "json" + Files *bool `yaml:"files,omitempty"` } // Dir returns the Supermodel config directory (~/.supermodel). @@ -70,6 +71,14 @@ func (c *Config) Save() error { return nil } +// FilesEnabled reports whether file mode is on. Defaults to true. +func (c *Config) FilesEnabled() bool { + if c.Files != nil { + return *c.Files + } + return true +} + // RequireAPIKey returns an actionable error if no API key is configured. func (c *Config) RequireAPIKey() error { if c.APIKey == "" { @@ -98,4 +107,9 @@ func (c *Config) applyEnv() { if base := os.Getenv("SUPERMODEL_API_BASE"); base != "" { c.APIBase = base } + if os.Getenv("SUPERMODEL_FILES") == "false" { + c.Files = boolPtr(false) + } } + +func boolPtr(b bool) *bool { return &b } diff --git a/internal/sidecars/daemon.go b/internal/files/daemon.go similarity index 99% rename from internal/sidecars/daemon.go rename to internal/files/daemon.go index d0c27be..9fe89df 100644 --- a/internal/sidecars/daemon.go +++ b/internal/files/daemon.go @@ -1,4 +1,4 @@ -package sidecars +package files import ( "context" diff --git a/internal/sidecars/graph.go b/internal/files/graph.go similarity index 99% rename from internal/sidecars/graph.go rename to internal/files/graph.go index 7de061e..ae94490 100644 --- a/internal/sidecars/graph.go +++ b/internal/files/graph.go @@ -1,4 +1,4 @@ -package sidecars +package files import ( "path/filepath" diff --git a/internal/sidecars/handler.go b/internal/files/handler.go similarity index 94% rename from internal/sidecars/handler.go rename to internal/files/handler.go index 52ccebf..978b721 100644 --- a/internal/sidecars/handler.go +++ b/internal/files/handler.go @@ -1,4 +1,4 @@ -package sidecars +package files import ( "bufio" @@ -32,16 +32,6 @@ type WatchOptions struct { PollInterval time.Duration } -// CleanOptions configures the clean command. -type CleanOptions struct { - DryRun bool -} - -// HookOptions configures the hook command. -type HookOptions struct { - Port int -} - // RenderOptions configures the render command. type RenderOptions struct { CacheFile string @@ -160,7 +150,7 @@ func Watch(ctx context.Context, cfg *config.Config, dir string, opts WatchOption } logf := func(format string, args ...interface{}) { - fmt.Fprintf(os.Stderr, "[sidecars] "+format+"\n", args...) + fmt.Fprintf(os.Stderr, "[files] "+format+"\n", args...) } daemonCfg := DaemonConfig{ @@ -178,7 +168,7 @@ func Watch(ctx context.Context, cfg *config.Config, dir string, opts WatchOption } // Clean removes all .graph.* sidecar files from the directory tree. -func Clean(dir string, opts CleanOptions) error { +func Clean(_ context.Context, _ *config.Config, dir string, dryRun bool) error { repoDir, err := filepath.Abs(dir) if err != nil { return fmt.Errorf("resolving path: %w", err) @@ -200,7 +190,7 @@ func Clean(dir string, opts CleanOptions) error { if !isSidecarFile(info.Name()) { return nil } - if opts.DryRun { + if dryRun { fmt.Printf(" [dry-run] would remove %s\n", path) removed++ return nil @@ -216,7 +206,7 @@ func Clean(dir string, opts CleanOptions) error { return err } - if opts.DryRun { + if dryRun { fmt.Printf("Would remove %d sidecar files\n", removed) } else { fmt.Printf("Removed %d sidecar files\n", removed) @@ -239,8 +229,7 @@ type toolInput struct { // Hook reads a Claude Code PostToolUse JSON event from stdin and sends a UDP // notification to the watch daemon for any source file written or edited. -func Hook(opts HookOptions) error { - port := opts.Port +func Hook(port int) error { if port <= 0 { port = 7734 } @@ -314,7 +303,7 @@ func Render(dir string, opts RenderOptions) error { data, err := os.ReadFile(cacheFile) if err != nil { - return fmt.Errorf("reading cache %s: %w (run `supermodel sidecars generate` first)", cacheFile, err) + return fmt.Errorf("reading cache %s: %w (run `supermodel analyze` first)", cacheFile, err) } var ir api.SidecarIR diff --git a/internal/sidecars/render.go b/internal/files/render.go similarity index 99% rename from internal/sidecars/render.go rename to internal/files/render.go index 9d2b29f..29c77a5 100644 --- a/internal/sidecars/render.go +++ b/internal/files/render.go @@ -1,4 +1,4 @@ -package sidecars +package files import ( "fmt" diff --git a/internal/sidecars/watcher.go b/internal/files/watcher.go similarity index 99% rename from internal/sidecars/watcher.go rename to internal/files/watcher.go index c21831c..7208b5a 100644 --- a/internal/sidecars/watcher.go +++ b/internal/files/watcher.go @@ -1,4 +1,4 @@ -package sidecars +package files import ( "context" diff --git a/internal/sidecars/zip.go b/internal/files/zip.go similarity index 99% rename from internal/sidecars/zip.go rename to internal/files/zip.go index 0ac9476..da86cd3 100644 --- a/internal/sidecars/zip.go +++ b/internal/files/zip.go @@ -1,4 +1,4 @@ -package sidecars +package files import ( "archive/zip" From 0530e9c51ba239fedd54ca2d6e0f1c92046db688 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Tue, 7 Apr 2026 13:44:41 -0400 Subject: [PATCH 3/4] fix: address all golangci-lint issues in internal/files - nolint:gocyclo on Build, mergeGraph, renderImpactSection (graph algorithms) - nolint:gocritic on NewDaemon hugeParam (value-semantic config struct) - fix appendAssign: assign append back to source slice before assigning to field - fix ifElseChain: rewrite label checks as switch statement - fix appendCombine: use slice literal and multi-arg append in renderImpactSection - fix errcheck: _ = conn.SetReadDeadline / SetWriteDeadline - fix emptyStringTest: content != "" instead of len(content) > 0 - fix gosec G302: .gitignore opened with 0o600 - fix goimports: reformat daemon.go, types.go, hook.go Co-Authored-By: Claude Sonnet 4.6 --- cmd/hook.go | 4 ++-- internal/api/types.go | 16 ++++++++-------- internal/files/daemon.go | 20 +++++++++++--------- internal/files/graph.go | 15 +++++++-------- internal/files/handler.go | 6 +++--- internal/files/render.go | 15 +++++++++------ 6 files changed, 40 insertions(+), 36 deletions(-) diff --git a/cmd/hook.go b/cmd/hook.go index 25611f9..5ea5333 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -12,8 +12,8 @@ func init() { c := &cobra.Command{ Use: "hook", Short: "Forward Claude Code file-change events to the watch daemon", - Long: `Reads a Claude Code PostToolUse JSON payload from stdin and forwards the file path to the running watch daemon via UDP. Install as a PostToolUse hook in .claude/settings.json.`, - Args: cobra.NoArgs, + Long: `Reads a Claude Code PostToolUse JSON payload from stdin and forwards the file path to the running watch daemon via UDP. Install as a PostToolUse hook in .claude/settings.json.`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return files.Hook(port) }, diff --git a/internal/api/types.go b/internal/api/types.go index 47b8420..08c802d 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -149,11 +149,11 @@ type IRSubdomain struct { // IRNode/IRRelationship stubs), SidecarIR preserves the complete node graph // with IDs, labels, and properties required for sidecar rendering. type SidecarIR struct { - Repo string `json:"repo"` - Summary map[string]any `json:"summary"` - Metadata IRMetadata `json:"metadata"` + Repo string `json:"repo"` + Summary map[string]any `json:"summary"` + Metadata IRMetadata `json:"metadata"` Domains []SidecarDomain `json:"domains"` - Graph SidecarGraph `json:"graph"` + Graph SidecarGraph `json:"graph"` } // SidecarGraph is the full node/relationship graph embedded in SidecarIR. @@ -164,10 +164,10 @@ type SidecarGraph struct { // SidecarDomain is a semantic domain from the API with file references. type SidecarDomain struct { - Name string `json:"name"` - DescriptionSummary string `json:"descriptionSummary"` - KeyFiles []string `json:"keyFiles"` - Responsibilities []string `json:"responsibilities"` + Name string `json:"name"` + DescriptionSummary string `json:"descriptionSummary"` + KeyFiles []string `json:"keyFiles"` + Responsibilities []string `json:"responsibilities"` Subdomains []SidecarSubdomain `json:"subdomains"` } diff --git a/internal/files/daemon.go b/internal/files/daemon.go index 9fe89df..1575a12 100644 --- a/internal/files/daemon.go +++ b/internal/files/daemon.go @@ -30,10 +30,10 @@ type DaemonConfig struct { // Daemon watches for file changes and keeps sidecars fresh. type Daemon struct { - cfg DaemonConfig - client *api.Client - cache *Cache - logf func(string, ...interface{}) + cfg DaemonConfig + client *api.Client + cache *Cache + logf func(string, ...interface{}) mu sync.Mutex ir *api.SidecarIR @@ -41,7 +41,7 @@ type Daemon struct { } // NewDaemon creates a daemon with the given config and API client. -func NewDaemon(cfg DaemonConfig, client *api.Client) *Daemon { +func NewDaemon(cfg DaemonConfig, client *api.Client) *Daemon { //nolint:gocritic // DaemonConfig is a value-semantic config struct; pointer would complicate call sites if cfg.Debounce <= 0 { cfg.Debounce = 2 * time.Second } @@ -282,7 +282,7 @@ func (d *Daemon) saveCache() { } // mergeGraph integrates incremental API results into the existing SidecarIR. -func (d *Daemon) mergeGraph(incremental *api.SidecarIR, changedFiles []string) { +func (d *Daemon) mergeGraph(incremental *api.SidecarIR, changedFiles []string) { //nolint:gocyclo // graph merge has inherent branching per node/rel type; splitting would obscure the algorithm if d.ir == nil { d.ir = incremental return @@ -471,8 +471,10 @@ func (d *Daemon) mergeGraph(incremental *api.SidecarIR, changedFiles []string) { newRels = append(newRels, rel) } - d.ir.Graph.Nodes = append(keptNodes, newNodes...) - d.ir.Graph.Relationships = append(keptRels, newRels...) + keptNodes = append(keptNodes, newNodes...) + d.ir.Graph.Nodes = keptNodes + keptRels = append(keptRels, newRels...) + d.ir.Graph.Relationships = keptRels if len(incremental.Domains) > 0 { d.ir.Domains = incremental.Domains @@ -521,7 +523,7 @@ func (d *Daemon) listenUDP(ctx context.Context) { go func() { <-ctx.Done() - conn.SetReadDeadline(time.Now()) + _ = conn.SetReadDeadline(time.Now()) }() buf := make([]byte, 4096) diff --git a/internal/files/graph.go b/internal/files/graph.go index ae94490..be22f5d 100644 --- a/internal/files/graph.go +++ b/internal/files/graph.go @@ -61,7 +61,7 @@ func NewCache() *Cache { // Build populates the cache from a SidecarIR result. // SidecarIR preserves the full Node/Relationship data (IDs, labels, properties) // required for sidecar rendering. -func (c *Cache) Build(ir *api.SidecarIR) { +func (c *Cache) Build(ir *api.SidecarIR) { //nolint:gocyclo // multi-pass graph indexing; each branch handles one node/rel label type nodes := ir.Graph.Nodes rels := ir.Graph.Relationships @@ -70,13 +70,12 @@ func (c *Cache) Build(ir *api.SidecarIR) { n := nodes[i] props := n.Properties - if n.HasLabel("File") { - path := firstString(props, "filePath", "path", "name", n.ID) - c.IDToPath[n.ID] = path - } else if n.HasLabel("LocalDependency") { - path := firstString(props, "filePath", "name", n.ID) - c.IDToPath[n.ID] = path - } else if n.HasLabel("ExternalDependency") { + switch { + case n.HasLabel("File"): + c.IDToPath[n.ID] = firstString(props, "filePath", "path", "name", n.ID) + case n.HasLabel("LocalDependency"): + c.IDToPath[n.ID] = firstString(props, "filePath", "name", n.ID) + case n.HasLabel("ExternalDependency"): name := n.Prop("name") if name == "" { name = n.ID diff --git a/internal/files/handler.go b/internal/files/handler.go index 978b721..63df2ac 100644 --- a/internal/files/handler.go +++ b/internal/files/handler.go @@ -284,7 +284,7 @@ func Hook(port int) error { return nil } defer conn.Close() - conn.SetWriteDeadline(time.Now().Add(500 * time.Millisecond)) + _ = conn.SetWriteDeadline(time.Now().Add(500 * time.Millisecond)) _, _ = conn.Write([]byte(filePath)) return nil } @@ -345,13 +345,13 @@ func updateGitignore(repoDir string) error { } } - f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) //nolint:gosec // .gitignore is a standard repo file; 0o600 satisfies gosec while remaining functional if err != nil { return nil // can't write, skip silently } defer f.Close() - if len(content) > 0 && !strings.HasSuffix(content, "\n") { + if content != "" && !strings.HasSuffix(content, "\n") { fmt.Fprintln(f) } fmt.Fprintln(f, entry) diff --git a/internal/files/render.go b/internal/files/render.go index 29c77a5..8fb7192 100644 --- a/internal/files/render.go +++ b/internal/files/render.go @@ -112,7 +112,7 @@ func renderDepsSection(filePath string, cache *Cache, prefix string) string { return strings.Join(lines, "\n") } -func renderImpactSection(filePath string, cache *Cache, prefix string) string { +func renderImpactSection(filePath string, cache *Cache, prefix string) string { //nolint:gocyclo // risk/domain/impact calculation has many branches by design; splitting would obscure the scoring logic directImporters := cache.Importers[filePath] directCallerFiles := make(map[string]bool) @@ -170,14 +170,17 @@ func renderImpactSection(filePath string, cache *Cache, prefix string) string { risk = "LOW" } - var lines []string - lines = append(lines, fmt.Sprintf("%s [impact]", prefix)) - lines = append(lines, fmt.Sprintf("%s risk %s", prefix, risk)) + lines := []string{ + fmt.Sprintf("%s [impact]", prefix), + fmt.Sprintf("%s risk %s", prefix, risk), + } if len(domains) > 0 { lines = append(lines, fmt.Sprintf("%s domains %s", prefix, strings.Join(sortedBoolKeys(domains), " · "))) } - lines = append(lines, fmt.Sprintf("%s direct %d", prefix, directCount)) - lines = append(lines, fmt.Sprintf("%s transitive %d", prefix, transitiveCount)) + lines = append(lines, + fmt.Sprintf("%s direct %d", prefix, directCount), + fmt.Sprintf("%s transitive %d", prefix, transitiveCount), + ) if directCount > 0 { lines = append(lines, fmt.Sprintf("%s affects %s", prefix, strings.Join(sortedBoolKeys(directFiles), " · "))) } From 6a38eec10cd8b77a1dee4c8b4d69b8405ea59334 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Tue, 7 Apr 2026 13:49:49 -0400 Subject: [PATCH 4/4] fix: extract pollLoop helper, fix changedFiles dropped on incremental retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extract pollLoop(ctx, post func) so both full and incremental paths share identical polling logic without duplication - add postIncrementalZip helper that always includes changedFiles in the multipart form — previously retries called postZipTo which omitted the field - AnalyzeIncremental now uses pollLoop with a closure over changedFiles so every request (initial + retries) sends the full payload Co-Authored-By: Claude Sonnet 4.6 --- internal/api/client.go | 80 +++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 43 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index e68b326..f375289 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -79,7 +79,15 @@ func (c *Client) AnalyzeDomains(ctx context.Context, zipPath, idempotencyKey str // pollUntilComplete submits a ZIP to the analyze endpoint and polls until the // async job reaches "completed" status, then returns the raw JobResponse. func (c *Client) pollUntilComplete(ctx context.Context, zipPath, idempotencyKey string) (*JobResponse, error) { - job, err := c.postZip(ctx, zipPath, idempotencyKey) + post := func() (*JobResponse, error) { return c.postZip(ctx, zipPath, idempotencyKey) } + return c.pollLoop(ctx, post) +} + +// pollLoop calls post() for the initial submission, then keeps calling it until +// the job reaches a terminal state. post() is called on every poll so all +// request fields (including incremental changedFiles) are sent on each retry. +func (c *Client) pollLoop(ctx context.Context, post func() (*JobResponse, error)) (*JobResponse, error) { + job, err := post() if err != nil { return nil, err } @@ -93,7 +101,7 @@ func (c *Client) pollUntilComplete(ctx context.Context, zipPath, idempotencyKey return nil, ctx.Err() case <-time.After(wait): } - job, err = c.postZip(ctx, zipPath, idempotencyKey) + job, err = post() if err != nil { return nil, err } @@ -123,9 +131,33 @@ func (c *Client) AnalyzeSidecars(ctx context.Context, zipPath, idempotencyKey st } // AnalyzeIncremental uploads a zip of changed files and requests an incremental -// graph update from the API. changedFiles is sent as a form field so the server -// can scope its analysis to only those files. +// graph update from the API. changedFiles is sent on every request (initial and +// retries) so the server always has the full context. func (c *Client) AnalyzeIncremental(ctx context.Context, zipPath string, changedFiles []string, idempotencyKey string) (*SidecarIR, error) { + post := func() (*JobResponse, error) { + return c.postIncrementalZip(ctx, zipPath, changedFiles, idempotencyKey) + } + job, err := c.pollLoop(ctx, post) + if err != nil { + return nil, err + } + + var ir SidecarIR + if err := json.Unmarshal(job.Result, &ir); err != nil { + return nil, fmt.Errorf("decode incremental sidecar result: %w", err) + } + return &ir, nil +} + +// postZip sends the repository ZIP to the analyze endpoint and returns the +// raw job response (which may be pending, processing, or completed). +func (c *Client) postZip(ctx context.Context, zipPath, idempotencyKey string) (*JobResponse, error) { + return c.postZipTo(ctx, zipPath, idempotencyKey, analyzeEndpoint) +} + +// postIncrementalZip builds a multipart form with both the ZIP and the +// changedFiles JSON array, then submits it to the analyze endpoint. +func (c *Client) postIncrementalZip(ctx context.Context, zipPath string, changedFiles []string, idempotencyKey string) (*JobResponse, error) { f, err := os.Open(zipPath) if err != nil { return nil, err @@ -134,7 +166,6 @@ func (c *Client) AnalyzeIncremental(ctx context.Context, zipPath string, changed var buf bytes.Buffer mw := multipart.NewWriter(&buf) - fw, err := mw.CreateFormFile("file", filepath.Base(zipPath)) if err != nil { return nil, err @@ -142,8 +173,6 @@ func (c *Client) AnalyzeIncremental(ctx context.Context, zipPath string, changed if _, err = io.Copy(fw, f); err != nil { return nil, err } - - // Encode changed files as a JSON array in a form field. changedJSON, err := json.Marshal(changedFiles) if err != nil { return nil, err @@ -157,42 +186,7 @@ func (c *Client) AnalyzeIncremental(ctx context.Context, zipPath string, changed if err := c.request(ctx, http.MethodPost, analyzeEndpoint, mw.FormDataContentType(), &buf, idempotencyKey, &job); err != nil { return nil, err } - - // Poll until complete (reuse pollUntilComplete logic inline since we already have the first response). - for job.Status == "pending" || job.Status == "processing" { - wait := time.Duration(job.RetryAfter) * time.Second - if wait <= 0 { - wait = 5 * time.Second - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(wait): - } - nextJob, err := c.postZipTo(ctx, zipPath, idempotencyKey, analyzeEndpoint) - if err != nil { - return nil, err - } - job = *nextJob - } - if job.Error != nil { - return nil, fmt.Errorf("incremental analysis failed: %s", *job.Error) - } - if job.Status != "completed" { - return nil, fmt.Errorf("unexpected job status: %s", job.Status) - } - - var ir SidecarIR - if err := json.Unmarshal(job.Result, &ir); err != nil { - return nil, fmt.Errorf("decode incremental sidecar result: %w", err) - } - return &ir, nil -} - -// postZip sends the repository ZIP to the analyze endpoint and returns the -// raw job response (which may be pending, processing, or completed). -func (c *Client) postZip(ctx context.Context, zipPath, idempotencyKey string) (*JobResponse, error) { - return c.postZipTo(ctx, zipPath, idempotencyKey, analyzeEndpoint) + return &job, nil } // deadCodeEndpoint is the API path for dead code analysis.