Skip to content

Commit ff8b3be

Browse files
committed
Add CLAUDE.md staleness checker to CI
Soft warning (never fails) when source files in a directory were committed 30+ days after its CLAUDE.md. Uses git timestamps, respects scoped ownership so nested CLAUDE.md files don't count toward the parent.
1 parent 31cf5fd commit ff8b3be

2 files changed

Lines changed: 250 additions & 0 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
package checks
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"sort"
9+
"strconv"
10+
"strings"
11+
)
12+
13+
const (
14+
// staleDays is the number of days after which a CLAUDE.md is considered
15+
// potentially stale relative to source files in its directory.
16+
staleDays = 30
17+
)
18+
19+
// sourceExtensions lists the file extensions considered as "source code"
20+
// when checking for staleness. Test files and generated files are excluded
21+
// separately by name pattern.
22+
var sourceExtensions = map[string]bool{
23+
".rs": true,
24+
".ts": true,
25+
".svelte": true,
26+
".css": true,
27+
".go": true,
28+
".js": true,
29+
}
30+
31+
// skipDirs lists directories that should never be scanned for source files.
32+
var stalenessSkipDirs = map[string]bool{
33+
"vendor": true,
34+
"node_modules": true,
35+
".cargo-docker": true,
36+
"target": true,
37+
"build": true,
38+
"dist": true,
39+
}
40+
41+
type staleEntry struct {
42+
dir string // relative path to the directory containing CLAUDE.md
43+
daysDiff int // how many days newer the most recent source file is
44+
}
45+
46+
// RunClaudeMdStaleness checks whether CLAUDE.md files might be stale relative
47+
// to the source files in their directory. Always succeeds — emits warnings,
48+
// never fails.
49+
func RunClaudeMdStaleness(ctx *CheckContext) (CheckResult, error) {
50+
// Step 1: Find all CLAUDE.md files in the repo
51+
claudeFiles, err := findClaudeMdFiles(ctx.RootDir)
52+
if err != nil {
53+
return CheckResult{}, fmt.Errorf("failed to find CLAUDE.md files: %w", err)
54+
}
55+
56+
if len(claudeFiles) == 0 {
57+
return Success("No CLAUDE.md files found"), nil
58+
}
59+
60+
// Build a set of directories that have their own CLAUDE.md (for scoping)
61+
claudeDirs := make(map[string]bool)
62+
for _, f := range claudeFiles {
63+
claudeDirs[filepath.Dir(f)] = true
64+
}
65+
66+
// Step 2: For each CLAUDE.md, check staleness
67+
var staleEntries []staleEntry
68+
69+
for _, claudePath := range claudeFiles {
70+
dir := filepath.Dir(claudePath)
71+
72+
claudeTime := gitLastModified(ctx.RootDir, claudePath)
73+
if claudeTime == 0 {
74+
continue // file not tracked by git or no history
75+
}
76+
77+
newestSource := findNewestSourceTime(ctx.RootDir, dir, claudeDirs)
78+
if newestSource == 0 {
79+
continue // no source files found
80+
}
81+
82+
diffDays := int((newestSource - claudeTime) / (60 * 60 * 24))
83+
if diffDays >= staleDays {
84+
relDir, _ := filepath.Rel(ctx.RootDir, filepath.Join(ctx.RootDir, dir))
85+
staleEntries = append(staleEntries, staleEntry{
86+
dir: relDir,
87+
daysDiff: diffDays,
88+
})
89+
}
90+
}
91+
92+
if len(staleEntries) == 0 {
93+
return Success(fmt.Sprintf("%d CLAUDE.md %s checked, all up to date",
94+
len(claudeFiles), Pluralize(len(claudeFiles), "file", "files"))), nil
95+
}
96+
97+
sort.Slice(staleEntries, func(i, j int) bool {
98+
return staleEntries[i].dir < staleEntries[j].dir
99+
})
100+
101+
var sb strings.Builder
102+
for _, e := range staleEntries {
103+
sb.WriteString(fmt.Sprintf(" - %s/ — source files modified %d days after the doc\n", e.dir, e.daysDiff))
104+
}
105+
106+
msg := fmt.Sprintf("%d of %d CLAUDE.md %s may be stale (>%d days behind source):\n%s"+
107+
"Please verify these docs still match the code.",
108+
len(staleEntries),
109+
len(claudeFiles),
110+
Pluralize(len(claudeFiles), "file", "files"),
111+
staleDays,
112+
sb.String(),
113+
)
114+
115+
return CheckResult{
116+
Code: ResultWarning,
117+
Message: msg,
118+
Total: len(claudeFiles),
119+
Issues: len(staleEntries),
120+
Changes: -1,
121+
}, nil
122+
}
123+
124+
// findClaudeMdFiles walks the repo and returns paths to all CLAUDE.md files,
125+
// relative to rootDir. Skips vendor, node_modules, .cargo-docker, and hidden dirs.
126+
func findClaudeMdFiles(rootDir string) ([]string, error) {
127+
var files []string
128+
129+
err := filepath.WalkDir(rootDir, func(path string, d os.DirEntry, err error) error {
130+
if err != nil {
131+
return nil
132+
}
133+
if d.IsDir() {
134+
name := d.Name()
135+
if strings.HasPrefix(name, ".") || stalenessSkipDirs[name] {
136+
return filepath.SkipDir
137+
}
138+
return nil
139+
}
140+
if d.Name() == "CLAUDE.md" {
141+
rel, _ := filepath.Rel(rootDir, path)
142+
files = append(files, rel)
143+
}
144+
return nil
145+
})
146+
147+
return files, err
148+
}
149+
150+
// gitLastModified returns the unix timestamp of the last git commit that
151+
// touched the given file (relative to rootDir). Returns 0 if unknown.
152+
func gitLastModified(rootDir, relPath string) int64 {
153+
cmd := exec.Command("git", "log", "-1", "--format=%ct", "--", relPath)
154+
cmd.Dir = rootDir
155+
out, err := RunCommand(cmd, true)
156+
if err != nil {
157+
return 0
158+
}
159+
ts, err := strconv.ParseInt(strings.TrimSpace(out), 10, 64)
160+
if err != nil {
161+
return 0
162+
}
163+
return ts
164+
}
165+
166+
// findNewestSourceTime finds the most recent git-committed timestamp among
167+
// source files in dir and its subdirectories (relative to rootDir), but stops
168+
// recursing into any subdirectory that has its own CLAUDE.md.
169+
func findNewestSourceTime(rootDir, dir string, claudeDirs map[string]bool) int64 {
170+
var newest int64
171+
absDir := filepath.Join(rootDir, dir)
172+
173+
_ = filepath.WalkDir(absDir, func(path string, d os.DirEntry, err error) error {
174+
if err != nil {
175+
return nil
176+
}
177+
if d.IsDir() {
178+
rel, _ := filepath.Rel(rootDir, path)
179+
// Skip subdirectories that have their own CLAUDE.md (but not the
180+
// root dir itself — that's the one we're checking).
181+
if rel != dir && claudeDirs[rel] {
182+
return filepath.SkipDir
183+
}
184+
name := d.Name()
185+
if strings.HasPrefix(name, ".") || stalenessSkipDirs[name] {
186+
return filepath.SkipDir
187+
}
188+
return nil
189+
}
190+
191+
// Skip non-source files
192+
ext := filepath.Ext(d.Name())
193+
if !sourceExtensions[ext] {
194+
return nil
195+
}
196+
197+
// Skip test files and generated files
198+
name := d.Name()
199+
if isTestOrGenerated(name) {
200+
return nil
201+
}
202+
203+
// Skip CLAUDE.md files themselves
204+
if name == "CLAUDE.md" {
205+
return nil
206+
}
207+
208+
rel, _ := filepath.Rel(rootDir, path)
209+
ts := gitLastModified(rootDir, rel)
210+
if ts > newest {
211+
newest = ts
212+
}
213+
return nil
214+
})
215+
216+
return newest
217+
}
218+
219+
// isTestOrGenerated returns true if the filename looks like a test file or
220+
// generated file that shouldn't count toward staleness.
221+
func isTestOrGenerated(name string) bool {
222+
lower := strings.ToLower(name)
223+
224+
// Test files
225+
if strings.HasSuffix(lower, "_test.go") ||
226+
strings.HasSuffix(lower, ".test.ts") ||
227+
strings.HasSuffix(lower, ".test.js") ||
228+
strings.HasSuffix(lower, ".spec.ts") ||
229+
strings.HasSuffix(lower, ".spec.js") {
230+
return true
231+
}
232+
233+
// Generated files
234+
if strings.HasSuffix(lower, ".generated.ts") ||
235+
strings.HasSuffix(lower, ".generated.go") ||
236+
strings.HasSuffix(lower, ".gen.go") ||
237+
strings.HasSuffix(lower, ".pb.go") {
238+
return true
239+
}
240+
241+
return false
242+
}

scripts/check/checks/registry.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,14 @@ var AllChecks = []CheckDefinition{
376376
DependsOn: nil,
377377
Run: RunFileLength,
378378
},
379+
{
380+
ID: "claude-md-staleness",
381+
DisplayName: "CLAUDE.md staleness",
382+
App: AppOther,
383+
Tech: "📏 Metrics",
384+
DependsOn: nil,
385+
Run: RunClaudeMdStaleness,
386+
},
379387
}
380388

381389
// GetCheckByID returns a check definition by its ID or nickname.

0 commit comments

Comments
 (0)