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