From 495f72c43d5badec5cb1f62f0e163a0907a5469e Mon Sep 17 00:00:00 2001 From: "Snow Lee (Sungwon)" Date: Tue, 18 Aug 2026 15:09:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(cli):=20bdrive=20stale=20=E2=80=94=20flag?= =?UTF-8?q?=20the=20docs=20your=20code=20has=20outgrown=20(#165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 1 + architecture/cli-sync.md | 4 +- cmd/bdrive/main.go | 1 + cmd/bdrive/stale.go | 318 +++++++++++++++ cmd/bdrive/stale_test.go | 433 +++++++++++++++++++++ web/docs/src/content/docs/reference/cli.md | 44 +++ 6 files changed, 799 insertions(+), 2 deletions(-) create mode 100644 cmd/bdrive/stale.go create mode 100644 cmd/bdrive/stale_test.go diff --git a/README.md b/README.md index 55e66b9..0a12929 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,7 @@ hub's own storage, never something a syncing client points at directly: | `bdrive stop [folder]` | Stop syncing, including agent sync hooks (files stay; `bdrive init` resumes) | | `bdrive scope [add\|rm ]` | Show or change which subfolders sync — edits the managed block of `.bdriveignore` rules that `init --only` writes, so no one hand-writes negation syntax. The daemon picks changes up in seconds; `rm` deletes nothing, locally or on the hub. `--explain` lists every path in the folder split into what syncs and what does not, so you can verify what leaves this machine (pure read — no daemon, no lock, no network) | | `bdrive grep [folder]` | Search the text **inside** the files a project syncs — Go RE2 regexp, or a literal with `-F`; `-i` ignores case, `-l` prints matching paths only, `-n` caps the lines printed (default 200, `0` = all). Output is `path:line: text`. Only files the project actually syncs are searched, so a `.bdriveignore` rule or a narrowed `bdrive scope` excludes a file from search exactly as it excludes it from sync; binary files are skipped. Pure local read — no daemon, no lock, no network, works offline and never blocks a sync in progress. Exit status 0 on match, 1 on none, so it composes in scripts | +| `bdrive stale [folder]` | Find docs the code has outgrown: synced markdown (`.md`/`.markdown`) that links to a file written **after** the doc itself. Staleness here is not age — a doc goes stale when what it describes moves. Write times come from the **journal**, not `os.Stat`: materialize stamps a peer's file with this device's mtime, so on a freshly synced machine every mtime is identical and only the journal still knows. `-l` prints outgrown paths only, `-n` caps the docs printed (default 50, `0` = all). A reference that does not resolve to a file this project syncs — a URL, a `../` escape, a made-up path — is silently ignored. Pure local read — no daemon, no lock, no network. **Exit status is 0 whether or not anything is stale**: this is advisory, not a gate | | `bdrive forget ...` | Stop syncing a path *and* remove it from the hub — adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices | | `bdrive url [path]` | Internal hub link for a file/folder (sign-in + membership required; `--sync` pushes first; no arg = project home). Computed locally | | `bdrive share ` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) | diff --git a/architecture/cli-sync.md b/architecture/cli-sync.md index 0b8c239..7dc1c4d 100644 --- a/architecture/cli-sync.md +++ b/architecture/cli-sync.md @@ -216,13 +216,13 @@ classDiagram class Commands { init login logout - sync stop scope grep forget status log + sync stop scope grep stale forget status log restore url share export import web daemon hooks read-log resume autostart } note for Commands "cmd/bdrive — thin cobra layer; init is the front door (one command: login + hooks + sync + link), stop pauses" - note for Commands "grep searches file CONTENTS in the working folder via syncer.SyncedFiles — LoadProject not ResolveMount (a read must not enroll the device), no session, no flock, and the volume store is opened only if it already exists, so a search creates nothing. Exit 1 on no match is a status, not an error (errNoMatch + SilenceErrors)" + note for Commands "grep searches file CONTENTS in the working folder via syncer.SyncedFiles — LoadProject not ResolveMount (a read must not enroll the device), no session, no flock, and the volume store is opened only if it already exists, so a search creates nothing. Exit 1 on no match is a status, not an error (errNoMatch + SilenceErrors). stale copies that whole posture and swaps the predicate: it extracts path-shaped references from synced markdown, keeps only the ones resolving into the SyncedFiles set, and flags a doc whose reference was written later. It dates a path from the JOURNAL, not os.Stat — materialize stamps a peer's file with this device's mtime, so mtime comparison reports nothing on a freshly cloned machine — folding st.AllOps() to the max syncer.DisplayTime per path, which drops a forged future stamp instead of dating that path to year 1. Unlike grep it exits 0 either way: advisory output, not a gate" note for Commands "Every peer-authored string status / log / whoami print goes through safeField first — a teammate's file name is attacker-controlled text landing in your terminal, and an escape sequence there rewrites the line above it. grep runs BOTH the path and the matched line through it — a matched line is a teammate's file content, the widest version of that surface. login now does PKCE on the loopback callback (no compat arm) and both its client and init's refuse to follow a redirect off the hub's origin with the device token attached" class Templates { diff --git a/cmd/bdrive/main.go b/cmd/bdrive/main.go index 60fda16..9ddcd67 100644 --- a/cmd/bdrive/main.go +++ b/cmd/bdrive/main.go @@ -54,6 +54,7 @@ everything keeps working offline; changes sync when the remote is reachable.`, stopCmd(), scopeCmd(), grepCmd(), + staleCmd(), forgetCmd(), syncCmd(), readLogCmd(), diff --git a/cmd/bdrive/stale.go b/cmd/bdrive/stale.go new file mode 100644 index 0000000..1494418 --- /dev/null +++ b/cmd/bdrive/stale.go @@ -0,0 +1,318 @@ +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) +} diff --git a/cmd/bdrive/stale_test.go b/cmd/bdrive/stale_test.go new file mode 100644 index 0000000..cdc18a0 --- /dev/null +++ b/cmd/bdrive/stale_test.go @@ -0,0 +1,433 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/runbear-io/beardrive/internal/config" + "github.com/runbear-io/beardrive/internal/journal" + "github.com/runbear-io/beardrive/internal/store" +) + +// staleRun drives the real cobra command and returns its combined output. +func staleRun(t *testing.T, args ...string) (string, error) { + t.Helper() + c := staleCmd() + var out bytes.Buffer + c.SetOut(&out) + c.SetErr(&out) + c.SetArgs(args) + err := c.Execute() + return out.String(), err +} + +// staleProject enrolls a folder and syncs it, so the journal — the only clock +// this command reads — actually has ops in it. The remote is unreachable on +// purpose: the cycle degrades offline and the local journal is still written. +// ages dates each file's mtime, which the scan carries into Op.Mtime. +func staleProject(t *testing.T, files map[string]string, ages map[string]time.Duration) string { + t.Helper() + t.Setenv("BDRIVE_HOME", t.TempDir()) + folder := t.TempDir() + folder, _ = filepath.EvalSymlinks(folder) + if _, err := config.SaveProject(folder, config.Project{ + Volume: "wiki", + Remote: "https://hub.example.com/p/p-12345678", + }); err != nil { + t.Fatal(err) + } + if _, _, err := config.EnrollMount(folder); err != nil { + t.Fatal(err) + } + now := time.Now() + for rel, body := range files { + abs := filepath.Join(folder, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if age, ok := ages[rel]; ok { + when := now.Add(age) + if err := os.Chtimes(abs, when, when); err != nil { + t.Fatal(err) + } + } + } + sync := syncCmd() + sync.SetOut(&bytes.Buffer{}) + sync.SetErr(&bytes.Buffer{}) + sync.SetArgs([]string{folder}) + if err := sync.Execute(); err != nil { + t.Fatalf("sync: %v", err) + } + return folder +} + +const day = 24 * time.Hour + +// The core verdict: a doc is outgrown when something it references was written +// after it, and a doc whose references are all older is not. +func TestStaleReportsOutgrownDocsOnly(t *testing.T) { + folder := staleProject(t, map[string]string{ + "docs/architecture.md": "the syncer lives in [the syncer](../internal/syncer/syncer.go)\n", + "docs/current.md": "see `internal/journal/journal.go` for the model\n", + "internal/syncer/syncer.go": "package syncer\n", + "internal/journal/journal.go": "package journal\n", + }, map[string]time.Duration{ + "docs/architecture.md": -40 * day, + "internal/syncer/syncer.go": -2 * day, // 38 days newer than the doc + "docs/current.md": -1 * day, + "internal/journal/journal.go": -30 * day, // older than its doc + }) + + out, err := staleRun(t, folder) + if err != nil { + t.Fatalf("stale: %v\n%s", err, out) + } + for _, want := range []string{ + "docs/architecture.md", + "internal/syncer/syncer.go", + "38d newer", + "1 outgrown doc, 1 stale reference", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "docs/current.md") { + t.Errorf("a doc newer than everything it references was flagged:\n%s", out) + } +} + +// The acceptance criterion that fails on any mtime-based implementation: after +// a fresh materialize every file on disk carries this device's write time, so +// mtime comparison reports nothing on exactly the machine that most needs the +// answer. The journal still knows. +func TestStaleDatesFromTheJournalNotMtime(t *testing.T) { + folder := staleProject(t, map[string]string{ + "guide.md": "the loop is in [worker](src/worker.go)\n", + "src/worker.go": "package src\n", + }, map[string]time.Duration{ + "guide.md": -20 * day, + "src/worker.go": -3 * day, + }) + + // Simulate a fresh clone: materialize writes every blob now, so every + // mtime on disk collapses onto one instant. + same := time.Now() + for _, rel := range []string{"guide.md", "src/worker.go"} { + abs := filepath.Join(folder, filepath.FromSlash(rel)) + if err := os.Chtimes(abs, same, same); err != nil { + t.Fatal(err) + } + } + + out, err := staleRun(t, folder) + if err != nil { + t.Fatalf("stale: %v\n%s", err, out) + } + if !strings.Contains(out, "guide.md") || !strings.Contains(out, "17d newer") { + t.Errorf("identical mtimes must not erase the journal's answer:\n%s", out) + } +} + +// Resolution is the filter: a candidate that does not land on a synced file is +// not a reference, however path-shaped it looks. +func TestStaleReferenceResolution(t *testing.T) { + synced := map[string]bool{ + "internal/syncer/syncer.go": true, + "docs/hub-config.md": true, + "docs/nested/deep.go": true, + "README.md": true, + } + cases := []struct { + name, docDir, cand, want string + }{ + {"inline link, root-relative", ".", "internal/syncer/syncer.go", "internal/syncer/syncer.go"}, + {"relative to the doc's own dir", "docs/nested", "deep.go", "docs/nested/deep.go"}, + {"falls back to the root", "docs", "internal/syncer/syncer.go", "internal/syncer/syncer.go"}, + {"wikilink retried with .md", ".", "docs/hub-config.md", "docs/hub-config.md"}, + {"anchor stripped", ".", "README.md#install", "README.md"}, + {"query stripped", ".", "README.md?raw=1", "README.md"}, + {"trailing sentence period", ".", "README.md.", "README.md"}, + {"dot-slash prefix", ".", "./README.md", "README.md"}, + {"http url", ".", "https://example.com/README.md", ""}, + {"mailto", ".", "mailto:someone@example.com", ""}, + {"protocol-relative", ".", "//example.com/README.md", ""}, + {"absolute path", ".", "/etc/passwd", ""}, + {"escapes the mount", "docs", "../../../etc/passwd", ""}, + {"made up", ".", "internal/nope/missing.go", ""}, + {"empty", ".", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := resolveRef(tc.docDir, tc.cand, synced) + if tc.want == "" { + if ok { + t.Fatalf("resolved %q to %q, want dropped", tc.cand, got) + } + return + } + if !ok || got != tc.want { + t.Fatalf("resolveRef(%q, %q) = %q,%v want %q", tc.docDir, tc.cand, got, ok, tc.want) + } + }) + } +} + +// A URL, a ../ escape and a made-up path reach the command as real document +// text — and none of them may be counted. +func TestStaleIgnoresUnresolvableReferences(t *testing.T) { + folder := staleProject(t, map[string]string{ + "notes.md": "see https://example.com/src/worker.go and ../../../etc/passwd and made/up/path.go\n" + + "the real one is [worker](src/worker.go)\n", + "src/worker.go": "package src\n", + }, map[string]time.Duration{ + "notes.md": -10 * day, + "src/worker.go": -1 * day, + }) + + out, err := staleRun(t, folder) + if err != nil { + t.Fatalf("stale: %v\n%s", err, out) + } + if !strings.Contains(out, "1 outgrown doc, 1 stale reference") { + t.Errorf("only the resolvable reference should count:\n%s", out) + } + for _, bad := range []string{"example.com", "etc/passwd", "made/up"} { + if strings.Contains(out, bad) { + t.Errorf("unresolvable %q was counted:\n%s", bad, out) + } + } +} + +// A .bdriveignore rule excludes a file from this command exactly as it +// excludes it from sync — as a doc to scan and as a file that can age one. +func TestStaleHonorsBdriveignore(t *testing.T) { + folder := staleProject(t, map[string]string{ + ".bdriveignore": "secret/\n", + "guide.md": "[a](secret/hidden.go) and [b](src/worker.go)\n", + "secret/notes.md": "[worker](../src/worker.go)\n", + "secret/hidden.go": "package secret\n", + "src/worker.go": "package src\n", + }, map[string]time.Duration{ + "guide.md": -10 * day, + "secret/notes.md": -10 * day, + "secret/hidden.go": -1 * day, + "src/worker.go": -2 * day, + }) + + out, err := staleRun(t, folder) + if err != nil { + t.Fatalf("stale: %v\n%s", err, out) + } + if strings.Contains(out, "secret/") { + t.Errorf("an ignored path was scanned or counted:\n%s", out) + } + if !strings.Contains(out, "1 outgrown doc, 1 stale reference") { + t.Errorf("the synced reference should still count:\n%s", out) + } +} + +// -l is paths only; -n caps the docs printed; both exit 0. +func TestStaleOutputAndFlags(t *testing.T) { + folder := staleProject(t, map[string]string{ + "a.md": "[w](src/worker.go)\n", + "b.md": "[c](src/cache.go)\n", + "src/worker.go": "package src\n", + "src/cache.go": "package src\n", + }, map[string]time.Duration{ + "a.md": -40 * day, "src/worker.go": -1 * day, + "b.md": -10 * day, "src/cache.go": -5 * day, + }) + + out, err := staleRun(t, "-l", folder) + if err != nil { + t.Fatalf("stale -l: %v\n%s", err, out) + } + lines := strings.Split(strings.TrimSpace(out), "\n") + if want := []string{"a.md", "b.md"}; len(lines) != 2 || lines[0] != want[0] || lines[1] != want[1] { + t.Errorf("-l should print two bare paths worst-first, got %q", lines) + } + + out, err = staleRun(t, "-l", "-n", "1", folder) + if err != nil { + t.Fatalf("stale -n 1: %v\n%s", err, out) + } + if !strings.Contains(out, "a.md") || strings.Contains(out, "b.md") { + t.Errorf("-n 1 should print exactly the worst doc:\n%s", out) + } + if !strings.Contains(out, "output limited to 1 doc ") { + t.Errorf("truncation should say so:\n%s", out) + } + + // A clean project still exits 0 — this is advisory, not a gate. + clean := staleProject(t, map[string]string{ + "a.md": "[w](src/worker.go)\n", + "src/worker.go": "package src\n", + }, map[string]time.Duration{ + "a.md": -1 * day, "src/worker.go": -20 * day, + }) + if out, err := staleRun(t, clean); err != nil { + t.Fatalf("a clean project must exit 0: %v\n%s", err, out) + } else if !strings.Contains(out, "no outgrown docs") { + t.Errorf("a clean project should say so:\n%s", out) + } +} + +// A project that has never synced has no volume store, so nothing is datable. +// Same wording and same exit as `bdrive log`. +func TestStaleWithNoHistory(t *testing.T) { + t.Setenv("BDRIVE_HOME", t.TempDir()) + folder := t.TempDir() + folder, _ = filepath.EvalSymlinks(folder) + if _, err := config.SaveProject(folder, config.Project{ + Volume: "wiki", + Remote: "https://hub.example.com/p/p-12345678", + }); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(folder, "a.md"), []byte("[x](b.md)\n"), 0o644); err != nil { + t.Fatal(err) + } + + out, err := staleRun(t, folder) + if err != nil { + t.Fatalf("no history must exit 0: %v\n%s", err, out) + } + if !strings.Contains(out, "no history yet") { + t.Errorf("want `no history yet`, got:\n%s", out) + } +} + +// Two layers stand between a planted file name and the operator's terminal, +// and this asserts both: the scan refuses a path journal.SafePath rejects +// (walk.go:48), so a hostile name never becomes a synced path at all — and +// safeField still guards every string this command prints, because a filter +// that holds today is not a reason to print unfiltered tomorrow. +func TestStaleOutputCannotRewriteTheTerminal(t *testing.T) { + hostile := "na‮me\x1b[31m\r.md" + folder := staleProject(t, map[string]string{ + hostile: "[w](src/worker.go)\n", + "clean.md": "[w](src/worker.go)\n", + "src/worker.go": "package src\n", + }, map[string]time.Duration{ + hostile: -10 * day, "clean.md": -10 * day, "src/worker.go": -1 * day, + }) + + for _, args := range [][]string{{folder}, {"-l", folder}} { + out, err := staleRun(t, args...) + if err != nil { + t.Fatalf("stale %v: %v", args, err) + } + if !strings.Contains(out, "clean.md") { + t.Fatalf("the hostile name must not suppress the rest of the report:\n%q", out) + } + for _, bad := range []string{"\x1b", "\r", "‮", "\x9b", "\x7f"} { + if strings.Contains(out, bad) { + t.Errorf("stale %v leaked %q into the terminal:\n%q", args, bad, out) + } + } + } + + // Layer two, directly: the print path is safeField, so even if a hostile + // path ever reached it, the row it draws is inert. + if got := safeField(hostile, 160); strings.ContainsAny(got, "\x1b\r‮") { + t.Errorf("safeField let a control character through: %q", got) + } +} + +// A read-only query must not enroll this device: LoadProject, never +// ResolveMount. Outside a project it says so, exits non-zero, and the registry +// is untouched. +func TestStaleOutsideAProjectWritesNothing(t *testing.T) { + t.Setenv("BDRIVE_HOME", t.TempDir()) + folder := t.TempDir() + if err := os.WriteFile(filepath.Join(folder, "loose.md"), []byte("hi\n"), 0o644); err != nil { + t.Fatal(err) + } + + mounts := filepath.Join(os.Getenv("BDRIVE_HOME"), "mounts.json") + before, beforeErr := os.ReadFile(mounts) + + out, err := staleRun(t, folder) + if err == nil { + t.Fatalf("should fail outside a project, got nil\n%s", out) + } + if !strings.Contains(err.Error(), "not a beardrive project") { + t.Errorf("message should name the problem: %v", err) + } + after, afterErr := os.ReadFile(mounts) + if (beforeErr == nil) != (afterErr == nil) || !bytes.Equal(before, after) { + t.Errorf("the registry was written by a read-only query") + } +} + +// The write-time fold is a union across every device's journal, and it must +// survive a peer stamping an op in the year 9999: DisplayTime returns the zero +// time for that op, so max-by-DisplayTime drops it instead of dating the path +// to a future that makes every doc referencing it look stale. +func TestStaleFoldsAcrossDevicesAndClampsForgedStamps(t *testing.T) { + folder := staleProject(t, map[string]string{ + "guide.md": "[w](src/worker.go) and [c](src/cache.go)\n", + "src/worker.go": "package src\n", + "src/cache.go": "package src\n", + }, map[string]time.Duration{ + "guide.md": -10 * day, + "src/worker.go": -20 * day, // older than the doc: not stale on its own + "src/cache.go": -30 * day, // ditto + }) + + // Device B: a genuine later write to worker.go, and a forged year-9999 + // stamp on cache.go. + proj, found, err := config.LoadProject(folder) + if err != nil || !found { + t.Fatalf("load project: %v %v", err, found) + } + vdir, err := config.VolumeDir(proj.ID) + if err != nil { + t.Fatal(err) + } + st, err := store.Open(vdir) + if err != nil { + t.Fatal(err) + } + now := time.Now() + if err := st.AppendOps("devb", []journal.Op{ + { + Seq: 1, Lamport: 100, Time: now.Add(-2 * day), Device: "devb", + Kind: journal.KindPut, Path: "src/worker.go", Blob: strings.Repeat("a", 64), + Mtime: now.Add(-2 * day), + }, + { + Seq: 2, Lamport: 101, Time: now.Add(-time.Hour), Device: "devb", + Kind: journal.KindPut, Path: "src/cache.go", Blob: strings.Repeat("b", 64), + Mtime: time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC), + }, + }); err != nil { + t.Fatal(err) + } + + out, err := staleRun(t, folder) + if err != nil { + t.Fatalf("stale: %v\n%s", err, out) + } + // Device B's real write is picked up across journals: 8 days newer. + if !strings.Contains(out, "src/worker.go") || !strings.Contains(out, "8d newer") { + t.Errorf("the newest write across devices should win:\n%s", out) + } + // The forged stamp is clamped to Op.Time (an hour ago), so cache.go is + // stale by hours — not by eight thousand years. + if strings.Contains(out, "2919") || strings.Contains(out, "9999") { + t.Errorf("a forged year-9999 stamp reached the output:\n%s", out) + } + if !strings.Contains(out, "1 outgrown doc, 2 stale references") { + t.Errorf("both references should count once, clamped:\n%s", out) + } +} diff --git a/web/docs/src/content/docs/reference/cli.md b/web/docs/src/content/docs/reference/cli.md index a939f7d..aef438d 100644 --- a/web/docs/src/content/docs/reference/cli.md +++ b/web/docs/src/content/docs/reference/cli.md @@ -18,6 +18,7 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server. | `bdrive scope [add\|rm ]` | Show or change which subfolders sync — edits the managed block of `.bdriveignore` rules that `init --only` writes. Run from the mount root; the daemon picks changes up in seconds. `rm` stops syncing a folder but deletes nothing, locally or on the hub | | `bdrive scope --explain` | List every path in the folder, split into what syncs and what does not, with counts — the verifiable answer to "what leaves this machine". Pure read: no daemon, no lock, no network | | `bdrive grep [folder]` | Search the text **inside** the files a project syncs. `pattern` is a Go RE2 regexp, or a literal string with `-F`. `-i` ignores case, `-l` prints matching paths only, `-n` caps the lines printed (default 200, `0` = all). Pure read: no daemon, no lock, no network | +| `bdrive stale [folder]` | Find synced markdown that links to a file written **after** the doc itself — staleness by what moved, not by the calendar. `-l` prints outgrown paths only, `-n` caps the docs printed (default 50, `0` = all). Pure read: no daemon, no lock, no network. Exit status is 0 whether or not anything is stale | | `bdrive forget ...` | Stop syncing a path and remove it from the hub. Adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices | | `bdrive url [path]` | Internal hub link for a file or folder — sign-in and membership required. `--sync` pushes first; no argument gives the project home. Computed locally | | `bdrive share ` | Public URL for a synced file. `--list`, `--revoke`, `--expires` (the hub's Share dialog can also set an expiry on an existing link). Refuses a file whose first 1 MiB holds credential-shaped strings — `--force` shares it anyway | @@ -155,6 +156,49 @@ browser, is not built yet — the ⌘K palette covers file names, projects and actions. ::: +### `bdrive stale` — find the docs your code has outgrown + +A doc does not go stale because a month passed. It goes stale when the thing it +describes moves. `bdrive stale` reads that directly: it scans synced markdown +for references to other synced files, and reports every doc that links to +something written *after* the doc itself. + +```sh +bdrive stale +# docs/architecture.md 3 files newer (oldest gap 41d) +# internal/syncer/syncer.go 41d newer +# internal/store/store.go 41d newer +# docs/hub-config.md 40d newer +# archive/retired-spec.md 1 file newer (oldest gap 6d) +# cmd/bdrive/init.go 6d newer +# +# 2 outgrown docs, 4 stale references + +bdrive stale -l # outgrown paths only, one per line +bdrive stale -n 5 # the five worst +``` + +Markdown inline links, Obsidian `[[wikilinks]]` and bare path-shaped tokens all +count as references. **Resolution is the filter**: anything that does not land +on a file this project actually syncs — a URL, a `../` escape out of the mount, +a path that no longer exists — is silently ignored, so a `.bdriveignore` rule +or a narrowed `bdrive scope` excludes a file here exactly as it excludes it from +sync. + +:::note[The dates come from the journal, not the filesystem] +Materialize writes a teammate's file with *this* device's mtime, so on a machine +that just cloned a project every file's mtime is within seconds of every other. +Comparing mtimes would report nothing on exactly the machine that most needs the +answer. `bdrive stale` uses the same write times `bdrive log` prints, which are +identical on every device and available offline. +::: + +**Exit status is 0 whether or not anything is stale.** Unlike `bdrive grep`, +this is advisory output, not a gate — grep's "1 means nothing found" convention +would invert here and fail on a clean project. Read heat, a badge on the hub's +file view, and injecting the flag into an agent's session context are not built +yet; this ships the signal. + ### `bdrive forget` and `bdrive sync --prune` — cleaning up the hub Adding a rule to `.bdriveignore` only stops *future* uploads. Anything that