|
| 1 | +package checks |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "os" |
| 6 | + "os/exec" |
| 7 | + "path/filepath" |
| 8 | + "sort" |
| 9 | + "strings" |
| 10 | +) |
| 11 | + |
| 12 | +// reminderSourceExts lists file extensions considered "source code" for the |
| 13 | +// purpose of the doc-touch reminder. Other files (json, md, lockfiles, etc.) |
| 14 | +// rarely warrant a CLAUDE.md update on their own, so they don't count. |
| 15 | +var reminderSourceExts = map[string]bool{ |
| 16 | + ".rs": true, |
| 17 | + ".ts": true, |
| 18 | + ".svelte": true, |
| 19 | + ".css": true, |
| 20 | + ".go": true, |
| 21 | + ".js": true, |
| 22 | +} |
| 23 | + |
| 24 | +// reminderSkipDirs lists directories that should never be scanned for CLAUDE.md |
| 25 | +// files (matches the directories `git status` would never report on either). |
| 26 | +var reminderSkipDirs = map[string]bool{ |
| 27 | + "vendor": true, |
| 28 | + "node_modules": true, |
| 29 | + ".cargo-docker": true, |
| 30 | + "target": true, |
| 31 | + "build": true, |
| 32 | + "dist": true, |
| 33 | +} |
| 34 | + |
| 35 | +type reminderMiss struct { |
| 36 | + dir string |
| 37 | + count int |
| 38 | +} |
| 39 | + |
| 40 | +// RunClaudeMdReminder warns when source files were changed (in the working tree |
| 41 | +// or on the current branch vs the base branch) under a directory that has a |
| 42 | +// colocated CLAUDE.md, but the CLAUDE.md itself was not also touched. Always |
| 43 | +// succeeds — emits warnings, never fails. |
| 44 | +// |
| 45 | +// The intent is a low-friction nudge to the agent that just made the change: |
| 46 | +// "you touched code under X/, did you mean to update X/CLAUDE.md too?" |
| 47 | +func RunClaudeMdReminder(ctx *CheckContext) (CheckResult, error) { |
| 48 | + claudeFiles, err := findClaudeMdFiles(ctx.RootDir) |
| 49 | + if err != nil { |
| 50 | + return CheckResult{}, fmt.Errorf("failed to find CLAUDE.md files: %w", err) |
| 51 | + } |
| 52 | + if len(claudeFiles) == 0 { |
| 53 | + return Success("No CLAUDE.md files found"), nil |
| 54 | + } |
| 55 | + |
| 56 | + // Map dir → CLAUDE.md path so we can both look up enclosing docs by directory |
| 57 | + // and check whether each doc itself was touched. |
| 58 | + claudeDirs := make(map[string]string, len(claudeFiles)) |
| 59 | + for _, f := range claudeFiles { |
| 60 | + claudeDirs[filepath.Dir(f)] = f |
| 61 | + } |
| 62 | + |
| 63 | + changed, err := changedFiles(ctx.RootDir) |
| 64 | + if err != nil { |
| 65 | + return CheckResult{}, fmt.Errorf("failed to enumerate changed files: %w", err) |
| 66 | + } |
| 67 | + if len(changed) == 0 { |
| 68 | + return Success(fmt.Sprintf("No changes; %d CLAUDE.md %s left alone", |
| 69 | + len(claudeFiles), Pluralize(len(claudeFiles), "file", "files"))), nil |
| 70 | + } |
| 71 | + |
| 72 | + changedClaude := make(map[string]bool) |
| 73 | + bucket := make(map[string]int) // CLAUDE.md dir → count of changed source files under it |
| 74 | + for _, f := range changed { |
| 75 | + if filepath.Base(f) == "CLAUDE.md" { |
| 76 | + changedClaude[f] = true |
| 77 | + continue |
| 78 | + } |
| 79 | + if !reminderSourceExts[filepath.Ext(f)] { |
| 80 | + continue |
| 81 | + } |
| 82 | + if dir := nearestClaudeDir(f, claudeDirs); dir != "" { |
| 83 | + bucket[dir]++ |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + var misses []reminderMiss |
| 88 | + for dir, count := range bucket { |
| 89 | + if changedClaude[claudeDirs[dir]] { |
| 90 | + continue |
| 91 | + } |
| 92 | + misses = append(misses, reminderMiss{dir, count}) |
| 93 | + } |
| 94 | + |
| 95 | + if len(misses) == 0 { |
| 96 | + return Success(fmt.Sprintf("All touched directories had matching CLAUDE.md updates (%d %s checked)", |
| 97 | + len(claudeFiles), Pluralize(len(claudeFiles), "doc", "docs"))), nil |
| 98 | + } |
| 99 | + |
| 100 | + sort.Slice(misses, func(i, j int) bool { return misses[i].dir < misses[j].dir }) |
| 101 | + |
| 102 | + var sb strings.Builder |
| 103 | + for _, m := range misses { |
| 104 | + sb.WriteString(fmt.Sprintf(" - %s/ (%d %s)\n", m.dir, m.count, Pluralize(m.count, "file", "files"))) |
| 105 | + } |
| 106 | + |
| 107 | + msg := fmt.Sprintf("%d %s with source changes but no CLAUDE.md update:\n%s"+ |
| 108 | + "Just a friendly reminder — if your changes affect the documented architecture, decisions, or gotchas, consider updating these.", |
| 109 | + len(misses), |
| 110 | + Pluralize(len(misses), "directory", "directories"), |
| 111 | + sb.String(), |
| 112 | + ) |
| 113 | + |
| 114 | + return CheckResult{ |
| 115 | + Code: ResultWarning, |
| 116 | + Message: msg, |
| 117 | + Total: len(claudeFiles), |
| 118 | + Issues: len(misses), |
| 119 | + Changes: -1, |
| 120 | + }, nil |
| 121 | +} |
| 122 | + |
| 123 | +// findClaudeMdFiles walks the repo and returns paths to all CLAUDE.md files |
| 124 | +// relative to rootDir. Skips vendor, node_modules, build outputs, hidden dirs. |
| 125 | +func findClaudeMdFiles(rootDir string) ([]string, error) { |
| 126 | + var files []string |
| 127 | + err := filepath.WalkDir(rootDir, func(path string, d os.DirEntry, err error) error { |
| 128 | + if err != nil { |
| 129 | + return nil |
| 130 | + } |
| 131 | + if d.IsDir() { |
| 132 | + name := d.Name() |
| 133 | + if strings.HasPrefix(name, ".") || reminderSkipDirs[name] { |
| 134 | + return filepath.SkipDir |
| 135 | + } |
| 136 | + return nil |
| 137 | + } |
| 138 | + if d.Name() == "CLAUDE.md" { |
| 139 | + rel, _ := filepath.Rel(rootDir, path) |
| 140 | + files = append(files, rel) |
| 141 | + } |
| 142 | + return nil |
| 143 | + }) |
| 144 | + return files, err |
| 145 | +} |
| 146 | + |
| 147 | +// changedFiles returns repo-relative paths of files that differ between the |
| 148 | +// working tree and the base branch. The set is the union of: |
| 149 | +// |
| 150 | +// - `git status --porcelain=v1 -z` (staged, unstaged, untracked) |
| 151 | +// - `git diff --name-only -z <base>...HEAD` (committed on this branch since |
| 152 | +// diverging from base) |
| 153 | +// |
| 154 | +// Renames and copies contribute both old and new paths so the doc check fires |
| 155 | +// on either side. If neither `origin/main` nor `main` exists, only the working |
| 156 | +// tree is consulted. |
| 157 | +func changedFiles(rootDir string) ([]string, error) { |
| 158 | + seen := make(map[string]bool) |
| 159 | + |
| 160 | + statusOut, err := runGitOut(rootDir, "status", "--porcelain=v1", "-z") |
| 161 | + if err != nil { |
| 162 | + return nil, err |
| 163 | + } |
| 164 | + for _, p := range parsePorcelainZ(statusOut) { |
| 165 | + seen[p] = true |
| 166 | + } |
| 167 | + |
| 168 | + if base := pickBaseRef(rootDir); base != "" { |
| 169 | + // `<base>...HEAD` diffs against the merge-base, ignoring commits on the |
| 170 | + // base branch since divergence. Falls back gracefully if the diff fails |
| 171 | + // (for example, shallow clone without the merge-base). |
| 172 | + if diffOut, err := runGitOut(rootDir, "diff", "--name-only", "-z", base+"...HEAD"); err == nil { |
| 173 | + for p := range strings.SplitSeq(diffOut, "\x00") { |
| 174 | + if p != "" { |
| 175 | + seen[p] = true |
| 176 | + } |
| 177 | + } |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + out := make([]string, 0, len(seen)) |
| 182 | + for p := range seen { |
| 183 | + out = append(out, p) |
| 184 | + } |
| 185 | + return out, nil |
| 186 | +} |
| 187 | + |
| 188 | +// pickBaseRef returns the first existing ref from the candidate list, or "" |
| 189 | +// if none exist (single-branch repo, fresh init, etc.). |
| 190 | +func pickBaseRef(rootDir string) string { |
| 191 | + for _, ref := range []string{"origin/main", "main"} { |
| 192 | + if _, err := runGitOut(rootDir, "rev-parse", "--verify", "--quiet", ref); err == nil { |
| 193 | + return ref |
| 194 | + } |
| 195 | + } |
| 196 | + return "" |
| 197 | +} |
| 198 | + |
| 199 | +func runGitOut(rootDir string, args ...string) (string, error) { |
| 200 | + cmd := exec.Command("git", args...) |
| 201 | + cmd.Dir = rootDir |
| 202 | + var stdout, stderr strings.Builder |
| 203 | + cmd.Stdout = &stdout |
| 204 | + cmd.Stderr = &stderr |
| 205 | + if err := cmd.Run(); err != nil { |
| 206 | + return "", fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, stderr.String()) |
| 207 | + } |
| 208 | + return stdout.String(), nil |
| 209 | +} |
| 210 | + |
| 211 | +// parsePorcelainZ extracts file paths from `git status --porcelain=v1 -z` output. |
| 212 | +// |
| 213 | +// Each record is `XY<space>path` terminated by NUL. Renames (`R`) and copies (`C`) |
| 214 | +// add a second NUL-terminated field after the new path: the original path. We |
| 215 | +// surface both so doc-touch attribution works whichever side the agent thinks of. |
| 216 | +func parsePorcelainZ(s string) []string { |
| 217 | + var paths []string |
| 218 | + rest := s |
| 219 | + for len(rest) > 0 { |
| 220 | + idx := strings.IndexByte(rest, 0) |
| 221 | + if idx < 0 { |
| 222 | + break |
| 223 | + } |
| 224 | + entry := rest[:idx] |
| 225 | + rest = rest[idx+1:] |
| 226 | + if len(entry) < 4 { |
| 227 | + continue |
| 228 | + } |
| 229 | + xy := entry[:2] |
| 230 | + paths = append(paths, entry[3:]) |
| 231 | + if xy[0] == 'R' || xy[0] == 'C' { |
| 232 | + origIdx := strings.IndexByte(rest, 0) |
| 233 | + if origIdx >= 0 { |
| 234 | + if orig := rest[:origIdx]; orig != "" { |
| 235 | + paths = append(paths, orig) |
| 236 | + } |
| 237 | + rest = rest[origIdx+1:] |
| 238 | + } |
| 239 | + } |
| 240 | + } |
| 241 | + return paths |
| 242 | +} |
| 243 | + |
| 244 | +// nearestClaudeDir walks up from filePath's directory and returns the nearest |
| 245 | +// directory that has a CLAUDE.md, or "" if no ancestor has one. |
| 246 | +func nearestClaudeDir(filePath string, claudeDirs map[string]string) string { |
| 247 | + dir := filepath.Dir(filePath) |
| 248 | + for { |
| 249 | + if _, ok := claudeDirs[dir]; ok { |
| 250 | + return dir |
| 251 | + } |
| 252 | + parent := filepath.Dir(dir) |
| 253 | + if parent == dir { |
| 254 | + return "" |
| 255 | + } |
| 256 | + dir = parent |
| 257 | + } |
| 258 | +} |
0 commit comments