diff --git a/README.md b/README.md index db590fb..6a14cea 100644 --- a/README.md +++ b/README.md @@ -110,31 +110,15 @@ Manages `.graph.*` shards written next to each source file. Agents read these wi | Command | Description | |---|---| -| `analyze [path]` | Upload repo, run full analysis, write graph files (use `--three-file` for best results, `--no-shards` to skip) | +| `analyze [path]` | Upload repo, run full analysis, write `.graph.*` files (`--no-shards` skips file writing) | | `skill` | Print agent awareness prompt — pipe to `CLAUDE.md` or `AGENTS.md` | -| `watch [path]` | Generate graph files on startup, then keep them updated incrementally | +| `supermodel` | Generate graph files on startup, then keep them updated incrementally | | `clean [path]` | Remove all `.graph.*` files from the repository | -| `hook` | Claude Code `PostToolUse` hook — forward file-change events to the `watch` daemon | +| `hook` | Claude Code `PostToolUse` hook — forward file-change events to the `supermodel` daemon | -### Three-file shard format (recommended) +### Tell your agent about graph files -For best results, use the `--three-file` flag to generate separate `.calls`, `.deps`, and `.impact` files instead of a single `.graph` file: - -```bash -supermodel analyze --three-file -``` - -This produces three files per source file: - -``` -src/cache.go → src/cache.calls.go # who calls what, with file:line - → src/cache.deps.go # imports and imported-by - → src/cache.impact.go # risk level, domains, blast radius -``` - -The three-file format is **68% faster** in benchmarks because grep hits are more targeted — searching for a function name hits only the `.calls` file with caller/callee data, not a combined blob. - -**Tell your agent about the files** by adding this to `CLAUDE.md` or `AGENTS.md`: +Add the Supermodel graph-file prompt to `CLAUDE.md`, `AGENTS.md`, or your agent's instruction file: ```bash supermodel skill >> CLAUDE.md diff --git a/cmd/analyze.go b/cmd/analyze.go index 83ca5d0..ad4dc10 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -1,8 +1,6 @@ package cmd import ( - "fmt" - "github.com/spf13/cobra" "github.com/supermodeltools/cli/internal/analyze" @@ -13,7 +11,6 @@ import ( func init() { var opts analyze.Options var noShards bool - var threeFile bool c := &cobra.Command{ Use: "analyze [path]", @@ -35,9 +32,6 @@ Use --no-shards to skip writing graph files.`, if err := cfg.RequireAPIKey(); err != nil { return err } - if noShards && threeFile { - return fmt.Errorf("--three-file cannot be used with --no-shards") - } dir := "." if len(args) > 0 { dir = args[0] @@ -46,7 +40,7 @@ Use --no-shards to skip writing graph files.`, // Shard mode: Generate handles the full pipeline (API call + // cache + shards) in a single upload. Running analyze.Run // first would duplicate the API call. - return shards.Generate(cmd.Context(), cfg, dir, shards.GenerateOptions{Force: opts.Force, ThreeFile: threeFile}) + return shards.Generate(cmd.Context(), cfg, dir, shards.GenerateOptions{Force: opts.Force}) } return analyze.Run(cmd.Context(), cfg, dir, opts) }, @@ -55,7 +49,6 @@ Use --no-shards to skip writing graph files.`, 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(&noShards, "no-shards", false, "skip writing .graph.* shard files") - c.Flags().BoolVar(&threeFile, "three-file", false, "generate .calls/.deps/.impact files instead of single .graph") rootCmd.AddCommand(c) } diff --git a/cmd/hook.go b/cmd/hook.go index be199c7..be5ee64 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -11,14 +11,14 @@ 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.`, + Short: "Forward Claude Code file-change events to the Supermodel daemon", + Long: `Reads a Claude Code PostToolUse JSON payload from stdin and forwards the file path to the running Supermodel daemon via UDP. Install as a PostToolUse hook in .claude/settings.json.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return shards.Hook(port) }, } - c.Flags().IntVar(&port, "port", 7734, "UDP port of the watch daemon") + c.Flags().IntVar(&port, "port", 7734, "UDP port of the Supermodel daemon") rootCmd.AddCommand(c) } diff --git a/cmd/login.go b/cmd/login.go index fe652b7..5beeb54 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -17,7 +17,7 @@ func init() { For CI or headless environments, pass the key directly: supermodel login --token smsk_live_...`, RunE: func(cmd *cobra.Command, _ []string) error { - if token != "" { + if cmd.Flags().Changed("token") { return auth.LoginWithToken(token) } return auth.Login(cmd.Context()) diff --git a/cmd/root.go b/cmd/root.go index ee1b4cc..2149ced 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,8 +1,10 @@ package cmd import ( + "context" "fmt" "os" + "os/signal" "syscall" "time" @@ -176,8 +178,22 @@ func init() { // Execute is the entry point called by main. func Execute() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + stop() + case <-done: + } + }() + rootCmd.SetContext(ctx) if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) + close(done) + stop() os.Exit(1) } + close(done) + stop() } diff --git a/internal/cache/fingerprint.go b/internal/cache/fingerprint.go index 0d22814..4d6a320 100644 --- a/internal/cache/fingerprint.go +++ b/internal/cache/fingerprint.go @@ -4,7 +4,11 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "io" + "os" "os/exec" + "path/filepath" + "sort" "strings" ) @@ -14,37 +18,307 @@ import ( // For dirty git repos (~100ms): returns commitSHA:dirtyHash. // For non-git dirs: returns empty string and an error. func RepoFingerprint(dir string) (string, error) { - commitSHA, err := gitOutput(dir, "rev-parse", "HEAD") + commitSHA, err := gitOutputTrim(dir, "rev-parse", "HEAD") if err != nil { return "", fmt.Errorf("not a git repo: %w", err) } - dirty, err := gitOutput(dir, "status", "--porcelain", "--untracked-files=no") + statusOut, err := gitOutputRaw(dir, "status", "--porcelain=v1", "-z", "--untracked-files=all") if err != nil { return commitSHA, nil } + dirtyEntries := filterFingerprintStatus(parseGitStatusZ(statusOut)) - if dirty == "" { + if len(dirtyEntries) == 0 { return commitSHA, nil } - // Dirty: hash the diff to capture uncommitted changes. - diff, err := gitOutput(dir, "diff", "HEAD") - if err != nil { + // Dirty: hash tracked changes plus untracked file contents. Generated + // files are filtered because they are not uploaded for analysis. + h := sha256.New() + fmt.Fprint(h, "status\x00") + writeStatusEntries(h, dirtyEntries) + if err := hashDirtyFiles(h, dir, dirtyEntries); err != nil { return commitSHA + ":dirty", nil } - h := sha256.Sum256([]byte(diff)) - return commitSHA + ":" + hex.EncodeToString(h[:8]), nil + if err := hashUntrackedFiles(h, dir); err != nil { + return commitSHA + ":dirty", nil + } + sum := h.Sum(nil) + return commitSHA + ":" + hex.EncodeToString(sum[:8]), nil +} + +type gitStatusEntry struct { + code string + paths []string +} + +func parseGitStatusZ(status string) []gitStatusEntry { + if status == "" { + return nil + } + records := strings.Split(status, "\x00") + entries := make([]gitStatusEntry, 0, len(records)) + for i := 0; i < len(records); i++ { + record := records[i] + if record == "" || len(record) < 4 { + continue + } + entry := gitStatusEntry{ + code: record[:2], + paths: []string{filepath.ToSlash(record[3:])}, + } + if isRenameOrCopyStatus(entry.code) && i+1 < len(records) && records[i+1] != "" { + entry.paths = append(entry.paths, filepath.ToSlash(records[i+1])) + i++ + } + entries = append(entries, entry) + } + return entries +} + +func filterFingerprintStatus(entries []gitStatusEntry) []gitStatusEntry { + kept := make([]gitStatusEntry, 0, len(entries)) + for _, entry := range entries { + if !statusEntryTouchesUploadablePath(entry) { + continue + } + kept = append(kept, entry) + } + sort.Slice(kept, func(i, j int) bool { + return statusEntryKey(kept[i]) < statusEntryKey(kept[j]) + }) + return kept +} + +func statusEntryTouchesUploadablePath(entry gitStatusEntry) bool { + for _, path := range entry.paths { + if path != "" && !ignoreFingerprintPath(path) { + return true + } + } + return false +} + +func statusEntryKey(entry gitStatusEntry) string { + return entry.code + "\x00" + strings.Join(entry.paths, "\x00") +} + +func writeStatusEntries(h io.Writer, entries []gitStatusEntry) { + for _, entry := range entries { + fmt.Fprintf(h, "%s\x00", entry.code) + for _, path := range entry.paths { + fmt.Fprintf(h, "%s\x00", path) + } + } +} + +func isRenameOrCopyStatus(code string) bool { + return strings.ContainsAny(code, "RC") +} + +func isRenameStatus(code string) bool { + return strings.Contains(code, "R") +} + +func hashDirtyFiles(h io.Writer, dir string, entries []gitStatusEntry) error { + for _, entry := range entries { + if entry.code == "??" || len(entry.paths) == 0 { + continue + } + + if isRenameOrCopyStatus(entry.code) && len(entry.paths) > 1 { + newPath := entry.paths[0] + oldPath := entry.paths[1] + if isRenameStatus(entry.code) && !ignoreFingerprintPath(oldPath) && oldPath != newPath { + fmt.Fprintf(h, "tracked\x00%s\x00deleted\x00", oldPath) + } + if !ignoreFingerprintPath(newPath) { + if err := hashFileState(h, dir, "tracked", newPath, true); err != nil { + return err + } + } + continue + } + + rel := entry.paths[0] + if rel == "" || ignoreFingerprintPath(rel) { + continue + } + if err := hashFileState(h, dir, "tracked", rel, true); err != nil { + return err + } + } + return nil +} + +func hashUntrackedFiles(h io.Writer, dir string) error { + out, err := gitOutputRaw(dir, "ls-files", "-z", "--others", "--exclude-standard") + if err != nil || out == "" { + return err + } + files := splitNUL(out) + sort.Strings(files) + for _, rel := range files { + if rel == "" { + continue + } + rel = filepath.ToSlash(rel) + if ignoreFingerprintPath(rel) { + continue + } + if err := hashFileState(h, dir, "untracked", rel, false); err != nil { + return err + } + } + return nil +} + +func splitNUL(out string) []string { + if out == "" { + return nil + } + fields := strings.Split(out, "\x00") + if len(fields) > 0 && fields[len(fields)-1] == "" { + fields = fields[:len(fields)-1] + } + return fields } -// gitOutput runs a git command in dir and returns its trimmed stdout. -func gitOutput(dir string, args ...string) (string, error) { +func hashFileState(h io.Writer, dir, kind, rel string, missingAsDeleted bool) error { + full := filepath.Join(dir, filepath.FromSlash(rel)) + info, err := os.Lstat(full) + if os.IsNotExist(err) { + if missingAsDeleted { + fmt.Fprintf(h, "%s\x00%s\x00deleted\x00", kind, rel) + } + return nil + } + if err != nil { + return err + } + if info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil + } + fmt.Fprintf(h, "%s\x00%s\x00%d\x00", kind, rel, info.Size()) + f, err := os.Open(full) + if err != nil { + return err + } + if _, err := io.Copy(h, f); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + fmt.Fprint(h, "\x00") + return nil +} + +func ignoreFingerprintPath(path string) bool { + path = filepath.ToSlash(path) + parts := strings.Split(path, "/") + if len(parts) == 0 { + return false + } + for _, part := range parts[:len(parts)-1] { + if strings.HasPrefix(part, ".") || defaultUploadSkipDir(part) { + return true + } + } + filename := parts[len(parts)-1] + return isGeneratedShardPath(path) || + isSensitiveFingerprintPath(path) || + defaultUploadSkipFile(filename) || + defaultUploadSkipExtension(filename) +} + +func defaultUploadSkipDir(name string) bool { + // Keep this in lockstep with internal/shards/zip.go upload exclusions. + // The cache package cannot import shards because shards already imports cache. + switch name { + case ".git", "node_modules", "vendor", ".venv", "venv", "__pycache__", "dist", "build", + ".next", ".nuxt", ".cache", ".turbo", "coverage", ".nyc_output", "__snapshots__", + "docs-output", ".terraform": + return true + default: + return false + } +} + +func defaultUploadSkipFile(name string) bool { + // Keep this in lockstep with internal/shards/zip.go upload exclusions. + switch name { + case "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "Gemfile.lock", + "poetry.lock", "go.sum", "Cargo.lock": + return true + default: + return false + } +} + +func defaultUploadSkipExtension(name string) bool { + // Keep this in lockstep with internal/shards/zip.go upload exclusions. + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".map", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".otf", ".mp4", ".mp3", + ".wav", ".png", ".jpg", ".jpeg", ".gif", ".webp": + return true + default: + return strings.HasSuffix(name, ".min.js") || + strings.HasSuffix(name, ".min.css") || + strings.HasSuffix(name, ".bundle.js") + } +} + +func isSensitiveFingerprintPath(path string) bool { + base := strings.ToLower(filepath.Base(path)) + if base == ".env" { + return true + } + for _, suffix := range []string{".key", ".pem", ".p12", ".pfx", ".crt", ".cert", ".tfstate", ".tfstate.backup"} { + if strings.HasSuffix(base, suffix) { + return true + } + } + return strings.Contains(base, "secret") || + strings.Contains(base, "credential") || + strings.Contains(base, "password") +} + +func isGeneratedShardPath(path string) bool { + base := filepath.Base(path) + ext := filepath.Ext(base) + if ext == "" { + return false + } + stem := strings.TrimSuffix(base, ext) + tag := strings.TrimPrefix(filepath.Ext(stem), ".") + switch tag { + case "graph", "calls", "deps", "impact": + return true + default: + return false + } +} + +func gitOutputRaw(dir string, args ...string) (string, error) { cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) out, err := cmd.Output() if err != nil { return "", err } - return strings.TrimSpace(string(out)), nil + return string(out), nil +} + +// gitOutputTrim runs a git command in dir and returns stdout without trailing whitespace. +func gitOutputTrim(dir string, args ...string) (string, error) { + out, err := gitOutputRaw(dir, args...) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil } // AnalysisKey builds a cache key for a specific analysis type on a repo state. diff --git a/internal/cache/fingerprint_test.go b/internal/cache/fingerprint_test.go index 6435f9d..ea2ea0f 100644 --- a/internal/cache/fingerprint_test.go +++ b/internal/cache/fingerprint_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "testing" ) @@ -88,6 +89,237 @@ func TestRepoFingerprint_ChangesAfterCommit(t *testing.T) { } } +func TestRepoFingerprint_ChangesForUntrackedFile(t *testing.T) { + dir := initGitRepo(t) + fp1, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(dir, "generated.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + fp2, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + if fp1 == fp2 { + t.Error("fingerprint should change when an untracked uploadable file appears") + } + + if err := os.WriteFile(filepath.Join(dir, "generated.go"), []byte("package main\nfunc generated() {}\n"), 0o600); err != nil { + t.Fatal(err) + } + fp3, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + if fp2 == fp3 { + t.Error("fingerprint should change when untracked file contents change") + } +} + +func TestRepoFingerprint_ChangesForUntrackedQuotedFilename(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows filesystems do not allow double quotes in filenames") + } + dir := initGitRepo(t) + fp1, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + + rel := `quote" file.go` + if err := os.WriteFile(filepath.Join(dir, rel), []byte("package main\nfunc one() {}\n"), 0o600); err != nil { + t.Fatal(err) + } + fp2, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + if fp1 == fp2 { + t.Fatal("fingerprint should change for an untracked filename requiring git quoting") + } + + if err := os.WriteFile(filepath.Join(dir, rel), []byte("package main\nfunc two() {}\n"), 0o600); err != nil { + t.Fatal(err) + } + fp3, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + if fp2 == fp3 { + t.Fatal("fingerprint should change when quoted untracked file contents change") + } +} + +func TestRepoFingerprint_ChangesForTrackedQuotedFilename(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows filesystems do not allow double quotes in filenames") + } + dir := initGitRepo(t) + rel := `quote" file.go` + if err := os.WriteFile(filepath.Join(dir, rel), []byte("package main\nfunc one() {}\n"), 0o600); err != nil { + t.Fatal(err) + } + run(t, dir, "git", "add", rel) + run(t, dir, "git", "commit", "-m", "quoted filename") + + fp1, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, rel), []byte("package main\nfunc two() {}\n"), 0o600); err != nil { + t.Fatal(err) + } + fp2, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + if fp1 == fp2 { + t.Fatal("fingerprint should change for a dirty tracked filename requiring git quoting") + } + + if err := os.WriteFile(filepath.Join(dir, rel), []byte("package main\nfunc three() {}\n"), 0o600); err != nil { + t.Fatal(err) + } + fp3, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + if fp2 == fp3 { + t.Fatal("fingerprint should change when quoted tracked file contents change") + } +} + +func TestRepoFingerprint_RenameToIgnoredDirInvalidatesSource(t *testing.T) { + dir := initGitRepo(t) + src := filepath.Join("src", "keep.go") + if err := os.MkdirAll(filepath.Join(dir, "src"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, src), []byte("package main\nfunc keep() {}\n"), 0o600); err != nil { + t.Fatal(err) + } + run(t, dir, "git", "add", src) + run(t, dir, "git", "commit", "-m", "source file") + + fp1, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "docs-output"), 0o755); err != nil { + t.Fatal(err) + } + run(t, dir, "git", "mv", src, filepath.Join("docs-output", "keep.go")) + + fp2, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + if fp1 == fp2 { + t.Fatal("renaming an uploadable source into an ignored directory should invalidate the source fingerprint") + } +} + +func TestRepoFingerprint_IgnoresGeneratedArtifacts(t *testing.T) { + dir := initGitRepo(t) + fp1, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + + if err := os.MkdirAll(filepath.Join(dir, ".supermodel"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".supermodel", "shards.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "main.graph.go"), []byte("// generated graph\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "docs-output"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "docs-output", "index.html"), []byte("\n"), 0o600); err != nil { + t.Fatal(err) + } + + fp2, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + if fp1 != fp2 { + t.Fatalf("fingerprint should ignore generated artifacts: %q != %q", fp1, fp2) + } +} + +func TestRepoFingerprint_IgnoresHiddenDirsAndSecrets(t *testing.T) { + dir := initGitRepo(t) + fp1, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + + for _, rel := range []string{ + filepath.Join(".claude", "settings.json"), + filepath.Join(".github", "workflows", "ci.yml"), + filepath.Join("frontend", "__snapshots__", "component.snap"), + filepath.Join("web", ".cache", "vite.json"), + filepath.Join("app", ".venv", "pyvenv.cfg"), + ".env", + "prod.key", + "credentials.txt", + } { + full := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("ignored\n"), 0o600); err != nil { + t.Fatal(err) + } + } + + fp2, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + if fp1 != fp2 { + t.Fatalf("fingerprint should ignore non-uploaded hidden dirs and secrets: %q != %q", fp1, fp2) + } +} + +func TestRepoFingerprint_IgnoresGeneratedArtifactsWhenSourceIsDirty(t *testing.T) { + dir := initGitRepo(t) + if err := os.WriteFile(filepath.Join(dir, "main.graph.go"), []byte("// generated graph\n"), 0o600); err != nil { + t.Fatal(err) + } + run(t, dir, "git", "add", "-f", "main.graph.go") + run(t, dir, "git", "commit", "-m", "track generated artifact") + + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n// dirty\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "main.graph.go"), []byte("// regenerated graph\n"), 0o600); err != nil { + t.Fatal(err) + } + fpWithGeneratedDirty, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(dir, "main.graph.go"), []byte("// generated graph\n"), 0o600); err != nil { + t.Fatal(err) + } + fpSourceOnlyDirty, err := RepoFingerprint(dir) + if err != nil { + t.Fatal(err) + } + if fpWithGeneratedDirty != fpSourceOnlyDirty { + t.Fatalf("generated shard changes should not affect source fingerprint: %q != %q", fpWithGeneratedDirty, fpSourceOnlyDirty) + } +} + func TestRepoFingerprint_NotGitRepo(t *testing.T) { dir := t.TempDir() _, err := RepoFingerprint(dir) diff --git a/internal/restore/local.go b/internal/restore/local.go index 3b1656f..0b71423 100644 --- a/internal/restore/local.go +++ b/internal/restore/local.go @@ -29,7 +29,7 @@ var ignoreDirs = map[string]bool{ "build": true, "target": true, ".tox": true, "venv": true, ".venv": true, "coverage": true, ".nyc_output": true, "out": true, ".next": true, ".nuxt": true, ".turbo": true, "Pods": true, - "elm-stuff": true, "_build": true, "env": true, + "elm-stuff": true, "_build": true, "env": true, "docs-output": true, } // extToLanguage maps common file extensions to language display names. @@ -332,7 +332,7 @@ func collectFiles(ctx context.Context, rootDir string) (extCounts map[string]int } if d.IsDir() { name := d.Name() - if ignoreDirs[name] || strings.HasPrefix(name, ".") { + if path != rootDir && (ignoreDirs[name] || strings.HasPrefix(name, ".")) { return filepath.SkipDir } return nil @@ -348,11 +348,15 @@ func collectFiles(ctx context.Context, rootDir string) (extCounts map[string]int if strings.HasPrefix(d.Name(), ".") { return nil } + if isGeneratedShardPath(rel) { + return nil + } ext := strings.ToLower(filepath.Ext(path)) - if ext != "" { - extCounts[ext]++ + if _, ok := extToLanguage[ext]; !ok { + return nil } + extCounts[ext]++ total++ parts := strings.SplitN(rel, string(filepath.Separator), 3) @@ -369,6 +373,22 @@ func collectFiles(ctx context.Context, rootDir string) (extCounts map[string]int return extCounts, dirFiles, total, walkErr } +func isGeneratedShardPath(path string) bool { + base := filepath.Base(path) + ext := filepath.Ext(base) + if ext == "" { + return false + } + stem := strings.TrimSuffix(base, ext) + tag := strings.TrimPrefix(filepath.Ext(stem), ".") + switch tag { + case "graph", "calls", "deps", "impact": + return true + default: + return false + } +} + func detectLanguages(extCounts map[string]int) (primary string, languages []string) { langCounts := make(map[string]int) for ext, count := range extCounts { diff --git a/internal/restore/render.go b/internal/restore/render.go index d274dcf..fc90ab2 100644 --- a/internal/restore/render.go +++ b/internal/restore/render.go @@ -45,7 +45,7 @@ const contextBombTmpl = `# Supermodel Context — {{.ProjectName}} **Language:** {{.Graph.Language}}{{if .Graph.Framework}} **Framework:** {{.Graph.Framework}}{{end}}{{if .Graph.Description}} **Description:** {{.Graph.Description}}{{end}} -**Codebase:** {{.Graph.Stats.TotalFiles}} files · {{.Graph.Stats.TotalFunctions}} functions +**Codebase:** {{.Graph.Stats.TotalFiles}} source files{{if and .LocalMode (eq .Graph.Stats.TotalFunctions 0)}} · functions not counted locally{{else}} · {{.Graph.Stats.TotalFunctions}} functions{{end}} {{- if .Graph.Stats.Languages}} **Languages:** {{languageList .Graph.Stats.Languages}}{{end}}{{if .Graph.ExternalDeps}} @@ -168,9 +168,14 @@ func truncateToTokenBudget(graph *ProjectGraph, projectName string, opts RenderO fmt.Fprintf(&hdr, "> ⚠️ %d circular dependency %s detected\n", graph.Stats.CircularDependencyCycles, label) } fmt.Fprintf(&hdr, - "\n**Language:** %s · **Files:** %d · **Functions:** %d", - graph.Language, graph.Stats.TotalFiles, graph.Stats.TotalFunctions, + "\n**Language:** %s · **Files:** %d", + graph.Language, graph.Stats.TotalFiles, ) + if opts.LocalMode && graph.Stats.TotalFunctions == 0 { + hdr.WriteString(" · **Functions:** not counted locally") + } else { + fmt.Fprintf(&hdr, " · **Functions:** %d", graph.Stats.TotalFunctions) + } required := hdr.String() reqTokens := CountTokens(required) diff --git a/internal/restore/restore_test.go b/internal/restore/restore_test.go index 08b0d39..d4bb4bb 100644 --- a/internal/restore/restore_test.go +++ b/internal/restore/restore_test.go @@ -195,6 +195,88 @@ func TestDetectLanguages_UnknownExtensionsIgnored(t *testing.T) { } } +func TestBuildProjectGraph_LocalModeCountsRootSourceFiles(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Smoke\n\nExample project.\n"), 0o600); err != nil { + t.Fatal(err) + } + + graph, err := BuildProjectGraph(context.Background(), dir, "smoke") + if err != nil { + t.Fatal(err) + } + if graph.Stats.TotalFiles != 1 { + t.Fatalf("local mode should count recognized source files only: got %d", graph.Stats.TotalFiles) + } + if graph.Language != "Go" { + t.Fatalf("primary language: got %q, want Go", graph.Language) + } + if len(graph.Domains) != 1 || graph.Domains[0].Name != "Root" { + t.Fatalf("root source file should produce Root domain, got %#v", graph.Domains) + } + + out, _, err := Render(graph, "smoke", RenderOptions{LocalMode: true}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "1 source files") { + t.Fatalf("rendered output should report source file count, got:\n%s", out) + } + if strings.Contains(out, "0 functions") { + t.Fatalf("local mode should not claim zero functions, got:\n%s", out) + } + if !strings.Contains(out, "functions not counted locally") { + t.Fatalf("local mode should explain function count limitation, got:\n%s", out) + } +} + +func TestBuildProjectGraph_LocalModeExcludesDocsOutput(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "docs-output"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "docs-output", "generated.js"), []byte("console.log('generated')\n"), 0o600); err != nil { + t.Fatal(err) + } + + graph, err := BuildProjectGraph(context.Background(), dir, "smoke") + if err != nil { + t.Fatal(err) + } + if graph.Stats.TotalFiles != 1 { + t.Fatalf("docs-output should be ignored by local restore scan: got %d files", graph.Stats.TotalFiles) + } +} + +func TestBuildProjectGraph_LocalModeExcludesGeneratedShards(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + for _, name := range []string{"main.graph.go", "main.calls.go", "main.deps.go", "main.impact.go"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("// generated\n"), 0o600); err != nil { + t.Fatal(err) + } + } + + graph, err := BuildProjectGraph(context.Background(), dir, "smoke") + if err != nil { + t.Fatal(err) + } + if graph.Stats.TotalFiles != 1 { + t.Fatalf("generated shard files should be ignored by local restore scan: got %d files", graph.Stats.TotalFiles) + } + if len(graph.Domains) != 1 || len(graph.Domains[0].KeyFiles) != 1 || graph.Domains[0].KeyFiles[0] != "main.go" { + t.Fatalf("generated shard files should not become key files, got %#v", graph.Domains) + } +} + // ── buildDomains ────────────────────────────────────────────────────────────── func TestBuildDomains_Empty(t *testing.T) { diff --git a/internal/shards/daemon.go b/internal/shards/daemon.go index 5308897..3fc581f 100644 --- a/internal/shards/daemon.go +++ b/internal/shards/daemon.go @@ -16,6 +16,7 @@ import ( "time" "github.com/supermodeltools/cli/internal/api" + repocache "github.com/supermodeltools/cli/internal/cache" ) // DaemonConfig holds watch daemon configuration. @@ -174,29 +175,41 @@ func (d *Daemon) Run(ctx context.Context) error { // loadOrGenerate loads an existing cache if available and re-renders shards. // If no cache exists, it does a full API fetch. func (d *Daemon) loadOrGenerate(ctx context.Context) error { + if err := updateGitignore(d.cfg.RepoDir); err != nil { + return err + } + data, err := os.ReadFile(d.cfg.CacheFile) if err == nil { var ir api.ShardIR 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.loadedCache = true - d.mu.Unlock() - - files := d.cache.SourceFiles() - written, renderErr := RenderAll(d.cfg.RepoDir, d.cache, files, false) - if renderErr != nil { - return renderErr + fingerprint, fingerprintErr := repocache.RepoFingerprint(d.cfg.RepoDir) + switch { + case fingerprintErr != nil: + d.logf("Unable to validate cached graph for current repo contents, regenerating") + case !shardCacheMatchesFingerprint(&ir, fingerprint): + d.logf("Cache is stale for current repo contents, regenerating") + default: + 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.loadedCache = true + 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 shards for %d source files", written, len(files)) + return nil } - d.logf("Rendered %d shards for %d source files", written, len(files)) - return nil } } @@ -209,6 +222,7 @@ func (d *Daemon) loadOrGenerate(ctx context.Context) error { func (d *Daemon) fullGenerate(ctx context.Context) error { d.logf("Fetching full graph from Supermodel API...") idemKey := newUUID() + fingerprint, _ := repocache.RepoFingerprint(d.cfg.RepoDir) if fileList, listErr := DryRunList(d.cfg.RepoDir); listErr == nil { stats := LanguageStats(fileList) @@ -227,10 +241,11 @@ func (d *Daemon) fullGenerate(ctx context.Context) error { } d.mu.Lock() + setShardCacheFingerprint(ir, fingerprint) d.ir = ir d.cache = NewCache() d.cache.Build(ir) - d.saveCache() + d.saveCacheWithFingerprint(fingerprint) d.mu.Unlock() files := d.cache.SourceFiles() @@ -339,9 +354,15 @@ func (d *Daemon) incrementalUpdate(ctx context.Context, changedFiles []string) { // saveCache writes the current merged ShardIR to the cache file. Must be called with d.mu held. func (d *Daemon) saveCache() { + fingerprint, _ := repocache.RepoFingerprint(d.cfg.RepoDir) + d.saveCacheWithFingerprint(fingerprint) +} + +func (d *Daemon) saveCacheWithFingerprint(fingerprint string) { if d.ir == nil { return } + setShardCacheFingerprint(d.ir, fingerprint) // Ensure cache directory exists if err := os.MkdirAll(filepath.Dir(d.cfg.CacheFile), 0o755); err != nil { d.logf("Error creating cache directory: %v", err) diff --git a/internal/shards/daemon_test.go b/internal/shards/daemon_test.go index 3d25613..9124ea7 100644 --- a/internal/shards/daemon_test.go +++ b/internal/shards/daemon_test.go @@ -2,12 +2,16 @@ package shards import ( "context" + "encoding/json" "net" "os" + "os/exec" + "path/filepath" "strings" "testing" "github.com/supermodeltools/cli/internal/api" + repocache "github.com/supermodeltools/cli/internal/cache" ) // ── helpers ───────────────────────────────────────────────────────────────── @@ -966,6 +970,91 @@ func TestOnSyncing_NilSafe(t *testing.T) { d.incrementalUpdate(context.Background(), []string{"a.go"}) } +func TestLoadOrGenerate_RegeneratesStaleFingerprintCache(t *testing.T) { + repoDir := t.TempDir() + if err := os.WriteFile(filepath.Join(repoDir, "main.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + shardsRunGit(t, repoDir, "init") + shardsRunGit(t, repoDir, "config", "user.email", "test@example.com") + shardsRunGit(t, repoDir, "config", "user.name", "Test") + shardsRunGit(t, repoDir, "add", "main.go") + shardsRunGit(t, repoDir, "commit", "-m", "init") + + cacheFile := filepath.Join(repoDir, ".supermodel", "shards.json") + if err := os.MkdirAll(filepath.Dir(cacheFile), 0o755); err != nil { + t.Fatal(err) + } + stale := buildIR( + []api.Node{newNode("old-file", []string{"File"}, "filePath", "old.go")}, + nil, + ) + stale.Summary = map[string]any{shardCacheFingerprintKey: "stale"} + staleJSON, err := json.Marshal(stale) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cacheFile, staleJSON, 0o644); err != nil { + t.Fatal(err) + } + + fresh := buildIR( + []api.Node{newNode("file-main", []string{"File"}, "filePath", "main.go")}, + nil, + ) + client := &mockAnalyzeClient{result: fresh} + d := &Daemon{ + cfg: DaemonConfig{ + RepoDir: repoDir, + CacheFile: cacheFile, + LogFunc: func(string, ...interface{}) {}, + }, + client: client, + cache: NewCache(), + logf: func(string, ...interface{}) {}, + notifyCh: make(chan string, 256), + } + if err := d.loadOrGenerate(context.Background()); err != nil { + t.Fatal(err) + } + if client.called == 0 { + t.Fatal("stale fingerprint cache should trigger API regeneration") + } + current, err := repocache.RepoFingerprint(repoDir) + if err != nil { + t.Fatal(err) + } + var saved api.ShardIR + data, err := os.ReadFile(cacheFile) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &saved); err != nil { + t.Fatal(err) + } + if got, _ := saved.Summary[shardCacheFingerprintKey].(string); got != current { + t.Fatalf("saved fingerprint = %q, want %q", got, current) + } +} + +func TestShardCacheMatchesFingerprint_FailsClosedForMissingFingerprint(t *testing.T) { + ir := buildIR([]api.Node{newNode("file-main", []string{"File"}, "filePath", "main.go")}, nil) + ir.Summary = map[string]any{shardCacheFingerprintKey: "known"} + + if shardCacheMatchesFingerprint(ir, "") { + t.Fatal("missing current fingerprint should not be treated as a cache hit") + } +} + +func shardsRunGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + // ── Port-conflict UX ───────────────────────────────────────────────────────── // TestPortConflict_FriendlyMessage verifies that when the daemon cannot bind diff --git a/internal/shards/handler.go b/internal/shards/handler.go index 01825d7..7f37c7b 100644 --- a/internal/shards/handler.go +++ b/internal/shards/handler.go @@ -12,6 +12,7 @@ import ( "time" "github.com/supermodeltools/cli/internal/api" + repocache "github.com/supermodeltools/cli/internal/cache" "github.com/supermodeltools/cli/internal/config" "github.com/supermodeltools/cli/internal/ui" ) @@ -25,19 +26,13 @@ const ( ansiDim = "\033[2m" ) +const shardCacheFingerprintKey = "_supermodelFingerprint" + // GenerateOptions configures the generate command. type GenerateOptions struct { Force bool DryRun bool CacheFile string - ThreeFile bool // generate .calls/.deps/.impact instead of single .graph -} - -func renderShards(repoDir string, cache *Cache, files []string, dryRun, threeFile bool) (int, error) { - if threeFile { - return RenderAllThreeFile(repoDir, cache, files, dryRun) - } - return RenderAll(repoDir, cache, files, dryRun) } // WatchOptions configures the watch command. @@ -53,7 +48,6 @@ type WatchOptions struct { type RenderOptions struct { CacheFile string DryRun bool - ThreeFile bool } // guardDir returns an error if dir is the filesystem root or the user's home @@ -83,29 +77,40 @@ func Generate(ctx context.Context, cfg *config.Config, dir string, opts Generate if err := guardDir(repoDir); err != nil { return err } + if err := updateGitignore(repoDir); err != nil { + return err + } cacheFile := opts.CacheFile if cacheFile == "" { cacheFile = filepath.Join(repoDir, ".supermodel", "shards.json") } + fingerprint, fingerprintErr := repocache.RepoFingerprint(repoDir) // Check for existing cache unless --force if !opts.Force { if data, err := os.ReadFile(cacheFile); err == nil { var ir api.ShardIR 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 shards…") - written, err := renderShards(repoDir, cache, files, opts.DryRun, opts.ThreeFile) - spin.Stop() - if err != nil { - return err + switch { + case fingerprintErr != nil: + ui.Warn("Unable to validate cached graph for current repo contents — re-fetching") + case !shardCacheMatchesFingerprint(&ir, fingerprint): + ui.Warn("Cached graph is stale for current repo contents — re-fetching") + default: + 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 shards…") + written, err := RenderAll(repoDir, cache, files, opts.DryRun) + spin.Stop() + if err != nil { + return err + } + ui.Success("Wrote %d shards for %d source files", written, len(files)) + return updateGitignore(repoDir) } - ui.Success("Wrote %d shards for %d source files", written, len(files)) - return updateGitignore(repoDir) } } } @@ -162,7 +167,7 @@ func Generate(ctx context.Context, cfg *config.Config, dir string, opts Generate staleCache := NewCache() staleCache.Build(&staleIR) files := staleCache.SourceFiles() - written, renderErr := renderShards(repoDir, staleCache, files, opts.DryRun, opts.ThreeFile) + written, renderErr := RenderAll(repoDir, staleCache, files, opts.DryRun) if renderErr != nil { return fmt.Errorf("API error: %w; stale render also failed: %v", err, renderErr) } @@ -173,6 +178,8 @@ func Generate(ctx context.Context, cfg *config.Config, dir string, opts Generate return err } + setShardCacheFingerprint(ir, fingerprint) + // Persist cache if err := os.MkdirAll(filepath.Dir(cacheFile), 0o755); err != nil { return fmt.Errorf("create cache dir: %w", err) @@ -195,7 +202,7 @@ func Generate(ctx context.Context, cfg *config.Config, dir string, opts Generate files := cache.SourceFiles() spin = ui.Start("Rendering shards…") - written, err := renderShards(repoDir, cache, files, opts.DryRun, opts.ThreeFile) + written, err := RenderAll(repoDir, cache, files, opts.DryRun) spin.Stop() if err != nil { return err @@ -207,6 +214,27 @@ func Generate(ctx context.Context, cfg *config.Config, dir string, opts Generate return updateGitignore(repoDir) } +func setShardCacheFingerprint(ir *api.ShardIR, fingerprint string) { + if ir == nil || fingerprint == "" { + return + } + if ir.Summary == nil { + ir.Summary = make(map[string]any) + } + ir.Summary[shardCacheFingerprintKey] = fingerprint +} + +func shardCacheMatchesFingerprint(ir *api.ShardIR, fingerprint string) bool { + if fingerprint == "" { + return false + } + if ir == nil || ir.Summary == nil { + return false + } + got, _ := ir.Summary[shardCacheFingerprintKey].(string) + return got == fingerprint +} + // 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) @@ -438,7 +466,7 @@ func Render(dir string, opts RenderOptions) error { cache.Build(&ir) files := cache.SourceFiles() - written, err := renderShards(repoDir, cache, files, opts.DryRun, opts.ThreeFile) + written, err := RenderAll(repoDir, cache, files, opts.DryRun) if err != nil { return err } diff --git a/internal/shards/render.go b/internal/shards/render.go index f97a8b4..1694de8 100644 --- a/internal/shards/render.go +++ b/internal/shards/render.go @@ -26,13 +26,6 @@ func ShardFilename(sourcePath string) string { return stem + ".graph" + ext } -// ThreeFileShardNames generates the .calls, .deps, .impact shard paths. -func ThreeFileShardNames(sourcePath string) (calls, deps, impact string) { - ext := filepath.Ext(sourcePath) - stem := strings.TrimSuffix(sourcePath, ext) - return stem + ".calls" + ext, stem + ".deps" + ext, stem + ".impact" + ext -} - // Header returns the @generated header line. func Header(prefix string) string { return prefix + " @generated supermodel-shard — do not edit\n" @@ -259,19 +252,22 @@ func safeRemove(repoDir, relPath string) { _ = os.Remove(full) } -// removeStaleThreeFile removes .calls/.deps/.impact files for a source file. -func removeStaleThreeFile(repoDir, srcFile string) { - c, d, i := ThreeFileShardNames(srcFile) - for _, p := range []string{c, d, i} { +// removeStaleSplitShards removes legacy .calls/.deps/.impact files for a source file. +func removeStaleSplitShards(repoDir, srcFile string, dryRun bool) { + if dryRun { + return + } + ext := filepath.Ext(srcFile) + stem := strings.TrimSuffix(srcFile, ext) + for _, p := range []string{ + stem + ".calls" + ext, + stem + ".deps" + ext, + stem + ".impact" + ext, + } { safeRemove(repoDir, p) } } -// removeStaleGraph removes the single .graph file for a source file. -func removeStaleGraph(repoDir, srcFile string) { - safeRemove(repoDir, ShardFilename(srcFile)) -} - // RenderAll generates and writes .graph shards for the given source files. // Returns the count of shards written. func RenderAll(repoDir string, cache *Cache, files []string, dryRun bool) (int, error) { @@ -279,8 +275,8 @@ func RenderAll(repoDir string, cache *Cache, files []string, dryRun bool) (int, written := 0 for _, srcFile := range files { - // Clean up stale three-file shards from a previous --three-file run. - removeStaleThreeFile(repoDir, srcFile) + // Clean up stale legacy split shards when writing the supported .graph file. + removeStaleSplitShards(repoDir, srcFile, dryRun) ext := filepath.Ext(srcFile) prefix := CommentPrefix(ext) @@ -288,8 +284,9 @@ func RenderAll(repoDir string, cache *Cache, files []string, dryRun bool) (int, content := RenderGraph(srcFile, cache, prefix) if content == "" { - full := filepath.Join(repoDir, ShardFilename(srcFile)) - _ = os.Remove(full) + if !dryRun { + safeRemove(repoDir, ShardFilename(srcFile)) + } continue } @@ -311,55 +308,6 @@ func RenderAll(repoDir string, cache *Cache, files []string, dryRun bool) (int, return written, nil } -// RenderAllThreeFile generates .calls, .deps, and .impact files per source file. -func RenderAllThreeFile(repoDir string, cache *Cache, files []string, dryRun bool) (int, error) { - sort.Strings(files) - written := 0 - - for _, srcFile := range files { - // Clean up stale single .graph file from a previous non-three-file run. - removeStaleGraph(repoDir, srcFile) - - ext := filepath.Ext(srcFile) - prefix := CommentPrefix(ext) - header := Header(prefix) - goPrefix := "" - if ext == ".go" { - goPrefix = "//go:build ignore\n\npackage ignore\n" - } - - callsPath, depsPath, impactPath := ThreeFileShardNames(srcFile) - - deps := renderDepsSection(srcFile, cache, prefix) - calls := renderCallsSection(srcFile, cache, prefix) - impact := renderImpactSection(srcFile, cache, prefix) - - for _, item := range []struct { - path string - content string - }{ - {depsPath, deps}, - {callsPath, calls}, - {impactPath, impact}, - } { - if item.content == "" { - safeRemove(repoDir, item.path) - continue - } - fullContent := goPrefix + header + item.content + "\n" - if err := WriteShard(repoDir, item.path, 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) diff --git a/internal/shards/render_stale_test.go b/internal/shards/render_stale_test.go index c04d555..76a3c53 100644 --- a/internal/shards/render_stale_test.go +++ b/internal/shards/render_stale_test.go @@ -31,26 +31,6 @@ func testCache() *Cache { return c } -// testCacheNoImpact returns a cache where src/lonely.ts has no importers -// and no callers — so the impact section will be empty. lonely.ts imports -// index.ts (so it has deps) but nothing imports lonely.ts. -func testCacheNoImpact() *Cache { - ir := &api.ShardIR{ - Graph: api.ShardGraph{ - Nodes: []api.Node{ - {ID: "f1", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/index.ts", "name": "index.ts"}}, - {ID: "f2", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/lonely.ts", "name": "lonely.ts"}}, - }, - Relationships: []api.Relationship{ - {ID: "r1", Type: "imports", StartNode: "f2", EndNode: "f1"}, - }, - }, - } - c := NewCache() - c.Build(ir) - return c -} - func touchFile(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { @@ -63,7 +43,7 @@ func touchFile(t *testing.T, path string) { // ── Stale file cleanup ────────────────────────────────────────── -func TestRenderAll_RemovesStaleThreeFiles(t *testing.T) { +func TestRenderAll_RemovesStaleSplitShards(t *testing.T) { dir := t.TempDir() os.MkdirAll(filepath.Join(dir, "src"), 0o755) @@ -88,112 +68,28 @@ func TestRenderAll_RemovesStaleThreeFiles(t *testing.T) { } } -func TestRenderAllThreeFile_RemovesStaleGraphFile(t *testing.T) { +func TestRenderAll_DryRunDoesNotRemoveStaleSplitShards(t *testing.T) { dir := t.TempDir() os.MkdirAll(filepath.Join(dir, "src"), 0o755) - touchFile(t, filepath.Join(dir, "src", "index.graph.ts")) + for _, name := range []string{"index.calls.ts", "index.deps.ts", "index.impact.ts"} { + touchFile(t, filepath.Join(dir, "src", name)) + } cache := testCache() - _, err := RenderAllThreeFile(dir, cache, []string{"src/index.ts"}, false) + _, err := RenderAll(dir, cache, []string{"src/index.ts"}, true) if err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(dir, "src", "index.graph.ts")); !os.IsNotExist(err) { - t.Error("expected index.graph.ts to be removed after three-file render") + t.Error("dry-run should not write index.graph.ts") } - - found := false for _, name := range []string{"index.calls.ts", "index.deps.ts", "index.impact.ts"} { - if _, err := os.Stat(filepath.Join(dir, "src", name)); err == nil { - found = true + if _, err := os.Stat(filepath.Join(dir, "src", name)); err != nil { + t.Errorf("dry-run should not remove %s: %v", name, err) } } - if !found { - t.Error("expected at least one three-file shard to exist") - } -} - -// ── Happy-path content verification ───────────────────────────── - -func TestRenderAllThreeFile_CallsContent(t *testing.T) { - dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "src"), 0o755) - - cache := testCache() - _, err := RenderAllThreeFile(dir, cache, []string{"src/index.ts"}, false) - if err != nil { - t.Fatal(err) - } - - data, err := os.ReadFile(filepath.Join(dir, "src", "index.calls.ts")) - if err != nil { - t.Fatal("index.calls.ts not written") - } - content := string(data) - if !strings.Contains(content, "[calls]") { - t.Error("calls file missing [calls] section header") - } - if !strings.Contains(content, "main") { - t.Error("calls file missing 'main' function") - } - if !strings.Contains(content, "helper") { - t.Error("calls file missing 'helper' callee") - } -} - -func TestRenderAllThreeFile_DepsContent(t *testing.T) { - dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "src"), 0o755) - - cache := testCache() - _, err := RenderAllThreeFile(dir, cache, []string{"src/index.ts"}, false) - if err != nil { - t.Fatal(err) - } - - data, err := os.ReadFile(filepath.Join(dir, "src", "index.deps.ts")) - if err != nil { - t.Fatal("index.deps.ts not written") - } - content := string(data) - if !strings.Contains(content, "[deps]") { - t.Error("deps file missing [deps] section header") - } - if !strings.Contains(content, "imports") { - t.Error("deps file missing 'imports' line") - } - if !strings.Contains(content, "utils.ts") { - t.Error("deps file missing utils.ts import") - } -} - -func TestRenderAllThreeFile_ImpactContent(t *testing.T) { - dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "src"), 0o755) - - cache := testCache() - // utils.ts has an importer (index.ts) so it will have impact data - _, err := RenderAllThreeFile(dir, cache, []string{"src/utils.ts"}, false) - if err != nil { - t.Fatal(err) - } - - data, err := os.ReadFile(filepath.Join(dir, "src", "utils.impact.ts")) - if err != nil { - t.Fatal("utils.impact.ts not written") - } - content := string(data) - if !strings.Contains(content, "[impact]") { - t.Error("impact file missing [impact] section header") - } - if !strings.Contains(content, "risk") { - t.Error("impact file missing risk line") - } - if !strings.Contains(content, "direct") { - t.Error("impact file missing direct count") - } } func TestRenderAll_GraphContent(t *testing.T) { @@ -225,33 +121,6 @@ func TestRenderAll_GraphContent(t *testing.T) { } } -// ── Empty section cleanup ─────────────────────────────────────── - -func TestRenderAllThreeFile_EmptySectionRemovesStaleFile(t *testing.T) { - dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "src"), 0o755) - - // lonely.ts has no importers and no callers — impact will be empty - // Pre-create a stale .impact file - touchFile(t, filepath.Join(dir, "src", "lonely.impact.ts")) - - cache := testCacheNoImpact() - _, err := RenderAllThreeFile(dir, cache, []string{"src/lonely.ts"}, false) - if err != nil { - t.Fatal(err) - } - - // deps file should exist (lonely.ts is imported by index.ts) - if _, err := os.Stat(filepath.Join(dir, "src", "lonely.deps.ts")); err != nil { - t.Error("expected lonely.deps.ts to exist (it has an importer)") - } - - // impact file should be removed (no importers of lonely.ts, no callers) - if _, err := os.Stat(filepath.Join(dir, "src", "lonely.impact.ts")); !os.IsNotExist(err) { - t.Error("expected lonely.impact.ts to be removed (empty impact section)") - } -} - // ── Path traversal on delete ──────────────────────────────────── func TestSafeRemove_BlocksTraversal(t *testing.T) { diff --git a/internal/shards/zip.go b/internal/shards/zip.go index 26adf38..f10ac3c 100644 --- a/internal/shards/zip.go +++ b/internal/shards/zip.go @@ -25,7 +25,8 @@ var zipSkipDirs = map[string]bool{ "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, + "docs-output": true, + ".terraform": true, } var zipSkipFiles = map[string]bool{ @@ -403,7 +404,7 @@ func PrintLanguageBarChart(stats []LangStat, totalFiles int) { // shardTags are the extension tags used by all shard formats. var shardTags = map[string]bool{ "graph": true, // single-file format - "calls": true, // three-file format + "calls": true, // legacy split-shard format "deps": true, "impact": true, } diff --git a/internal/shards/zip_test.go b/internal/shards/zip_test.go index 2ab4768..15a6468 100644 --- a/internal/shards/zip_test.go +++ b/internal/shards/zip_test.go @@ -17,6 +17,9 @@ func TestIsShardFile(t *testing.T) { want bool }{ {"handler.graph.go", true}, + {"handler.calls.go", true}, + {"handler.deps.go", true}, + {"handler.impact.go", true}, {"handler.graph.ts", true}, {"handler.graph.py", true}, {"handler.go", false}, @@ -87,6 +90,13 @@ func TestShouldInclude_SkipDir(t *testing.T) { } } +func TestShouldInclude_DefaultDocsOutputDir(t *testing.T) { + ex := buildExclusions(t.TempDir()) + if shouldInclude("docs-output/index.html", 100, ex) { + t.Error("default docs-output directory should be excluded from analysis uploads") + } +} + func TestShouldInclude_SkipExt(t *testing.T) { ex := &zipExclusions{ skipDirs: map[string]bool{}, diff --git a/npm/README.md b/npm/README.md index 4dd147d..da7fc94 100644 --- a/npm/README.md +++ b/npm/README.md @@ -23,7 +23,7 @@ curl -fsSL https://supermodeltools.com/install | sh supermodel analyze # Watch for changes and keep graphs up to date -supermodel watch +supermodel # Print the Claude Code skill prompt supermodel skill