diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 3abc0ed..373bd11 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -50,6 +50,7 @@ - [watch](cli/watch.md) - [completions](cli/completions.md) - [hook](cli/hook.md) +- [bead](cli/bead.md) - [tour](cli/tour.md) - [prime](cli/prime.md) - [bundle](cli/bundle.md) diff --git a/docs/book/src/cli/bead.md b/docs/book/src/cli/bead.md new file mode 100644 index 0000000..27afaed --- /dev/null +++ b/docs/book/src/cli/bead.md @@ -0,0 +1,95 @@ +--- +title: bead +description: Record and mine bead-to-commit workflow telemetry +tags: [cli, bead, telemetry] +status: draft +category: cli-reference +related: [cli/hook.md] +commands: [bead] +feature: telemetry +source_files: [src/cli/bead.rs] +--- + +# bead + +Record and mine bead-to-commit workflow telemetry (GH#9). + +## Synopsis + +```bash +bobbin bead [OPTIONS] +``` + +## Description + +The `bead` command captures the relationship between beads (units of work) and +the commits that resolve them. Each link is stored in the `bead_lineage` table +with the changeset it touched — files, line counts, and named symbols — so later +layers can mine which code matters for which kinds of work and reconstruct which +change introduced a bug. + +Lineage is normally populated automatically by the [git post-commit +hook](./hook.md) (`bobbin bead auto-link`); the subcommands below are also +available directly. + +## Subcommands + +### link + +Manually link a bead to a commit and its changeset. + +```bash +bobbin bead link bo-abc123 [--files a.rs,b.rs] [--type bug] \ + [--bundles slug1,slug2] [--action linked] +``` + +When `` is given and `--files` is omitted, the changeset (files and line +counts) is read from git automatically. `--files` overrides git detection. + +### auto-link + +Link a commit to its bead, inferring the bead id from the commit. Invoked by the +post-commit hook; rarely run by hand. + +```bash +bobbin bead auto-link --commit HEAD +``` + +The bead id is resolved by precedence: (a) a `Bead: ` trailer in the commit +message, (b) a parenthesized `(bo-xxxx)` token in the subject, (c) the first +bead-id token in the subject, (d) the branch name. If none match, no row is +recorded and the command exits 0. The link is idempotent: re-running on a commit +that already has a `commit` lineage row (e.g. after an amend or rebase) does not +create a duplicate. + +### reconstruct-causality + +Reconstruct bug causality and populate the `bug_causality` table — the +supervised signal for "risky change". + +```bash +bobbin bead reconstruct-causality [--bug bo-abc123] [--limit 200] +``` + +For each bug bead, the command collects the files its fix touched, finds the +most-recent prior commit that touched each such file (the candidate culprit), +and scores confidence by how much of the fix's changeset that commit overlaps. +One row per `(bug, culprit_sha, file)` is upserted, so the job is idempotent and +safe to run periodically. With `--bug` it processes a single bug; otherwise it +scans bug beads found in lineage (capped by `--limit`). + +> The current heuristic ranks culprits by file overlap and recency over +> `bead_lineage`. Git-blame sharpening of the exact introducing commit is a +> planned refinement. + +### history + +Show recorded lineage for a bead, or recent lineage across all beads. + +```bash +bobbin bead history [BEAD_ID] [--commit ] [-n 20] +``` + +## See Also + +- [hook](./hook.md) — installs the post-commit hook that auto-links commits. diff --git a/src/cli/bead.rs b/src/cli/bead.rs index ca3be3b..257dd36 100644 --- a/src/cli/bead.rs +++ b/src/cli/bead.rs @@ -8,7 +8,9 @@ use std::process::Command; use super::OutputConfig; use crate::config::Config; use crate::index::Parser; -use crate::storage::sqlite::{BeadLineageRecord, MetadataStore, NewBeadLineage, TouchedSymbol}; +use crate::storage::sqlite::{ + BeadLineageRecord, MetadataStore, NewBeadLineage, NewBugCausality, PriorTouch, TouchedSymbol, +}; #[derive(Args)] pub struct BeadArgs { @@ -53,6 +55,19 @@ enum BeadCommand { commit: String, }, + /// Reconstruct bug causality: for each bug bead, infer which prior commit + /// most likely introduced the bug it fixed (per file) and populate the + /// `bug_causality` table. Idempotent — safe to run periodically. (bo-s1kb) + ReconstructCausality { + /// Restrict to a single bug bead id (default: all bug beads in lineage). + #[arg(long)] + bug: Option, + + /// Max bug beads to process when scanning all (default: 200). + #[arg(long, default_value = "200")] + limit: usize, + }, + /// Show recorded lineage for a bead (or recent lineage across all beads) History { /// Bead identifier to filter by (omit for recent lineage across beads) @@ -198,6 +213,10 @@ pub async fn run(args: BeadArgs, output: OutputConfig) -> Result<()> { run_auto_link(&repo_root, &store, &commit, &output)?; } + BeadCommand::ReconstructCausality { bug, limit } => { + run_reconstruct_causality(&store, bug.as_deref(), limit, &output)?; + } + BeadCommand::History { bead_id, commit, @@ -470,6 +489,197 @@ fn find_bead_id(text: &str) -> Option { None } +/// A reconstructed culprit for one file of a bug's fix (bo-s1kb). +#[derive(Debug, Clone, PartialEq)] +struct CausalityCandidate { + file: String, + culprit_sha: String, + culprit_bead_id: String, + confidence: f64, +} + +#[derive(Serialize)] +struct CausalityOutput { + bug_id: String, + rows: usize, +} + +/// Reconstruct bug causality and populate `bug_causality` (bo-s1kb). +/// +/// For each bug bead: gather the files its fix touched, find the most-recent +/// prior commit touching each such file (the candidate culprit), score +/// confidence by how much of the fix's changeset that commit overlaps, and +/// upsert one row per (bug, culprit, file). Idempotent via the table's UNIQUE +/// constraint, so periodic re-runs refresh rather than duplicate. +/// +/// Scope (v1): the recency+overlap heuristic over `bead_lineage`. Git-blame +/// sharpening of the exact introducing commit and `change_events` outcome +/// labeling are deferred (see bo-s1kb design notes) — neither blocks the +/// supervised signal this table provides. +fn run_reconstruct_causality( + store: &MetadataStore, + bug: Option<&str>, + limit: usize, + output: &OutputConfig, +) -> Result<()> { + // Resolve the set of bug beads to process. + let bug_ids: Vec = match bug { + Some(b) => vec![b.to_string()], + None => store + .distinct_lineage_bead_ids()? + .into_iter() + .filter(|(id, ty)| is_bug_bead(id, ty.as_deref())) + .map(|(id, _)| id) + .take(limit) + .collect(), + }; + + let mut results: Vec = Vec::new(); + for bug_id in &bug_ids { + // The bug's own fix changeset: all files it touched, plus the earliest + // timestamp (the boundary before which a culprit must have landed). + let fix_rows = store.list_bead_lineage(Some(bug_id), None, 1000)?; + if fix_rows.is_empty() { + continue; + } + let mut fix_files: Vec = Vec::new(); + for r in &fix_rows { + for f in &r.touched_files { + if !fix_files.contains(f) { + fix_files.push(f.clone()); + } + } + } + let before = fix_rows + .iter() + .map(|r| r.created_at.as_str()) + .min() + .unwrap_or("") + .to_string(); + if fix_files.is_empty() || before.is_empty() { + continue; + } + + // Candidate culprits: prior commits touching the same files, excluding + // the bug's own lineage rows. + let prior: Vec = store + .prior_lineage_touching_files(&fix_files, &before)? + .into_iter() + .filter(|t| &t.bead_id != bug_id) + .collect(); + + let candidates = reconstruct_culprits(&fix_files, &prior); + for c in &candidates { + store.record_bug_causality(&NewBugCausality { + bug_id: bug_id.clone(), + culprit_sha: Some(c.culprit_sha.clone()), + culprit_bead_id: Some(c.culprit_bead_id.clone()), + file: Some(c.file.clone()), + confidence: Some(c.confidence), + })?; + } + if !candidates.is_empty() { + results.push(CausalityOutput { + bug_id: bug_id.clone(), + rows: candidates.len(), + }); + } + } + + if output.json { + println!("{}", serde_json::to_string_pretty(&results)?); + } else if !output.quiet { + let total: usize = results.iter().map(|r| r.rows).sum(); + if results.is_empty() { + println!("{}", "No bug causality reconstructed.".dimmed()); + } else { + for r in &results { + println!( + "{} {} {} culprit row{}", + "✓".green(), + r.bug_id.cyan(), + r.rows, + if r.rows == 1 { "" } else { "s" }, + ); + } + println!( + "{} {} bug(s), {} causality row(s) recorded", + "•".dimmed(), + results.len(), + total + ); + } + } + + Ok(()) +} + +/// Is `bead_id` a bug? Trusts the lineage `bead_type` column when present, +/// else falls back to `bd show` (best-effort; unknown → not a bug). +fn is_bug_bead(bead_id: &str, lineage_type: Option<&str>) -> bool { + if let Some(t) = lineage_type { + if !t.is_empty() { + return t.eq_ignore_ascii_case("bug"); + } + } + bead_json(bead_id) + .and_then(|v| { + v.get("issue_type") + .and_then(|t| t.as_str()) + .map(|t| t.eq_ignore_ascii_case("bug")) + }) + .unwrap_or(false) +} + +/// Pure causality heuristic (bo-s1kb): given the files a bug's fix touched and +/// the prior commits that touched those files (most-recent first), pick the +/// most-recent prior commit per file as that file's culprit and score +/// confidence by the fraction of the fix's files that commit also touched +/// (concentrated blame ⇒ higher confidence). Deterministic ordering: confidence +/// desc, then file asc. +fn reconstruct_culprits(fix_files: &[String], prior: &[PriorTouch]) -> Vec { + use std::collections::{HashMap, HashSet}; + let fix_set: HashSet<&str> = fix_files.iter().map(|s| s.as_str()).collect(); + + // commit_sha → set of fix-files it touched (overlap breadth). + let mut overlap: HashMap<&str, HashSet<&str>> = HashMap::new(); + // file → most-recent prior touch (prior is already DESC by time). + let mut chosen: HashMap<&str, &PriorTouch> = HashMap::new(); + for t in prior { + let sha = match t.commit_sha.as_deref() { + Some(s) if !s.is_empty() => s, + _ => continue, + }; + if !fix_set.contains(t.file.as_str()) { + continue; + } + overlap.entry(sha).or_default().insert(t.file.as_str()); + chosen.entry(t.file.as_str()).or_insert(t); + } + + let n = fix_files.len().max(1) as f64; + let mut out: Vec = chosen + .into_iter() + .map(|(file, t)| { + let sha = t.commit_sha.clone().unwrap_or_default(); + let breadth = overlap.get(sha.as_str()).map(|s| s.len()).unwrap_or(1) as f64; + CausalityCandidate { + file: file.to_string(), + culprit_sha: sha, + culprit_bead_id: t.bead_id.clone(), + confidence: (breadth / n).clamp(0.1, 0.95), + } + }) + .collect(); + out.sort_by(|a, b| { + b.confidence + .partial_cmp(&a.confidence) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.file.cmp(&b.file)) + }); + out +} + /// Files changed in a commit plus aggregate line counts, via `git show /// --numstat`. Each numstat line is `addeddeletedpath`; binary files /// emit `-` for added/deleted and contribute 0. Paths are repo-relative. @@ -614,6 +824,73 @@ mod tests { assert_eq!(extract_bead_id("just a normal commit", None), None); assert_eq!(extract_bead_id("just a normal commit", Some("main")), None); } + + fn touch(bead: &str, sha: &str, file: &str, at: &str) -> PriorTouch { + PriorTouch { + bead_id: bead.to_string(), + commit_sha: Some(sha.to_string()), + file: file.to_string(), + created_at: at.to_string(), + } + } + + #[test] + fn test_reconstruct_culprits_picks_most_recent() { + let fix_files = vec!["src/a.rs".to_string()]; + // Two prior commits touched a.rs; the more recent (DESC-first) wins. + let prior = vec![ + touch("bo-new", "sha_new", "src/a.rs", "2026-06-20T00:00:00Z"), + touch("bo-old", "sha_old", "src/a.rs", "2026-06-01T00:00:00Z"), + ]; + let got = reconstruct_culprits(&fix_files, &prior); + assert_eq!(got.len(), 1); + assert_eq!(got[0].culprit_sha, "sha_new"); + assert_eq!(got[0].culprit_bead_id, "bo-new"); + // Single fix file fully overlapped → max confidence. + assert!((got[0].confidence - 0.95).abs() < 1e-9); + } + + #[test] + fn test_reconstruct_culprits_confidence_scales_with_overlap() { + let fix_files = vec![ + "src/a.rs".to_string(), + "src/b.rs".to_string(), + "src/c.rs".to_string(), + "src/d.rs".to_string(), + ]; + // sha_wide touched 2 of the 4 fix files; sha_narrow touched only 1. + let prior = vec![ + touch("bo-wide", "sha_wide", "src/a.rs", "2026-06-10T00:00:00Z"), + touch("bo-wide", "sha_wide", "src/b.rs", "2026-06-10T00:00:00Z"), + touch("bo-narrow", "sha_narrow", "src/c.rs", "2026-06-09T00:00:00Z"), + ]; + let got = reconstruct_culprits(&fix_files, &prior); + // a.rs and b.rs → sha_wide (2/4 = 0.5); c.rs → sha_narrow (1/4 = 0.25). + let a = got.iter().find(|c| c.file == "src/a.rs").unwrap(); + let c = got.iter().find(|c| c.file == "src/c.rs").unwrap(); + assert_eq!(a.culprit_sha, "sha_wide"); + assert!((a.confidence - 0.5).abs() < 1e-9); + assert!((c.confidence - 0.25).abs() < 1e-9); + // Sorted by confidence desc → wider-blame culprit first. + assert!(got[0].confidence >= got[got.len() - 1].confidence); + } + + #[test] + fn test_reconstruct_culprits_ignores_unrelated_files_and_empty_sha() { + let fix_files = vec!["src/a.rs".to_string()]; + let prior = vec![ + // Touches a file the fix didn't → ignored. + touch("bo-x", "sha_x", "src/other.rs", "2026-06-10T00:00:00Z"), + // Empty sha → skipped. + PriorTouch { + bead_id: "bo-y".to_string(), + commit_sha: None, + file: "src/a.rs".to_string(), + created_at: "2026-06-11T00:00:00Z".to_string(), + }, + ]; + assert!(reconstruct_culprits(&fix_files, &prior).is_empty()); + } } /// Fetch a bead as JSON via `bd show --json`. bd may emit a single object diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs index d0f3bb7..fcb20ab 100644 --- a/src/storage/sqlite.rs +++ b/src/storage/sqlite.rs @@ -68,10 +68,27 @@ impl MetadataStore { action_type TEXT ); + -- Bug causality (GH#9 telemetry Phase 0, bo-s1kb). The supervised + -- signal for "risky change": reconstructs which prior commit most + -- likely introduced the bug a later bead fixed, per file. One row per + -- (bug, culprit_sha, file); UNIQUE makes the reconstruction job + -- idempotent so periodic re-runs upsert rather than duplicate. + CREATE TABLE IF NOT EXISTS bug_causality ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + bug_id TEXT NOT NULL, + culprit_sha TEXT, + culprit_bead_id TEXT, + file TEXT, + confidence REAL, + UNIQUE(bug_id, culprit_sha, file) + ); + -- Indexes CREATE INDEX IF NOT EXISTS idx_coupling_score ON coupling(score DESC); CREATE INDEX IF NOT EXISTS idx_bead_lineage_bead ON bead_lineage(bead_id); CREATE INDEX IF NOT EXISTS idx_bead_lineage_commit ON bead_lineage(commit_sha); + CREATE INDEX IF NOT EXISTS idx_bug_causality_bug ON bug_causality(bug_id); "#, )?; @@ -389,6 +406,158 @@ impl MetadataStore { .collect::, _>>()?; Ok(rows) } + + /// Distinct (bead_id, bead_type) pairs present in bead_lineage. `bead_type` + /// is the most-recent non-null type recorded for that bead (may be None). + /// Used by the causality job to discover candidate bug beads (bo-s1kb). + pub fn distinct_lineage_bead_ids(&self) -> Result)>> { + let mut stmt = self.conn.prepare( + "SELECT bead_id, MAX(bead_type) FROM bead_lineage GROUP BY bead_id ORDER BY bead_id", + )?; + let rows = stmt + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)))? + .collect::, _>>()?; + Ok(rows) + } + + /// Prior commit lineage rows that touched any of `files` strictly before + /// `before` (ISO timestamp). Returns one (bead_id, commit_sha, file, + /// created_at) tuple per matching (row, file), most-recent first. Used by + /// bug-causality reconstruction (bo-s1kb) to find candidate culprit commits. + /// `files` empty → empty result. Uses json_each over `touched_files`. + pub fn prior_lineage_touching_files( + &self, + files: &[String], + before: &str, + ) -> Result> { + if files.is_empty() { + return Ok(Vec::new()); + } + let placeholders = (0..files.len()) + .map(|i| format!("?{}", i + 1)) + .collect::>() + .join(","); + let before_idx = files.len() + 1; + let sql = format!( + "SELECT bl.bead_id, bl.commit_sha, je.value, bl.created_at + FROM bead_lineage bl, json_each(bl.touched_files) je + WHERE bl.commit_sha IS NOT NULL + AND bl.created_at < ?{before_idx} + AND je.value IN ({placeholders}) + ORDER BY bl.created_at DESC, bl.id DESC" + ); + let mut params: Vec> = Vec::new(); + for f in files { + params.push(Box::new(f.clone())); + } + params.push(Box::new(before.to_string())); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = self.conn.prepare(&sql)?; + let rows = stmt + .query_map(param_refs.as_slice(), |row| { + Ok(PriorTouch { + bead_id: row.get(0)?, + commit_sha: row.get(1)?, + file: row.get(2)?, + created_at: row.get(3)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + /// Upsert a bug-causality row (bo-s1kb). Idempotent on (bug_id, culprit_sha, + /// file): a re-run refreshes confidence / culprit_bead_id rather than + /// duplicating. Returns the row id. + pub fn record_bug_causality(&self, rec: &NewBugCausality) -> Result { + self.conn.execute( + r#"INSERT INTO bug_causality (bug_id, culprit_sha, culprit_bead_id, file, confidence) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(bug_id, culprit_sha, file) DO UPDATE SET + culprit_bead_id = excluded.culprit_bead_id, + confidence = excluded.confidence, + created_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')"#, + rusqlite::params![ + rec.bug_id, + rec.culprit_sha, + rec.culprit_bead_id, + rec.file, + rec.confidence, + ], + )?; + Ok(self.conn.last_insert_rowid()) + } + + /// List bug-causality rows, optionally filtered by bug id. Highest + /// confidence first. + pub fn list_bug_causality( + &self, + bug_id: Option<&str>, + limit: usize, + ) -> Result> { + let mut sql = String::from( + "SELECT id, created_at, bug_id, culprit_sha, culprit_bead_id, file, confidence + FROM bug_causality", + ); + let mut params: Vec> = Vec::new(); + if let Some(b) = bug_id { + sql.push_str(" WHERE bug_id = ?1"); + params.push(Box::new(b.to_string())); + } + sql.push_str(&format!( + " ORDER BY confidence DESC, id DESC LIMIT ?{}", + params.len() + 1 + )); + params.push(Box::new(limit as i64)); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = self.conn.prepare(&sql)?; + let rows = stmt + .query_map(param_refs.as_slice(), |row| { + Ok(BugCausalityRecord { + id: row.get(0)?, + created_at: row.get(1)?, + bug_id: row.get(2)?, + culprit_sha: row.get(3)?, + culprit_bead_id: row.get(4)?, + file: row.get(5)?, + confidence: row.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } +} + +/// One (commit, file) pair from a prior lineage row that touched a file of +/// interest (bug-causality reconstruction, bo-s1kb). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PriorTouch { + pub bead_id: String, + pub commit_sha: Option, + pub file: String, + pub created_at: String, +} + +/// Input for upserting a bug-causality row (bo-s1kb). +#[derive(Debug, Clone, Default)] +pub struct NewBugCausality { + pub bug_id: String, + pub culprit_sha: Option, + pub culprit_bead_id: Option, + pub file: Option, + pub confidence: Option, +} + +/// A stored bug-causality row (bo-s1kb). +#[derive(Debug, Clone)] +pub struct BugCausalityRecord { + pub id: i64, + pub created_at: String, + pub bug_id: String, + pub culprit_sha: Option, + pub culprit_bead_id: Option, + pub file: Option, + pub confidence: Option, } /// A symbol touched by a commit's changeset (telemetry Phase 0, bo-xrsy). @@ -674,6 +843,76 @@ mod tests { assert_eq!(by_commit.len(), 1); } + #[test] + fn test_prior_lineage_touching_files_and_bug_causality() { + let (store, _dir) = create_test_store(); + + // A prior commit (bo-old) and a later bug-fix commit (bo-bug) both touch + // src/a.rs. Record them, then query priors touching the bug's files. + store + .record_bead_lineage(&NewBeadLineage { + bead_id: "bo-old".to_string(), + commit_sha: Some("sha_old".to_string()), + touched_files: vec!["src/a.rs".to_string(), "src/b.rs".to_string()], + action_type: Some("commit".to_string()), + ..Default::default() + }) + .unwrap(); + store + .record_bead_lineage(&NewBeadLineage { + bead_id: "bo-bug".to_string(), + bead_type: Some("bug".to_string()), + commit_sha: Some("sha_fix".to_string()), + touched_files: vec!["src/a.rs".to_string()], + action_type: Some("commit".to_string()), + ..Default::default() + }) + .unwrap(); + + // json_each-driven lookup: a far-future boundary admits all prior rows. + let priors = store + .prior_lineage_touching_files(&["src/a.rs".to_string()], "9999-01-01T00:00:00Z") + .unwrap(); + assert!(priors.iter().all(|p| p.file == "src/a.rs")); + assert!(priors.iter().any(|p| p.bead_id == "bo-old")); + + // Empty file list short-circuits. + assert!(store + .prior_lineage_touching_files(&[], "9999-01-01T00:00:00Z") + .unwrap() + .is_empty()); + + // distinct bead ids surface the bug's recorded type. + let distinct = store.distinct_lineage_bead_ids().unwrap(); + assert!(distinct + .iter() + .any(|(id, ty)| id == "bo-bug" && ty.as_deref() == Some("bug"))); + + // Upsert idempotency: same (bug, sha, file) refreshes, not duplicates. + store + .record_bug_causality(&NewBugCausality { + bug_id: "bo-bug".to_string(), + culprit_sha: Some("sha_old".to_string()), + culprit_bead_id: Some("bo-old".to_string()), + file: Some("src/a.rs".to_string()), + confidence: Some(0.5), + }) + .unwrap(); + store + .record_bug_causality(&NewBugCausality { + bug_id: "bo-bug".to_string(), + culprit_sha: Some("sha_old".to_string()), + culprit_bead_id: Some("bo-old".to_string()), + file: Some("src/a.rs".to_string()), + confidence: Some(0.9), + }) + .unwrap(); + let rows = store.list_bug_causality(Some("bo-bug"), 10).unwrap(); + assert_eq!(rows.len(), 1, "upsert must not duplicate"); + assert_eq!(rows[0].confidence, Some(0.9), "confidence refreshed"); + assert_eq!(rows[0].culprit_sha.as_deref(), Some("sha_old")); + } + #[test] fn test_bead_lineage_migration_idempotent() { // Opening the same DB twice must not error or duplicate columns: the