Skip to content

Commit 60e30be

Browse files
committed
Tooling: Replace claude-md-staleness with claude-md-reminder
The old check warned 30+ days after the fact based on git history — by then the agent who introduced the drift was long gone, and the warning landed on whoever next ran `check.sh`. Useless as an in-loop signal. The new check looks at the working tree (staged + unstaged + untracked) and the current branch vs `origin/main` (or `main` as fallback). For each changed source file (`.rs`, `.ts`, `.svelte`, `.css`, `.go`, `.js`), it walks up to the nearest enclosing `CLAUDE.md` and warns if the doc itself wasn't also touched. Friendly tone, warn-only, never blocks. - New: `scripts/check/checks/claude-md-reminder.go` + tests - Deleted: `scripts/check/checks/claude-md-staleness.go` - Registry: `claude-md-staleness` → `claude-md-reminder` - Updated `scripts/check/CLAUDE.md` table entry
1 parent 7333b13 commit 60e30be

5 files changed

Lines changed: 473 additions & 282 deletions

File tree

scripts/check/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ before tests.
194194
| Website | Docker | docker-build |
195195
| API server | TS | oxfmt, eslint, typecheck, tests |
196196
| Scripts | Go | gofmt, go-vet, staticcheck, ineffassign, misspell, gocyclo, nilaway, deadcode, go-tests |
197-
| Other | Metrics | file-length (warn-only), CLAUDE.md-staleness (warn-only), changelog-commit-links |
197+
| Other | Metrics | file-length (warn-only), CLAUDE.md-reminder (warn-only), changelog-commit-links |
198198

199199
## Output format
200200

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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

Comments
 (0)