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..5ea5333 --- /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/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/api/client.go b/internal/api/client.go index f9d4f83..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 } @@ -107,12 +115,80 @@ 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 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 + } + 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 + } + 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 + } + return &job, nil +} + // deadCodeEndpoint is the API path for dead code analysis. const deadCodeEndpoint = "/v1/analysis/dead-code" diff --git a/internal/api/types.go b/internal/api/types.go index 8b69b97..08c802d 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/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/files/daemon.go b/internal/files/daemon.go new file mode 100644 index 0000000..1575a12 --- /dev/null +++ b/internal/files/daemon.go @@ -0,0 +1,587 @@ +package files + +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 { //nolint:gocritic // DaemonConfig is a value-semantic config struct; pointer would complicate call sites + 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) { //nolint:gocyclo // graph merge has inherent branching per node/rel type; splitting would obscure the algorithm + 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) + } + + 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 + } + + 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/files/graph.go b/internal/files/graph.go new file mode 100644 index 0000000..be22f5d --- /dev/null +++ b/internal/files/graph.go @@ -0,0 +1,308 @@ +package files + +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) { //nolint:gocyclo // multi-pass graph indexing; each branch handles one node/rel label type + nodes := ir.Graph.Nodes + rels := ir.Graph.Relationships + + // Pass 1: index nodes + for i := range nodes { + n := nodes[i] + props := n.Properties + + 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 + } + 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/files/handler.go b/internal/files/handler.go new file mode 100644 index 0000000..63df2ac --- /dev/null +++ b/internal/files/handler.go @@ -0,0 +1,359 @@ +package files + +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 +} + +// 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, "[files] "+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(_ context.Context, _ *config.Config, dir string, dryRun bool) 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 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 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(port int) error { + 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 analyze` 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, 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 content != "" && !strings.HasSuffix(content, "\n") { + fmt.Fprintln(f) + } + fmt.Fprintln(f, entry) + return nil +} diff --git a/internal/files/render.go b/internal/files/render.go new file mode 100644 index 0000000..8fb7192 --- /dev/null +++ b/internal/files/render.go @@ -0,0 +1,286 @@ +package files + +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 { //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) + + 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" + } + + 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), + 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/files/watcher.go b/internal/files/watcher.go new file mode 100644 index 0000000..7208b5a --- /dev/null +++ b/internal/files/watcher.go @@ -0,0 +1,151 @@ +package files + +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/files/zip.go b/internal/files/zip.go new file mode 100644 index 0000000..da86cd3 --- /dev/null +++ b/internal/files/zip.go @@ -0,0 +1,303 @@ +package files + +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 +}