Files
495f72c43d feat(cli): bdrive stale — flag the docs your code has outgrown (#165)
Staleness today is age and only age: Insights.tsx's STALE_DAYS = 30 flags a
fresh doc nobody reads and misses the one that costs a team — a doc edited last
week describing code that moved yesterday.

`bdrive stale` measures the other thing. It scans synced markdown for
references to other synced files and reports every doc that links to something
written after the doc itself.

It is `bdrive grep` with a different per-file predicate, and keeps that
command's whole posture: LoadProject not ResolveMount (a read must not enroll
the device), a Stat-guarded store.Open (a read must not create a volume), no
session, no flock, no network, safeField on everything printed.

The one place it must not copy the obvious approach is dating a file.
materialize never calls os.Chtimes, so a peer's January edit carries this
device's mtime — on a freshly cloned machine every mtime is within seconds of
every other, and mtime comparison would report nothing on exactly the machine
that most needs the answer. Dates come from the journal: st.AllOps() folded to
the max syncer.DisplayTime per path, which also drops a forged year-9999 stamp
rather than dating that path to year 1 and flagging everything that links to it.

Resolution is the filter — a URL, a ../ escape, a made-up path is silently
ignored — so a .bdriveignore rule or a narrowed `bdrive scope` excludes a file
here exactly as it excludes it from sync.

Exit status is 0 whether or not anything is stale. grep's "1 means nothing
found" convention inverts here: it would fail on a clean project, and this is
advisory in the same sense the agent hook's context is.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:09:47 -07:00

319 lines
10 KiB
Go

package main
import (
"bufio"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/store"
"github.com/runbear-io/beardrive/internal/syncer"
)
// staleLinkRe matches a markdown inline link's target: [label](target).
var staleLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)`)
// staleWikiRe matches Obsidian-style [[target]] and [[target|label]] links.
// Copied from internal/webapp/markdown.go rather than shared: importing the
// server package into a local read command to save one line is the wrong
// trade.
var staleWikiRe = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`)
// stalePathRe matches a bare path-shaped token — at least one slash, and no
// wrapping punctuation, so a backticked `cmd/bdrive/grep.go` yields the path
// and not the backticks. Resolution is the real filter, so this stays loose.
var stalePathRe = regexp.MustCompile(`[A-Za-z0-9._~@+-]+(?:/[A-Za-z0-9._~@+-]+)+`)
// staleSchemeRe matches a URL scheme, so https:// and mailto: never resolve.
var staleSchemeRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*:`)
func staleCmd() *cobra.Command {
var (
filesOnly bool
limit int
)
c := &cobra.Command{
Use: "stale [folder]",
Short: "Find docs whose code has moved on since they were written",
Long: `Report synced markdown that references files written after the doc itself.
Staleness here is not age: a doc is outgrown when a file it links to has a
newer last-write time than the doc. Only the files this project actually syncs
are scanned — a .bdriveignore rule or a narrowed ` + "`bdrive scope`" + ` excludes a file
from this command exactly as it excludes it from sync.
Write times come from the local journal, not from the filesystem: materialize
stamps a peer's file with THIS device's mtime, so on a freshly synced machine
every mtime is the same and only the journal still knows when each file was
really written.
It is a pure read with no daemon, no lock, and no network, so it works offline
and never blocks on a sync in progress. Exit status is 0 whether or not
anything is stale — this is advisory output, not a gate.`,
Example: ` bdrive stale # every outgrown doc, with the references that aged it
bdrive stale -l # paths only, one per line
bdrive stale -n 5 # the five worst`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runStale(cmd, args, filesOnly, limit)
},
}
c.Flags().BoolVarP(&filesOnly, "files-with-matches", "l", false, "print outgrown paths only, one per line")
// -n means the same here as in `bdrive log` and `bdrive grep`: max rows out.
c.Flags().IntVarP(&limit, "limit", "n", 50, "max docs printed (0 = all)")
return c
}
// staleRef is one reference that has outrun its doc.
type staleRef struct {
path string
gap time.Duration
}
// staleDoc is one outgrown doc and the references that aged it, worst first.
type staleDoc struct {
path string
refs []staleRef
}
func runStale(cmd *cobra.Command, folderArg []string, filesOnly bool, limit int) error {
folder, err := absFolder(folderArg)
if err != nil {
return err
}
// LoadProject, not ResolveMount: ResolveMount self-heals the registry
// path, i.e. it enrolls this device. A read-only query must not have that
// side effect — the same rule grep follows.
proj, found, err := config.LoadProject(folder)
if err != nil {
return err
}
if !found {
return fmt.Errorf("%s is not a beardrive project (run `bdrive init` there first)", folder)
}
// The accepted rules and the journal both come from the volume store,
// opened the way grep opens it: Stat-guarded, because store.Open MkdirAlls
// and a read must not create a volume for a project that has never synced,
// and unlocked, because store.Open takes no volume flock — a running
// daemon never blocks this.
var (
accepted string
ops []journal.Op
)
if vdir, verr := config.VolumeDir(proj.ID); verr == nil && dirExists(vdir) {
if st, serr := store.Open(vdir); serr == nil {
if sync, serr := st.LoadSync(); serr == nil {
accepted = sync.IgnoreAccepted
}
if all, serr := st.AllOps(); serr == nil {
ops = all
}
}
}
paths, err := syncer.SyncedFiles(folder, proj.Include, accepted)
if err != nil {
return err
}
out := cmd.OutOrStdout()
written := staleWriteTimes(ops)
if len(written) == 0 {
fmt.Fprintln(out, "no history yet")
return nil
}
synced := make(map[string]bool, len(paths))
for _, rel := range paths {
synced[rel] = true
}
var docs []staleDoc
for _, rel := range paths {
if !isMarkdownPath(rel) {
continue
}
docTime, ok := written[rel]
if !ok {
continue // never synced: nothing to date it by
}
var refs []staleRef
for _, ref := range staleRefs(filepath.Join(folder, rel), rel, synced) {
refTime, ok := written[ref]
if !ok || !refTime.After(docTime) {
continue
}
refs = append(refs, staleRef{path: ref, gap: refTime.Sub(docTime)})
}
if len(refs) == 0 {
continue
}
sort.Slice(refs, func(i, j int) bool { return refs[i].gap > refs[j].gap })
docs = append(docs, staleDoc{path: rel, refs: refs})
}
// Worst first: the doc with the reference that has outrun it furthest.
sort.SliceStable(docs, func(i, j int) bool { return docs[i].refs[0].gap > docs[j].refs[0].gap })
total := 0
for _, d := range docs {
total += len(d.refs)
}
shown := docs
truncated := false
if limit > 0 && len(shown) > limit {
shown, truncated = shown[:limit], true
}
for _, d := range shown {
// Every path here is a string a teammate chose — a file name, or text
// inside a synced doc. safeField, or a lone CR repaints the row and
// U+202E reverses it.
name := safeField(d.path, 160)
if filesOnly {
fmt.Fprintln(out, name)
continue
}
fmt.Fprintf(out, "%-40s %-16s (oldest gap %s)\n",
name, plural(len(d.refs), "file")+" newer", staleGap(d.refs[0].gap))
for _, r := range d.refs {
fmt.Fprintf(out, " %-38s %s newer\n", safeField(r.path, 160), staleGap(r.gap))
}
}
if !filesOnly {
if len(docs) == 0 {
fmt.Fprintln(out, "no outgrown docs")
} else {
fmt.Fprintf(out, "\n%s, %s\n", plural(len(docs), "outgrown doc"), plural(total, "stale reference"))
}
}
if truncated {
fmt.Fprintf(out, "output limited to %s — use -n 0 for all\n", plural(limit, "doc"))
}
// Exit 0 either way. grep's "1 means nothing found" convention inverts
// here — it would fail on a clean project — and this is advisory in the
// same sense the agent hook's context is: nothing is blocked by it.
return nil
}
// staleWriteTimes dates every path from the journal, newest write wins.
//
// Max by DisplayTime, not the newest op under journal.Less: DisplayTime is
// what `bdrive log` sorts by, and it returns the zero time for an op stamped
// in the future — so taking the causally-newest op would date that path to
// year 1 and flag every doc referencing it. Max discards the zero naturally.
func staleWriteTimes(ops []journal.Op) map[string]time.Time {
written := make(map[string]time.Time, len(ops))
for _, op := range ops {
if op.Kind != journal.KindPut {
continue
}
t := syncer.DisplayTime(op)
if t.IsZero() {
continue // an op we cannot date does not get to date a path
}
if cur, ok := written[op.Path]; !ok || t.After(cur) {
written[op.Path] = t
}
}
return written
}
func isMarkdownPath(rel string) bool {
switch strings.ToLower(path.Ext(rel)) {
case ".md", ".markdown":
return true
}
return false
}
// staleRefs returns the synced paths one doc references, deduped. Unreadable
// files are skipped, never fatal, the same posture the scan takes.
func staleRefs(abs, rel string, synced map[string]bool) []string {
f, err := os.Open(abs)
if err != nil {
return nil
}
defer f.Close()
docDir := path.Dir(rel)
seen := map[string]bool{}
var refs []string
keep := func(cand string) {
target, ok := resolveRef(docDir, cand, synced)
if !ok || target == rel || seen[target] {
return
}
seen[target] = true
refs = append(refs, target)
}
// grep's bounded scanner: a minified file that happens to be named .md
// must not be buffered whole.
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64<<10), maxLineScan)
for sc.Scan() {
line := sc.Text()
for _, m := range staleLinkRe.FindAllStringSubmatch(line, -1) {
keep(m[1])
}
for _, m := range staleWikiRe.FindAllStringSubmatch(line, -1) {
// A wikilink names a doc, usually without its extension.
keep(m[1])
keep(m[1] + ".md")
}
for _, m := range stalePathRe.FindAllString(line, -1) {
keep(m)
}
}
return refs // sc.Err() ignored: an over-long line ends this file, not the run
}
// resolveRef turns one candidate string into a synced path, or drops it.
// Resolution IS the filter: anything that does not land on a file this project
// syncs is not a reference, so a loose extractor upstream costs nothing.
func resolveRef(docDir, cand string, synced map[string]bool) (string, bool) {
cand = strings.TrimSpace(cand)
// A trailing anchor or query is not part of the path.
if i := strings.IndexAny(cand, "#?"); i >= 0 {
cand = cand[:i]
}
cand = strings.TrimRight(cand, `.,;:!?"'`)
if cand == "" || strings.HasPrefix(cand, "/") || staleSchemeRe.MatchString(cand) {
return "", false // absolute, protocol-relative (//host), or a URL
}
tries := []string{path.Clean(cand)}
if docDir != "." {
tries = append([]string{path.Join(docDir, cand)}, tries...)
}
for _, p := range tries {
// Never leave the mount, and never name the root itself.
if p == "." || p == "/" || strings.HasPrefix(p, "../") || strings.HasPrefix(p, "/") {
continue
}
if synced[p] {
return p, true
}
}
return "", false
}
// staleGap renders how far a reference has outrun its doc. Sub-day gaps read
// as <1d rather than 0d, which would look like no gap at all.
func staleGap(d time.Duration) string {
days := int(d.Hours() / 24)
if days < 1 {
return "<1d"
}
return fmt.Sprintf("%dd", days)
}