├── .beads ├── .local_version ├── metadata.json ├── correlation_feedback.jsonl ├── deletions.jsonl ├── .gitignore ├── feedback.json ├── config.yaml └── README.md ├── .ubsignore ├── .gitattributes ├── pkg ├── analysis │ ├── testdata │ │ └── startup_baseline.json │ ├── cache_extra_test.go │ ├── articulation_test.go │ ├── graph_extra_test.go │ └── status_fullstats_test.go ├── version │ └── version.go ├── export │ ├── viewer_assets │ │ ├── vendor │ │ │ ├── sql-wasm.wasm │ │ │ ├── bv_graph_bg.wasm │ │ │ ├── inter-variable.woff2 │ │ │ ├── jetbrains-mono-regular.woff2 │ │ │ └── alpine-collapse.min.js │ │ ├── hybrid_scorer.test.js │ │ └── hybrid_scorer.js │ ├── main_test.go │ ├── wasm_scorer │ │ └── Cargo.toml │ ├── preview_flow_test.go │ ├── wizard_flow_test.go │ └── init_and_push_test.go ├── ui │ ├── main_test.go │ ├── repo_picker_test.go │ ├── workspace_filter_test.go │ ├── visuals_test.go │ ├── workspace_repos.go │ ├── attention.go │ ├── history_selection_test.go │ ├── graph_internal_test.go │ ├── recipe_picker_test.go │ ├── truncate_test.go │ ├── theme_test.go │ ├── actionable_test.go │ └── styles_test.go ├── correlation │ ├── gitlog.go │ ├── temporal_path_test.go │ ├── beads_files.go │ └── gitlog_test.go ├── hooks │ ├── executor_unix_test.go │ ├── executor_windows_test.go │ ├── loader_extra_test.go │ ├── config_yaml_test.go │ └── runhooks_test.go ├── search │ ├── hybrid_scorer.go │ ├── lexical_boost_test.go │ ├── hybrid_scorer_bench_test.go │ ├── metrics_cache_bench_test.go │ ├── documents.go │ ├── normalizers.go │ ├── metrics_cache.go │ ├── embedder.go │ ├── lexical_boost.go │ ├── bench_helpers_test.go │ ├── hash_embedder_test.go │ ├── presets.go │ ├── query_adjust_test.go │ ├── normalizers_test.go │ ├── weights.go │ ├── hybrid_scorer_impl.go │ ├── hash_embedder.go │ ├── weights_test.go │ ├── hybrid_scorer_real_test.go │ ├── vector_index_test.go │ ├── index_sync_test.go │ └── query_adjust.go ├── loader │ ├── bom_test.go │ ├── synthetic_test.go │ ├── robustness_test.go │ ├── real_data_test.go │ ├── loader_extra_test.go │ └── sprint_test.go ├── cass │ └── doc.go ├── updater │ ├── download_test.go │ ├── network_test.go │ └── fileops_test.go ├── watcher │ └── debouncer.go └── drift │ └── summary_test.go ├── screenshots ├── screenshot_01__main_screen.webp ├── screenshot_03__kanban_view.webp ├── screenshot_04__graph_view.webp ├── screenshot_02__insights_view.webp └── convert_webp.py ├── bv-graph-wasm ├── src │ ├── subgraph.rs │ ├── advanced │ │ └── mod.rs │ ├── algorithms │ │ └── mod.rs │ └── lib.rs ├── Makefile └── Cargo.toml ├── testdata ├── graphs │ ├── cycle_5.json │ ├── diamond_5.json │ ├── chain_10.json │ ├── star_10.json │ └── complex_20.json ├── golden │ └── graph_render │ │ ├── diamond_5.mermaid.golden │ │ ├── chain_10.mermaid.golden │ │ ├── star_10.mermaid.golden │ │ ├── star_10_ascii.golden │ │ ├── chain_10_ascii.golden │ │ ├── diamond_5_ascii.golden │ │ └── complex_20_ascii.golden └── expected │ ├── cycle_5_metrics.json │ ├── diamond_5_metrics.json │ ├── star_10_metrics.json │ └── chain_10_metrics.json ├── tests ├── testdata │ ├── minimal.jsonl │ ├── search_hybrid.jsonl │ ├── synthetic_complex.jsonl │ └── real │ │ └── srps.jsonl └── e2e │ ├── README.md │ ├── tui_snapshot_test.go │ ├── emit_script_test.go │ ├── robot_suggest_test.go │ ├── brief_exports_test.go │ ├── workspace_robot_output_e2e_test.go │ ├── robot_capacity_test.go │ ├── robot_stderr_cleanliness_test.go │ ├── tui_hybrid_search_test.go │ └── robot_search_test.go ├── Makefile ├── .gitignore ├── .github └── workflows │ └── release.yml ├── scripts ├── build_hybrid_wasm.sh ├── e2e_hybrid_search.sh ├── benchmark_compare.sh └── benchmark.sh ├── .goreleaser.yaml ├── benchmarks └── baseline.txt ├── codecov.yml ├── LICENSE ├── GOLANG_BEST_PRACTICES.md ├── cmd └── bv │ ├── burndown_test.go │ └── main_robot_test.go └── go.mod /.beads/.local_version: -------------------------------------------------------------------------------- 1 | 0.30.3 2 | -------------------------------------------------------------------------------- /.ubsignore: -------------------------------------------------------------------------------- 1 | beads_reference/ 2 | -------------------------------------------------------------------------------- /.beads/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "beads.db", 3 | "jsonl_export": "issues.jsonl" 4 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | 2 | # Use bd merge for beads JSONL files 3 | .beads/beads.jsonl merge=beads 4 | -------------------------------------------------------------------------------- /pkg/analysis/testdata/startup_baseline.json: -------------------------------------------------------------------------------- 1 | { 2 | "large_500": 249, 3 | "medium_150": 27, 4 | "small_30": 2 5 | } -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // Version is the current application version 4 | const Version = "v0.11.1" 5 | -------------------------------------------------------------------------------- /screenshots/screenshot_01__main_screen.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dicklesworthstone/beads_viewer/HEAD/screenshots/screenshot_01__main_screen.webp -------------------------------------------------------------------------------- /screenshots/screenshot_03__kanban_view.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dicklesworthstone/beads_viewer/HEAD/screenshots/screenshot_03__kanban_view.webp -------------------------------------------------------------------------------- /screenshots/screenshot_04__graph_view.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dicklesworthstone/beads_viewer/HEAD/screenshots/screenshot_04__graph_view.webp -------------------------------------------------------------------------------- /pkg/export/viewer_assets/vendor/sql-wasm.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dicklesworthstone/beads_viewer/HEAD/pkg/export/viewer_assets/vendor/sql-wasm.wasm -------------------------------------------------------------------------------- /screenshots/screenshot_02__insights_view.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dicklesworthstone/beads_viewer/HEAD/screenshots/screenshot_02__insights_view.webp -------------------------------------------------------------------------------- /pkg/export/viewer_assets/vendor/bv_graph_bg.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dicklesworthstone/beads_viewer/HEAD/pkg/export/viewer_assets/vendor/bv_graph_bg.wasm -------------------------------------------------------------------------------- /pkg/export/viewer_assets/vendor/inter-variable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dicklesworthstone/beads_viewer/HEAD/pkg/export/viewer_assets/vendor/inter-variable.woff2 -------------------------------------------------------------------------------- /pkg/export/viewer_assets/vendor/jetbrains-mono-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dicklesworthstone/beads_viewer/HEAD/pkg/export/viewer_assets/vendor/jetbrains-mono-regular.woff2 -------------------------------------------------------------------------------- /bv-graph-wasm/src/subgraph.rs: -------------------------------------------------------------------------------- 1 | //! Subgraph extraction and operations. 2 | //! 3 | //! Create subgraphs from node sets for focused analysis. 4 | 5 | // Subgraph implementation will be added in bv-ywd5 6 | -------------------------------------------------------------------------------- /testdata/graphs/cycle_5.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "5-node cycle: n0 -> n1 -> n2 -> n3 -> n4 -> n0", 3 | "nodes": ["n0", "n1", "n2", "n3", "n4"], 4 | "edges": [ 5 | [0, 1], [1, 2], [2, 3], [3, 4], [4, 0] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tests/testdata/minimal.jsonl: -------------------------------------------------------------------------------- 1 | {"id":"A","title":"Root","status":"open","priority":1,"issue_type":"task"} 2 | {"id":"B","title":"Blocked","status":"blocked","priority":2,"issue_type":"task","dependencies":[{"depends_on_id":"A","type":"blocks"}]} 3 | -------------------------------------------------------------------------------- /.beads/correlation_feedback.jsonl: -------------------------------------------------------------------------------- 1 | {"commit_sha":"8855d76f67556c672c924cdf32543ff4592aa2b1","bead_id":"bv-35qc","feedback_at":"2025-12-18T01:49:21.78059Z","feedback_by":"BrownHill","type":"confirm","reason":"Testing feedback system","original_conf":0.99} 2 | -------------------------------------------------------------------------------- /testdata/graphs/diamond_5.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Diamond DAG: n0 is source, n1/n2 branch, n3 merges, n4 is sink. Tests betweenness.", 3 | "nodes": ["n0", "n1", "n2", "n3", "n4"], 4 | "edges": [ 5 | [0, 1], [0, 2], [1, 3], [2, 3], [3, 4] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /testdata/graphs/chain_10.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Linear chain of 10 nodes: n0 -> n1 -> n2 -> ... -> n9", 3 | "nodes": ["n0", "n1", "n2", "n3", "n4", "n5", "n6", "n7", "n8", "n9"], 4 | "edges": [ 5 | [0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7], [7, 8], [8, 9] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /pkg/ui/main_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestMain(m *testing.M) { 9 | // Prevent any test from accidentally opening a browser 10 | os.Setenv("BV_NO_BROWSER", "1") 11 | os.Setenv("BV_TEST_MODE", "1") 12 | 13 | os.Exit(m.Run()) 14 | } 15 | -------------------------------------------------------------------------------- /.beads/deletions.jsonl: -------------------------------------------------------------------------------- 1 | {"id":"bv-ggmc","ts":"2025-12-18T05:36:40.246868Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} 2 | {"id":"bv-hwt0","ts":"2025-12-18T06:51:01.532185Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} 3 | -------------------------------------------------------------------------------- /pkg/export/main_test.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestMain(m *testing.M) { 9 | // Prevent any test from accidentally opening a browser 10 | os.Setenv("BV_NO_BROWSER", "1") 11 | os.Setenv("BV_TEST_MODE", "1") 12 | 13 | os.Exit(m.Run()) 14 | } 15 | -------------------------------------------------------------------------------- /testdata/graphs/star_10.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Star topology with center node n0 connected to n1-n9: n1,n2,...,n9 all point to n0", 3 | "nodes": ["n0", "n1", "n2", "n3", "n4", "n5", "n6", "n7", "n8", "n9"], 4 | "edges": [ 5 | [1, 0], [2, 0], [3, 0], [4, 0], [5, 0], [6, 0], [7, 0], [8, 0], [9, 0] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /pkg/correlation/gitlog.go: -------------------------------------------------------------------------------- 1 | package correlation 2 | 3 | const ( 4 | gitLogHeaderFormat = "%H%x00%aI%x00%an%x00%ae%x00%s" 5 | 6 | // gitLogMaxScanTokenSize matches the loader and stream limits; it prevents 7 | // bufio.Scanner from failing on unusually long lines. 8 | gitLogMaxScanTokenSize = 10 * 1024 * 1024 // 10MB 9 | ) 10 | -------------------------------------------------------------------------------- /pkg/hooks/executor_unix_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package hooks 4 | 5 | import "testing" 6 | 7 | func TestGetShellCommand_Unix(t *testing.T) { 8 | shell, flag := getShellCommand() 9 | if shell != "sh" || flag != "-c" { 10 | t.Fatalf("getShellCommand() = (%q, %q); want (\"sh\", \"-c\")", shell, flag) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /bv-graph-wasm/src/advanced/mod.rs: -------------------------------------------------------------------------------- 1 | //! Advanced graph operations. 2 | //! 3 | //! Higher-level algorithms built on core graph primitives. 4 | 5 | // Advanced modules will be added as they're implemented: 6 | // pub mod topk_set; 7 | // pub mod coverage; 8 | // pub mod k_paths; 9 | // pub mod parallel_cut; 10 | // pub mod cycle_break; 11 | -------------------------------------------------------------------------------- /pkg/hooks/executor_windows_test.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package hooks 4 | 5 | import "testing" 6 | 7 | func TestGetShellCommand_Windows(t *testing.T) { 8 | shell, flag := getShellCommand() 9 | if shell != "cmd" || flag != "/C" { 10 | t.Fatalf("getShellCommand() = (%q, %q); want (\"cmd\", \"/C\")", shell, flag) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /pkg/export/wasm_scorer/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bv-hybrid-scorer" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | name = "bv_hybrid_scorer" 8 | crate-type = ["cdylib"] 9 | 10 | [dependencies] 11 | wasm-bindgen = "0.2" 12 | serde = { version = "1.0", features = ["derive"] } 13 | serde_json = "1.0" 14 | 15 | [profile.release] 16 | opt-level = 3 17 | lto = true 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # bv Makefile 2 | # 3 | # Build with SQLite FTS5 (full-text search) support enabled 4 | 5 | .PHONY: build install clean test 6 | 7 | # Enable FTS5 for full-text search in SQLite exports 8 | export CGO_CFLAGS := -DSQLITE_ENABLE_FTS5 9 | 10 | build: 11 | go build -o bv ./cmd/bv 12 | 13 | install: 14 | go install ./cmd/bv 15 | 16 | clean: 17 | rm -f bv 18 | go clean 19 | 20 | test: 21 | go test ./... 22 | -------------------------------------------------------------------------------- /bv-graph-wasm/src/algorithms/mod.rs: -------------------------------------------------------------------------------- 1 | //! Graph algorithm implementations. 2 | //! 3 | //! This module contains ports of the Go graph algorithms to Rust WASM. 4 | 5 | pub mod articulation; 6 | pub mod betweenness; 7 | pub mod coverage; 8 | pub mod critical_path; 9 | pub mod cycles; 10 | pub mod eigenvector; 11 | pub mod hits; 12 | pub mod k_paths; 13 | pub mod kcore; 14 | pub mod pagerank; 15 | pub mod parallel_cut; 16 | pub mod slack; 17 | pub mod subgraph; 18 | pub mod topo; 19 | pub mod topk_set; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | beads_reference/ 2 | /bv 3 | /main 4 | *.test 5 | cmd/bv/bv 6 | .DS_Store 7 | 8 | # Build artifacts 9 | dist/ 10 | coverage.out 11 | cover*.out 12 | *.cov 13 | coverage.html 14 | 15 | # Benchmark outputs 16 | benchmarks/baseline.txt 17 | benchmarks/current.txt 18 | 19 | # Local BV state and reports 20 | .bv/ 21 | beads_report_beads_viewer_*.md 22 | 23 | # Rust/WASM build artifacts 24 | bv-graph-wasm/target/ 25 | bv-graph-wasm/pkg/ 26 | pkg/export/wasm_scorer/target/ 27 | pkg/export/wasm_scorer/pkg/ 28 | node_modules/ 29 | beads_viewer_graph*.html 30 | test-results/ 31 | -------------------------------------------------------------------------------- /testdata/golden/graph_render/diamond_5.mermaid.golden: -------------------------------------------------------------------------------- 1 | graph TD 2 | classDef open fill:#50FA7B,stroke:#333,color:#000 3 | classDef inprogress fill:#8BE9FD,stroke:#333,color:#000 4 | classDef blocked fill:#FF5555,stroke:#333,color:#000 5 | classDef closed fill:#6272A4,stroke:#333,color:#fff 6 | 7 | n0["n0
n0"] 8 | class n0 open 9 | n1["n1
n1"] 10 | class n1 open 11 | n2["n2
n2"] 12 | class n2 open 13 | n3["n3
n3"] 14 | class n3 open 15 | n4["n4
n4"] 16 | class n4 open 17 | 18 | n0 ==> n1 19 | n0 ==> n2 20 | n1 ==> n3 21 | n2 ==> n3 22 | n3 ==> n4 23 | -------------------------------------------------------------------------------- /pkg/analysis/cache_extra_test.go: -------------------------------------------------------------------------------- 1 | package analysis 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/Dicklesworthstone/beads_viewer/pkg/model" 8 | ) 9 | 10 | func TestCacheSetTTLAndHash(t *testing.T) { 11 | issues := []model.Issue{{ID: "C1", Title: "Cache"}} 12 | c := NewCache(10 * time.Second) 13 | stats := &GraphStats{NodeCount: 1} 14 | c.Set(issues, stats) 15 | if c.Hash() == "" { 16 | t.Fatalf("expected hash after Set") 17 | } 18 | 19 | // Override TTL and ensure GetByHash respects expiry 20 | c.SetTTL(-1 * time.Second) 21 | if got, ok := c.Get(issues); got != nil || ok { 22 | t.Fatalf("expected cache miss after expired TTL") 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - uses: actions/setup-go@v6 19 | with: 20 | go-version: '1.25' 21 | - name: Run GoReleaser 22 | uses: goreleaser/goreleaser-action@v5 23 | with: 24 | distribution: goreleaser 25 | version: latest 26 | args: release --clean 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /scripts/build_hybrid_wasm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) 5 | WASM_SRC="$ROOT_DIR/pkg/export/wasm_scorer" 6 | WASM_OUT="$ROOT_DIR/pkg/export/viewer_assets/wasm" 7 | 8 | if ! command -v wasm-pack >/dev/null 2>&1; then 9 | echo "wasm-pack not found. Install with: cargo install wasm-pack" >&2 10 | exit 1 11 | fi 12 | 13 | if [ ! -d "$WASM_SRC" ]; then 14 | echo "WASM source directory not found: $WASM_SRC" >&2 15 | exit 1 16 | fi 17 | 18 | mkdir -p "$WASM_OUT" 19 | 20 | (cd "$WASM_SRC" && wasm-pack build --release --target web --out-dir "$WASM_OUT") 21 | 22 | echo "Hybrid WASM assets built to $WASM_OUT" 23 | -------------------------------------------------------------------------------- /.beads/.gitignore: -------------------------------------------------------------------------------- 1 | # SQLite databases 2 | *.db 3 | *.db?* 4 | *.db-journal 5 | *.db-wal 6 | *.db-shm 7 | 8 | # Daemon runtime files 9 | daemon.lock 10 | daemon.log 11 | daemon.pid 12 | bd.sock 13 | 14 | # Local version tracking (prevents upgrade notification spam after git ops) 15 | .local_version 16 | 17 | # Legacy database files 18 | db.sqlite 19 | bd.db 20 | 21 | # Merge artifacts (temporary files from 3-way merge) 22 | beads.base.jsonl 23 | beads.base.meta.json 24 | beads.left.jsonl 25 | beads.left.meta.json 26 | beads.right.jsonl 27 | beads.right.meta.json 28 | 29 | # Keep JSONL exports and config (source of truth for git) 30 | !issues.jsonl 31 | !metadata.json 32 | !config.json 33 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: bv 2 | builds: 3 | - main: ./cmd/bv 4 | binary: bv 5 | goos: 6 | - linux 7 | - darwin 8 | - windows 9 | goarch: 10 | - amd64 11 | - arm64 12 | ignore: 13 | - goos: windows 14 | goarch: arm64 15 | 16 | archives: 17 | - format: tar.gz 18 | name_template: >- 19 | {{ .ProjectName }}_ 20 | {{- .Version }}_ 21 | {{- .Os }}_ 22 | {{- .Arch }} 23 | 24 | checksum: 25 | name_template: 'checksums.txt' 26 | 27 | snapshot: 28 | name_template: "{{ .Tag }}-next" 29 | 30 | changelog: 31 | sort: asc 32 | filters: 33 | exclude: 34 | - '^docs:' 35 | - '^test:' 36 | -------------------------------------------------------------------------------- /benchmarks/baseline.txt: -------------------------------------------------------------------------------- 1 | Running quick benchmarks (CI mode)... 2 | goos: linux 3 | goarch: amd64 4 | pkg: github.com/Dicklesworthstone/beads_viewer/pkg/analysis 5 | cpu: AMD Ryzen Threadripper PRO 5975WX 32-Cores 6 | BenchmarkFullAnalysis_ManyCycles20-64 5 245313177 ns/op 220109878 B/op 508060 allocs/op 7 | BenchmarkFullAnalysis_Sparse100-64 361 4474591 ns/op 3083389 B/op 16777 allocs/op 8 | BenchmarkFullAnalysis_Sparse1000-64 7 152180095 ns/op 131057789 B/op 430022 allocs/op 9 | BenchmarkFullAnalysis_Dense100-64 168 8826527 ns/op 6383486 B/op 33517 allocs/op 10 | PASS 11 | ok github.com/Dicklesworthstone/beads_viewer/pkg/analysis 11.475s 12 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | round: down 4 | range: "60...100" 5 | 6 | status: 7 | project: 8 | default: 9 | target: 85% 10 | threshold: 2% 11 | informational: false 12 | patch: 13 | default: 14 | target: 80% 15 | threshold: 5% 16 | informational: true 17 | 18 | comment: 19 | layout: "reach,diff,flags,files" 20 | behavior: default 21 | require_changes: true 22 | require_base: false 23 | require_head: true 24 | 25 | ignore: 26 | - "**/*_test.go" 27 | - "**/testdata/**" 28 | - "**/testutil/**" 29 | - "cmd/**" 30 | 31 | flags: 32 | unittests: 33 | paths: 34 | - pkg/ 35 | carryforward: true 36 | -------------------------------------------------------------------------------- /testdata/graphs/complex_20.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Complex DAG with 20 nodes simulating real issue dependencies", 3 | "nodes": [ 4 | "epic-1", "task-1", "task-2", "task-3", "task-4", 5 | "task-5", "task-6", "task-7", "task-8", "task-9", 6 | "epic-2", "task-10", "task-11", "task-12", "task-13", 7 | "task-14", "task-15", "task-16", "task-17", "task-18" 8 | ], 9 | "edges": [ 10 | [1, 0], [2, 0], [3, 0], [4, 1], [4, 2], 11 | [5, 2], [5, 3], [6, 4], [6, 5], [7, 5], 12 | [8, 6], [8, 7], [9, 8], 13 | [11, 10], [12, 10], [13, 11], [14, 11], [14, 12], 14 | [15, 13], [15, 14], [16, 14], [17, 15], [17, 16], 15 | [18, 17], [19, 17], [19, 18], 16 | [10, 0], [9, 10] 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /pkg/hooks/loader_extra_test.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import "testing" 4 | 5 | func TestLoaderConfigAndWarnings(t *testing.T) { 6 | loader := NewLoader() 7 | cfg := loader.Config() 8 | if cfg == nil { 9 | t.Fatalf("expected non-nil config even before load") 10 | } 11 | if loader.HasHooks() { 12 | t.Fatalf("no hooks should be present before load") 13 | } 14 | if len(loader.GetHooks(PreExport)) != 0 { 15 | t.Fatalf("expected no pre-export hooks") 16 | } 17 | if len(loader.Warnings()) != 0 { 18 | t.Fatalf("expected no warnings before load") 19 | } 20 | } 21 | 22 | func TestTruncateHelper(t *testing.T) { 23 | long := "abcdefghijklmnopqrstuvwxyz" 24 | short := truncate(long, 10) 25 | if len(short) != 10 { 26 | t.Fatalf("truncate should limit length to 10, got %d", len(short)) 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /pkg/analysis/articulation_test.go: -------------------------------------------------------------------------------- 1 | package analysis 2 | 3 | import ( 4 | "testing" 5 | 6 | "gonum.org/v1/gonum/graph/simple" 7 | ) 8 | 9 | // Ensures articulation detection works with node ID 0 (no-parent sentinel safety). 10 | func TestFindArticulationPointsHandlesZeroID(t *testing.T) { 11 | g := simple.NewUndirectedGraph() 12 | // Explicit IDs: 0-1-2 chain; 1 should be articulation. 13 | n0 := simple.Node(0) 14 | n1 := simple.Node(1) 15 | n2 := simple.Node(2) 16 | g.AddNode(n0) 17 | g.AddNode(n1) 18 | g.AddNode(n2) 19 | g.SetEdge(g.NewEdge(n0, n1)) 20 | g.SetEdge(g.NewEdge(n1, n2)) 21 | 22 | ap := findArticulationPoints(g) 23 | if !ap[n1.ID()] { 24 | t.Fatalf("expected node 1 to be articulation, got %v", ap) 25 | } 26 | if ap[n0.ID()] || ap[n2.ID()] { 27 | t.Fatalf("endpoints should not be articulation: %v", ap) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /testdata/golden/graph_render/chain_10.mermaid.golden: -------------------------------------------------------------------------------- 1 | graph TD 2 | classDef open fill:#50FA7B,stroke:#333,color:#000 3 | classDef inprogress fill:#8BE9FD,stroke:#333,color:#000 4 | classDef blocked fill:#FF5555,stroke:#333,color:#000 5 | classDef closed fill:#6272A4,stroke:#333,color:#fff 6 | 7 | n0["n0
n0"] 8 | class n0 open 9 | n1["n1
n1"] 10 | class n1 open 11 | n2["n2
n2"] 12 | class n2 open 13 | n3["n3
n3"] 14 | class n3 open 15 | n4["n4
n4"] 16 | class n4 open 17 | n5["n5
n5"] 18 | class n5 open 19 | n6["n6
n6"] 20 | class n6 open 21 | n7["n7
n7"] 22 | class n7 open 23 | n8["n8
n8"] 24 | class n8 open 25 | n9["n9
n9"] 26 | class n9 open 27 | 28 | n0 ==> n1 29 | n1 ==> n2 30 | n2 ==> n3 31 | n3 ==> n4 32 | n4 ==> n5 33 | n5 ==> n6 34 | n6 ==> n7 35 | n7 ==> n8 36 | n8 ==> n9 37 | -------------------------------------------------------------------------------- /testdata/golden/graph_render/star_10.mermaid.golden: -------------------------------------------------------------------------------- 1 | graph TD 2 | classDef open fill:#50FA7B,stroke:#333,color:#000 3 | classDef inprogress fill:#8BE9FD,stroke:#333,color:#000 4 | classDef blocked fill:#FF5555,stroke:#333,color:#000 5 | classDef closed fill:#6272A4,stroke:#333,color:#fff 6 | 7 | n0["n0
n0"] 8 | class n0 open 9 | n1["n1
n1"] 10 | class n1 open 11 | n2["n2
n2"] 12 | class n2 open 13 | n3["n3
n3"] 14 | class n3 open 15 | n4["n4
n4"] 16 | class n4 open 17 | n5["n5
n5"] 18 | class n5 open 19 | n6["n6
n6"] 20 | class n6 open 21 | n7["n7
n7"] 22 | class n7 open 23 | n8["n8
n8"] 24 | class n8 open 25 | n9["n9
n9"] 26 | class n9 open 27 | 28 | n1 ==> n0 29 | n2 ==> n0 30 | n3 ==> n0 31 | n4 ==> n0 32 | n5 ==> n0 33 | n6 ==> n0 34 | n7 ==> n0 35 | n8 ==> n0 36 | n9 ==> n0 37 | -------------------------------------------------------------------------------- /pkg/search/hybrid_scorer.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | // HybridScorer computes hybrid search scores combining text relevance with graph metrics. 4 | type HybridScorer interface { 5 | // Score computes the hybrid score for an issue given its text score and metrics. 6 | // Returns the final score and component breakdown. 7 | Score(issueID string, textScore float64) (HybridScore, error) 8 | 9 | // Configure sets the weights for hybrid scoring. 10 | Configure(weights Weights) error 11 | 12 | // GetWeights returns the current weight configuration. 13 | GetWeights() Weights 14 | } 15 | 16 | // HybridScore contains the final score and component breakdown for transparency. 17 | type HybridScore struct { 18 | IssueID string `json:"issue_id"` 19 | FinalScore float64 `json:"score"` 20 | TextScore float64 `json:"text_score"` 21 | ComponentScores map[string]float64 `json:"component_scores,omitempty"` 22 | } 23 | -------------------------------------------------------------------------------- /pkg/search/lexical_boost_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import "testing" 4 | 5 | func TestShortQueryLexicalBoost(t *testing.T) { 6 | doc := "Performance benchmarks for graph rendering" 7 | if boost := ShortQueryLexicalBoost("benchmarks", doc); boost <= 0 { 8 | t.Fatalf("expected boost for literal short query match") 9 | } 10 | if boost := ShortQueryLexicalBoost("long descriptive query about rendering performance", doc); boost != 0 { 11 | t.Fatalf("expected no boost for long query") 12 | } 13 | } 14 | 15 | func TestApplyShortQueryLexicalBoostResorts(t *testing.T) { 16 | results := []SearchResult{ 17 | {IssueID: "a", Score: 0.2}, 18 | {IssueID: "b", Score: 0.5}, 19 | } 20 | docs := map[string]string{ 21 | "a": "benchmarks", 22 | "b": "unrelated", 23 | } 24 | updated := ApplyShortQueryLexicalBoost(results, "benchmarks", docs) 25 | if updated[0].IssueID != "a" { 26 | t.Fatalf("expected boosted match to rank first, got %s", updated[0].IssueID) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/e2e/README.md: -------------------------------------------------------------------------------- 1 | # E2E Harness 2 | 3 | Small, bash-first helpers for scripting `bv` end-to-end checks. The goal is 4 | repeatability and CI-friendly logs without bespoke glue per script. 5 | 6 | ## Usage 7 | 8 | ```bash 9 | #!/usr/bin/env bash 10 | set -euo pipefail 11 | source "$(dirname "$0")/harness.sh" 12 | 13 | section "build" 14 | run build ./bv --version 15 | 16 | section "robot-plan" 17 | run robot_plan ./bv --robot-plan 18 | jq_field "$BV_E2E_LOG_DIR/robot_plan.out" '.plan.tracks | length > 0' 19 | 20 | section "robot-insights" 21 | run robot_insights ./bv --robot-insights 22 | jq_field "$BV_E2E_LOG_DIR/robot_insights.out" '.data_hash' 23 | ``` 24 | 25 | Environment: 26 | - `BV_E2E_LOG_DIR` (optional) — log directory (default: `./.e2e-logs`). 27 | 28 | Helpers: 29 | - `run ` — timestamps, captures stdout/stderr to named files. 30 | - `jq_field ` — minimal assertion helper (exits non-zero on failure). 31 | - `section ` — log banner. 32 | -------------------------------------------------------------------------------- /pkg/loader/bom_test.go: -------------------------------------------------------------------------------- 1 | package loader_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/Dicklesworthstone/beads_viewer/pkg/loader" 9 | ) 10 | 11 | func TestLoadIssuesFromFile_WithBOM(t *testing.T) { 12 | dir := t.TempDir() 13 | path := filepath.Join(dir, "bom.jsonl") 14 | 15 | // UTF-8 BOM is EF BB BF 16 | bom := []byte{0xEF, 0xBB, 0xBF} 17 | jsonContent := []byte(`{"id":"1","title":"First","status":"open","issue_type":"task"}` + "\n") 18 | fullContent := append(bom, jsonContent...) 19 | 20 | if err := os.WriteFile(path, fullContent, 0644); err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | issues, err := loader.LoadIssuesFromFile(path) 25 | if err != nil { 26 | t.Fatalf("Unexpected error: %v", err) 27 | } 28 | 29 | if len(issues) != 1 { 30 | t.Errorf("Expected 1 issue, got %d. First issue might have been skipped due to BOM.", len(issues)) 31 | } else if issues[0].ID != "1" { 32 | t.Errorf("Expected ID '1', got '%s'", issues[0].ID) 33 | } 34 | } -------------------------------------------------------------------------------- /pkg/correlation/temporal_path_test.go: -------------------------------------------------------------------------------- 1 | package correlation 2 | 3 | import "testing" 4 | 5 | func TestExtractPathHintsKeywords(t *testing.T) { 6 | hints := extractPathHints("Add tests for API service") 7 | expect := []string{"api", "service", "tests"} 8 | for _, e := range expect { 9 | found := false 10 | for _, h := range hints { 11 | if h == e { 12 | found = true 13 | break 14 | } 15 | } 16 | if !found { 17 | t.Fatalf("missing expected hint %q in %v", e, hints) 18 | } 19 | } 20 | } 21 | 22 | func TestExtractIDsOrdering(t *testing.T) { 23 | m := NewExplicitMatcher("/tmp/test") 24 | msg := "fix: [AUTH-1] closes BV-2 and refs PROJ-3" 25 | matches := m.ExtractIDsFromMessage(msg) 26 | want := []string{"auth-1", "bv-2", "proj-3"} 27 | if len(matches) != len(want) { 28 | t.Fatalf("got %d matches, want %d", len(matches), len(want)) 29 | } 30 | for i, w := range want { 31 | if matches[i].ID != w { 32 | t.Fatalf("idx %d: got %s want %s", i, matches[i].ID, w) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pkg/search/hybrid_scorer_bench_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func BenchmarkHybridScorerScore(b *testing.B) { 9 | cache := buildBenchmarkMetricsCache(b, 1000) 10 | weights, err := GetPreset(PresetDefault) 11 | if err != nil { 12 | b.Fatalf("preset default: %v", err) 13 | } 14 | scorer := NewHybridScorer(weights, cache) 15 | ids := buildBenchmarkIssueIDs(1000) 16 | 17 | b.ReportAllocs() 18 | b.ResetTimer() 19 | for i := 0; i < b.N; i++ { 20 | id := ids[i%len(ids)] 21 | if _, err := scorer.Score(id, 0.75); err != nil { 22 | b.Fatal(err) 23 | } 24 | } 25 | } 26 | 27 | func BenchmarkNormalizers(b *testing.B) { 28 | b.Run("status", func(b *testing.B) { 29 | for i := 0; i < b.N; i++ { 30 | _ = normalizeStatus("open") 31 | } 32 | }) 33 | b.Run("priority", func(b *testing.B) { 34 | for i := 0; i < b.N; i++ { 35 | _ = normalizePriority(2) 36 | } 37 | }) 38 | b.Run("recency", func(b *testing.B) { 39 | updated := time.Now().Add(-30 * 24 * time.Hour) 40 | for i := 0; i < b.N; i++ { 41 | _ = normalizeRecency(updated) 42 | } 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jeffrey Emanuel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/loader/synthetic_test.go: -------------------------------------------------------------------------------- 1 | package loader_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/Dicklesworthstone/beads_viewer/pkg/loader" 7 | ) 8 | 9 | func TestLoadSyntheticComplex(t *testing.T) { 10 | f := "../../tests/testdata/synthetic_complex.jsonl" 11 | issues, err := loader.LoadIssuesFromFile(f) 12 | if err != nil { 13 | t.Fatalf("Failed to load synthetic data: %v", err) 14 | } 15 | 16 | if len(issues) != 6 { 17 | t.Errorf("Expected 6 issues, got %d", len(issues)) 18 | } 19 | 20 | // Validate specific rich content 21 | foundMarkdown := false 22 | foundDeps := false 23 | for _, i := range issues { 24 | if i.ID == "bd-101" { 25 | if i.Assignee != "alice" { 26 | t.Errorf("bd-101 assignee wrong") 27 | } 28 | if len(i.Comments) != 1 { 29 | t.Errorf("bd-101 should have 1 comment") 30 | } 31 | foundMarkdown = true 32 | } 33 | if i.ID == "bd-103" { 34 | if len(i.Dependencies) != 1 { 35 | t.Errorf("bd-103 should have 1 dependency") 36 | } 37 | foundDeps = true 38 | } 39 | } 40 | 41 | if !foundMarkdown || !foundDeps { 42 | t.Error("Failed to validate rich content structure") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/loader/robustness_test.go: -------------------------------------------------------------------------------- 1 | package loader_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/Dicklesworthstone/beads_viewer/pkg/loader" 8 | ) 9 | 10 | func TestLoadIssuesRobustness(t *testing.T) { 11 | // Create a temporary file with some bad lines 12 | f, err := os.CreateTemp("", "beads_robustness_*.jsonl") 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | defer os.Remove(f.Name()) 17 | 18 | content := `{"id": "1", "title": "Good Issue", "status": "open", "priority": 1, "issue_type": "task"} 19 | {INVALID JSON} 20 | {"id": "2", "title": "Another Good Issue", "status": "closed", "priority": 2, "issue_type": "bug"} 21 | ` 22 | if _, err := f.WriteString(content); err != nil { 23 | t.Fatal(err) 24 | } 25 | f.Close() 26 | 27 | issues, err := loader.LoadIssuesFromFile(f.Name()) 28 | if err != nil { 29 | t.Fatalf("Expected success even with bad lines, got error: %v", err) 30 | } 31 | 32 | if len(issues) != 2 { 33 | t.Errorf("Expected 2 valid issues, got %d", len(issues)) 34 | } 35 | 36 | if issues[0].ID != "1" || issues[1].ID != "2" { 37 | t.Errorf("Issues loaded in incorrect order or content mismatch") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /bv-graph-wasm/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build build-release test clean size check fmt clippy 2 | 3 | # Development build (faster, larger) 4 | build: 5 | wasm-pack build --target web --dev 6 | 7 | # Release build (optimized) 8 | build-release: 9 | wasm-pack build --target web --release 10 | @if command -v wasm-opt >/dev/null 2>&1; then \ 11 | echo "Optimizing with wasm-opt..."; \ 12 | wasm-opt -Os -o pkg/bv_graph_wasm_bg_opt.wasm pkg/bv_graph_wasm_bg.wasm; \ 13 | mv pkg/bv_graph_wasm_bg_opt.wasm pkg/bv_graph_wasm_bg.wasm; \ 14 | else \ 15 | echo "wasm-opt not found, skipping optimization"; \ 16 | fi 17 | 18 | # Run Rust unit tests (not WASM) 19 | test: 20 | cargo test 21 | 22 | # Run WASM tests in headless browser 23 | test-wasm: 24 | wasm-pack test --headless --firefox 25 | 26 | # Clean build artifacts 27 | clean: 28 | cargo clean 29 | rm -rf pkg/ 30 | 31 | # Check size 32 | size: build-release 33 | @echo "WASM size:" 34 | @wc -c pkg/bv_graph_wasm_bg.wasm 35 | @echo "Gzipped:" 36 | @gzip -c pkg/bv_graph_wasm_bg.wasm | wc -c 37 | 38 | # Format code 39 | fmt: 40 | cargo fmt 41 | 42 | # Run clippy lints 43 | clippy: 44 | cargo clippy -- -D warnings 45 | 46 | # Check without building 47 | check: 48 | cargo check 49 | -------------------------------------------------------------------------------- /pkg/search/metrics_cache_bench_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "runtime" 5 | "testing" 6 | ) 7 | 8 | func BenchmarkMetricsCacheGet(b *testing.B) { 9 | cache := buildBenchmarkMetricsCache(b, 1000) 10 | ids := buildBenchmarkIssueIDs(1000) 11 | 12 | b.ReportAllocs() 13 | b.ResetTimer() 14 | for i := 0; i < b.N; i++ { 15 | _, _ = cache.Get(ids[i%len(ids)]) 16 | } 17 | } 18 | 19 | func BenchmarkMetricsCacheGetBatch(b *testing.B) { 20 | cache := buildBenchmarkMetricsCache(b, 1000) 21 | ids := buildBenchmarkIssueIDs(100) 22 | 23 | b.ReportAllocs() 24 | b.ResetTimer() 25 | for i := 0; i < b.N; i++ { 26 | _ = cache.GetBatch(ids) 27 | } 28 | } 29 | 30 | func BenchmarkMetricsCacheMemory(b *testing.B) { 31 | runtime.GC() 32 | var m runtime.MemStats 33 | runtime.ReadMemStats(&m) 34 | before := m.Alloc 35 | 36 | loader := &benchmarkMetricsLoader{ 37 | metrics: buildBenchmarkMetrics(10000), 38 | dataHash: "bench-10000", 39 | } 40 | cache := NewMetricsCache(loader) 41 | if err := cache.Refresh(); err != nil { 42 | b.Fatalf("Refresh metrics cache: %v", err) 43 | } 44 | 45 | runtime.GC() 46 | runtime.ReadMemStats(&m) 47 | after := m.Alloc 48 | 49 | _, _ = cache.Get("issue-0") 50 | b.ReportMetric(float64(after-before)/1024.0, "KB") 51 | } 52 | -------------------------------------------------------------------------------- /pkg/ui/repo_picker_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | func TestRepoPickerSelectionAndToggle(t *testing.T) { 11 | repos := []string{"api", "web", "lib"} 12 | m := NewRepoPickerModel(repos, DefaultTheme(lipgloss.NewRenderer(nil))) 13 | m.SetSize(80, 24) 14 | 15 | // Default is all selected 16 | if got := len(m.SelectedRepos()); got != 3 { 17 | t.Fatalf("expected 3 selected repos by default, got %d", got) 18 | } 19 | 20 | // Toggle first repo off 21 | m.ToggleSelected() 22 | if got := len(m.SelectedRepos()); got != 2 { 23 | t.Fatalf("expected 2 selected after toggle, got %d", got) 24 | } 25 | 26 | // Select all 27 | m.SelectAll() 28 | if got := len(m.SelectedRepos()); got != 3 { 29 | t.Fatalf("expected 3 selected after SelectAll, got %d", got) 30 | } 31 | } 32 | 33 | func TestRepoPickerViewContainsRepos(t *testing.T) { 34 | repos := []string{"api"} 35 | m := NewRepoPickerModel(repos, DefaultTheme(lipgloss.NewRenderer(nil))) 36 | m.SetSize(60, 20) 37 | 38 | out := m.View() 39 | if !strings.Contains(out, "Repo Filter") { 40 | t.Fatalf("expected title in view, got:\n%s", out) 41 | } 42 | if !strings.Contains(out, "api") { 43 | t.Fatalf("expected repo name in view, got:\n%s", out) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/search/documents.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/Dicklesworthstone/beads_viewer/pkg/model" 7 | ) 8 | 9 | // IssueDocument returns the default text representation used for semantic indexing. 10 | // We boost important fields by repeating them: ID (x3), title (x2), labels (x1), description (x1). 11 | func IssueDocument(issue model.Issue) string { 12 | var parts []string 13 | 14 | id := strings.TrimSpace(issue.ID) 15 | if id != "" { 16 | parts = append(parts, id, id, id) 17 | } 18 | 19 | title := strings.TrimSpace(issue.Title) 20 | if title != "" { 21 | parts = append(parts, title, title) 22 | } 23 | 24 | labels := strings.TrimSpace(strings.Join(issue.Labels, " ")) 25 | if labels != "" { 26 | parts = append(parts, labels) 27 | } 28 | 29 | desc := strings.TrimSpace(issue.Description) 30 | if desc != "" { 31 | parts = append(parts, desc) 32 | } 33 | 34 | return strings.Join(parts, "\n") 35 | } 36 | 37 | // DocumentsFromIssues builds an ID->document map suitable for indexing. 38 | func DocumentsFromIssues(issues []model.Issue) map[string]string { 39 | docs := make(map[string]string, len(issues)) 40 | for _, issue := range issues { 41 | if issue.ID == "" { 42 | continue 43 | } 44 | docs[issue.ID] = IssueDocument(issue) 45 | } 46 | return docs 47 | } 48 | -------------------------------------------------------------------------------- /pkg/correlation/beads_files.go: -------------------------------------------------------------------------------- 1 | package correlation 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | var defaultBeadsFiles = []string{ 9 | ".beads/issues.jsonl", 10 | ".beads/beads.jsonl", 11 | ".beads/beads.base.jsonl", 12 | } 13 | 14 | func fileExists(path string) bool { 15 | info, err := os.Stat(path) 16 | if err != nil { 17 | return false 18 | } 19 | return !info.IsDir() 20 | } 21 | 22 | func pickBeadsFiles(repoPath string, candidates []string) []string { 23 | if len(candidates) == 0 { 24 | return nil 25 | } 26 | 27 | primary := "" 28 | for _, rel := range candidates { 29 | if rel == "" { 30 | continue 31 | } 32 | if fileExists(filepath.Join(repoPath, rel)) { 33 | primary = rel 34 | break 35 | } 36 | } 37 | if primary == "" { 38 | return candidates 39 | } 40 | 41 | out := make([]string, 0, len(candidates)) 42 | out = append(out, primary) 43 | for _, rel := range candidates { 44 | if rel == primary { 45 | continue 46 | } 47 | out = append(out, rel) 48 | } 49 | return out 50 | } 51 | 52 | func prependBeadsFile(primary string, candidates []string) []string { 53 | if primary == "" { 54 | return candidates 55 | } 56 | out := []string{primary} 57 | for _, rel := range candidates { 58 | if rel == primary { 59 | continue 60 | } 61 | out = append(out, rel) 62 | } 63 | return out 64 | } 65 | -------------------------------------------------------------------------------- /pkg/ui/workspace_filter_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/Dicklesworthstone/beads_viewer/pkg/model" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | func TestApplyFilterRespectsWorkspaceRepoFilter(t *testing.T) { 12 | issues := []model.Issue{ 13 | {ID: "api-AUTH-1", Title: "API", Status: model.StatusOpen}, 14 | {ID: "web-UI-1", Title: "Web", Status: model.StatusOpen}, 15 | } 16 | 17 | m := NewModel(issues, nil, "") 18 | updated, _ := m.Update(tea.WindowSizeMsg{Width: 140, Height: 40}) 19 | m = updated.(Model) 20 | 21 | m.EnableWorkspaceMode(WorkspaceInfo{ 22 | Enabled: true, 23 | RepoCount: 2, 24 | RepoPrefixes: []string{"api-", "web-"}, 25 | }) 26 | 27 | // Filter to api only 28 | m.activeRepos = map[string]bool{"api": true} 29 | m.applyFilter() 30 | 31 | if got := len(m.list.Items()); got != 1 { 32 | t.Fatalf("expected 1 visible item after repo filter, got %d", got) 33 | } 34 | item, ok := m.list.Items()[0].(IssueItem) 35 | if !ok { 36 | t.Fatalf("expected IssueItem") 37 | } 38 | if item.Issue.ID != "api-AUTH-1" { 39 | t.Fatalf("expected api issue, got %s", item.Issue.ID) 40 | } 41 | 42 | // Clear repo filter (nil = all repos) 43 | m.activeRepos = nil 44 | m.applyFilter() 45 | if got := len(m.list.Items()); got != 2 { 46 | t.Fatalf("expected 2 visible items with no repo filter, got %d", got) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /bv-graph-wasm/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! High-performance graph algorithms for bv static viewer. 2 | //! 3 | //! This crate provides WASM-compiled graph algorithms that run in the browser, 4 | //! enabling fast dependency analysis without server roundtrips. 5 | 6 | use wasm_bindgen::prelude::*; 7 | 8 | mod graph; 9 | pub mod algorithms; 10 | mod advanced; 11 | mod whatif; 12 | mod subgraph; 13 | mod reachability; 14 | 15 | pub use graph::DiGraph; 16 | 17 | // Re-export key algorithm functions for testing 18 | pub use algorithms::pagerank::{pagerank, pagerank_default, PageRankConfig}; 19 | pub use algorithms::betweenness::{betweenness, betweenness_approx}; 20 | pub use algorithms::eigenvector::{eigenvector, eigenvector_default, EigenvectorConfig}; 21 | pub use algorithms::critical_path::{critical_path_heights, critical_path_nodes, critical_path_length}; 22 | pub use algorithms::cycles::{has_cycles, tarjan_scc}; 23 | pub use algorithms::kcore::{kcore, degeneracy}; 24 | pub use algorithms::slack::{slack, total_float}; 25 | pub use algorithms::hits::{hits, hits_default, HITSConfig}; 26 | 27 | /// Initialize panic hook for better error messages in browser console. 28 | #[wasm_bindgen(start)] 29 | pub fn init() { 30 | #[cfg(feature = "console_error_panic_hook")] 31 | console_error_panic_hook::set_once(); 32 | } 33 | 34 | /// Get the crate version. 35 | #[wasm_bindgen] 36 | pub fn version() -> String { 37 | env!("CARGO_PKG_VERSION").to_string() 38 | } 39 | -------------------------------------------------------------------------------- /pkg/search/normalizers.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "math" 5 | "time" 6 | ) 7 | 8 | // normalizeStatus maps status to [0,1] range favoring actionable states. 9 | func normalizeStatus(status string) float64 { 10 | switch status { 11 | case "open": 12 | return 1.0 13 | case "in_progress": 14 | return 0.8 15 | case "blocked": 16 | return 0.5 17 | case "closed": 18 | return 0.1 19 | default: 20 | return 0.5 21 | } 22 | } 23 | 24 | // normalizePriority maps P0-P4 to [0.2, 1.0] range. 25 | func normalizePriority(priority int) float64 { 26 | switch priority { 27 | case 0: 28 | return 1.0 29 | case 1: 30 | return 0.8 31 | case 2: 32 | return 0.6 33 | case 3: 34 | return 0.4 35 | case 4: 36 | return 0.2 37 | default: 38 | return 0.5 39 | } 40 | } 41 | 42 | // normalizeImpact normalizes blocker count to [0,1]. 43 | func normalizeImpact(blockerCount, maxBlockerCount int) float64 { 44 | if maxBlockerCount == 0 { 45 | return 0.5 46 | } 47 | if blockerCount <= 0 { 48 | return 0 49 | } 50 | if blockerCount >= maxBlockerCount { 51 | return 1.0 52 | } 53 | return float64(blockerCount) / float64(maxBlockerCount) 54 | } 55 | 56 | // normalizeRecency applies exponential decay (half-life ~30 days). 57 | func normalizeRecency(updatedAt time.Time) float64 { 58 | if updatedAt.IsZero() { 59 | return 0.5 60 | } 61 | daysSinceUpdate := time.Since(updatedAt).Hours() / 24 62 | return math.Exp(-daysSinceUpdate / 30) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/export/viewer_assets/vendor/alpine-collapse.min.js: -------------------------------------------------------------------------------- 1 | (()=>{function g(n){n.directive("collapse",e),e.inline=(t,{modifiers:i})=>{i.includes("min")&&(t._x_doShow=()=>{},t._x_doHide=()=>{})};function e(t,{modifiers:i}){let r=l(i,"duration",250)/1e3,h=l(i,"min",0),u=!i.includes("min");t._x_isShown||(t.style.height=`${h}px`),!t._x_isShown&&u&&(t.hidden=!0),t._x_isShown||(t.style.overflow="hidden");let c=(d,s)=>{let o=n.setStyles(d,s);return s.height?()=>{}:o},f={transitionProperty:"height",transitionDuration:`${r}s`,transitionTimingFunction:"cubic-bezier(0.4, 0.0, 0.2, 1)"};t._x_transition={in(d=()=>{},s=()=>{}){u&&(t.hidden=!1),u&&(t.style.display=null);let o=t.getBoundingClientRect().height;t.style.height="auto";let a=t.getBoundingClientRect().height;o===a&&(o=h),n.transition(t,n.setStyles,{during:f,start:{height:o+"px"},end:{height:a+"px"}},()=>t._x_isShown=!0,()=>{Math.abs(t.getBoundingClientRect().height-a)<1&&(t.style.overflow=null)})},out(d=()=>{},s=()=>{}){let o=t.getBoundingClientRect().height;n.transition(t,c,{during:f,start:{height:o+"px"},end:{height:h+"px"}},()=>t.style.overflow="hidden",()=>{t._x_isShown=!1,t.style.height==`${h}px`&&u&&(t.style.display="none",t.hidden=!0)})}}}}function l(n,e,t){if(n.indexOf(e)===-1)return t;let i=n[n.indexOf(e)+1];if(!i)return t;if(e==="duration"){let r=i.match(/([0-9]+)ms/);if(r)return r[1]}if(e==="min"){let r=i.match(/([0-9]+)px/);if(r)return r[1]}return i}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(g)});})(); 2 | -------------------------------------------------------------------------------- /bv-graph-wasm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bv-graph-wasm" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = ["bv contributors"] 6 | description = "High-performance graph algorithms for bv static viewer" 7 | repository = "https://github.com/Dicklesworthstone/beads_viewer" 8 | license = "MIT" 9 | 10 | [lib] 11 | crate-type = ["cdylib", "rlib"] 12 | 13 | [features] 14 | default = ["console_error_panic_hook", "core"] 15 | 16 | # Core algorithms used by the viewer (required) 17 | core = [] 18 | 19 | # Optional algorithms that can be excluded to reduce bundle size 20 | eigenvector = [] # Eigenvector centrality (~2KB) 21 | kcore = [] # K-core decomposition (~1KB) 22 | slack = [] # Slack/float calculations (~1KB) 23 | hits = [] # HITS algorithm (~2KB) 24 | reachability = [] # Reachability queries (~1KB) 25 | 26 | # Include all algorithms 27 | full = ["core", "eigenvector", "kcore", "slack", "hits", "reachability"] 28 | 29 | [dependencies] 30 | wasm-bindgen = "0.2" 31 | js-sys = "0.3" 32 | serde = { version = "1.0", features = ["derive"] } 33 | serde_json = "1.0" 34 | serde-wasm-bindgen = "0.6" 35 | console_error_panic_hook = { version = "0.1", optional = true } 36 | getrandom = { version = "0.2", features = ["js"] } 37 | 38 | [dev-dependencies] 39 | wasm-bindgen-test = "0.3" 40 | 41 | [profile.release] 42 | # Optimize for size - critical for WASM bundles 43 | opt-level = "s" 44 | lto = true 45 | codegen-units = 1 46 | panic = "abort" 47 | -------------------------------------------------------------------------------- /pkg/ui/visuals_test.go: -------------------------------------------------------------------------------- 1 | package ui_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/Dicklesworthstone/beads_viewer/pkg/ui" 8 | ) 9 | 10 | func TestRenderSparkline(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | val float64 14 | width int 15 | }{ 16 | {"Zero", 0.0, 5}, 17 | {"Full", 1.0, 5}, 18 | {"Half", 0.5, 5}, 19 | {"Small", 0.1, 5}, 20 | {"AlmostFull", 0.99, 5}, 21 | {"Overflow", 1.5, 5}, 22 | {"Underflow", -0.5, 5}, 23 | {"Width1", 0.5, 1}, 24 | {"Width0", 0.5, 0}, // Edge case 25 | {"VerySmall", 0.01, 5}, 26 | } 27 | 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | defer func() { 31 | if r := recover(); r != nil { 32 | t.Errorf("RenderSparkline panicked: %v", r) 33 | } 34 | }() 35 | got := ui.RenderSparkline(tt.val, tt.width) 36 | if len([]rune(got)) != tt.width { 37 | if tt.width > 0 { // Allow 0 length for 0 width 38 | t.Errorf("RenderSparkline length mismatch. Want %d, got %d ('%s')", tt.width, len([]rune(got)), got) 39 | } 40 | } 41 | if strings.Count(got, "\n") > 0 { 42 | t.Errorf("RenderSparkline contains newlines") 43 | } 44 | // Verify visibility for non-zero small values 45 | if tt.name == "VerySmall" && tt.width > 0 { 46 | if strings.TrimSpace(got) == "" { 47 | t.Errorf("RenderSparkline should show visible bar for small values, got empty/spaces: '%s'", got) 48 | } 49 | } 50 | }) 51 | } 52 | } -------------------------------------------------------------------------------- /pkg/loader/real_data_test.go: -------------------------------------------------------------------------------- 1 | package loader_test 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/Dicklesworthstone/beads_viewer/pkg/loader" 8 | ) 9 | 10 | func TestLoadRealIssuesBenchmark(t *testing.T) { 11 | // Look for files in tests/testdata/real 12 | files, _ := filepath.Glob("../../tests/testdata/real/*.jsonl") 13 | 14 | if len(files) == 0 { 15 | t.Skip("No real test data found in tests/testdata/real/") 16 | } 17 | 18 | for _, f := range files { 19 | t.Run(filepath.Base(f), func(t *testing.T) { 20 | issues, err := loader.LoadIssuesFromFile(f) 21 | if err != nil { 22 | t.Fatalf("Failed to load %s: %v", f, err) 23 | } 24 | if len(issues) == 0 { 25 | t.Fatalf("Expected issues in %s, got 0", f) 26 | } 27 | t.Logf("Loaded %d issues from %s", len(issues), f) 28 | 29 | // Validate content of random issue 30 | first := issues[0] 31 | if first.ID == "" { 32 | t.Error("Issue missing ID") 33 | } 34 | }) 35 | } 36 | } 37 | 38 | func BenchmarkLoadLargeFile(b *testing.B) { 39 | // Setup a large synthetic file if real ones aren't huge enough, 40 | // but for now we assume the real ones exist for the purpose of this specific request 41 | files, _ := filepath.Glob("../../tests/testdata/real/*.jsonl") 42 | if len(files) == 0 { 43 | b.Skip("No real test data found") 44 | } 45 | f := files[0] 46 | 47 | b.ResetTimer() 48 | for i := 0; i < b.N; i++ { 49 | _, _ = loader.LoadIssuesFromFile(f) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/export/viewer_assets/hybrid_scorer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | (function runHybridScorerTests() { 3 | if (typeof HybridScorer === 'undefined') { 4 | console.warn('HybridScorer not available; skipping tests.'); 5 | return; 6 | } 7 | 8 | function assertClose(actual, expected, tolerance, message) { 9 | if (Math.abs(actual - expected) > tolerance) { 10 | throw new Error(`${message}: got ${actual}, want ${expected}`); 11 | } 12 | } 13 | 14 | const scorer = new HybridScorer(HYBRID_PRESETS.default); 15 | const now = new Date(); 16 | const result = scorer.scoreAndRank([{ 17 | id: 'test-1', 18 | textScore: 0.8, 19 | pagerank: 0.5, 20 | status: 'open', 21 | priority: 1, 22 | blockerCount: 3, 23 | updatedAt: now.toISOString(), 24 | }])[0]; 25 | 26 | if (!result.component_scores) { 27 | throw new Error('component_scores missing from result'); 28 | } 29 | 30 | // Expected score computed from weights + normalization. 31 | const statusScore = 1.0; 32 | const priorityScore = 0.8; 33 | const impactScore = 1.0; // maxBlockerCount = 3 from scoreAndRank 34 | const recencyScore = 1.0; 35 | const expected = 36 | 0.4 * 0.8 + 37 | 0.2 * 0.5 + 38 | 0.15 * statusScore + 39 | 0.1 * impactScore + 40 | 0.1 * priorityScore + 41 | 0.05 * recencyScore; 42 | 43 | assertClose(result.hybrid_score, expected, 0.01, 'hybrid score mismatch'); 44 | console.log('HybridScorer tests passed'); 45 | })(); 46 | -------------------------------------------------------------------------------- /pkg/search/metrics_cache.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import "time" 4 | 5 | // IssueMetrics contains the graph-derived metrics for hybrid scoring. 6 | type IssueMetrics struct { 7 | IssueID string `json:"issue_id"` 8 | PageRank float64 `json:"pagerank"` // 0.0-1.0, from graph analysis 9 | Status string `json:"status"` // open|in_progress|blocked|closed 10 | Priority int `json:"priority"` // 0-4 (P0=0, P4=4) 11 | BlockerCount int `json:"blocker_count"` // How many issues this blocks 12 | UpdatedAt time.Time `json:"updated_at"` // For recency calculation 13 | } 14 | 15 | // MetricsCache provides fast access to issue metrics for hybrid scoring. 16 | type MetricsCache interface { 17 | // Get returns metrics for an issue, computing/loading if needed. 18 | Get(issueID string) (IssueMetrics, bool) 19 | 20 | // GetBatch returns metrics for multiple issues efficiently. 21 | GetBatch(issueIDs []string) map[string]IssueMetrics 22 | 23 | // Refresh recomputes the cache from source data. 24 | Refresh() error 25 | 26 | // DataHash returns the hash of source data for cache validation. 27 | DataHash() string 28 | 29 | // MaxBlockerCount returns the maximum blocker count for normalization. 30 | MaxBlockerCount() int 31 | } 32 | 33 | // MetricsLoader abstracts the source of metrics (graph analysis or direct DB). 34 | type MetricsLoader interface { 35 | LoadMetrics() (map[string]IssueMetrics, error) 36 | ComputeDataHash() (string, error) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/export/preview_flow_test.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "net" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestPreviewServer_StartWithGracefulShutdown_ReturnsStartError(t *testing.T) { 11 | server := NewPreviewServer("/path/does/not/exist", 9010) 12 | if err := server.StartWithGracefulShutdown(); err == nil { 13 | t.Fatal("Expected StartWithGracefulShutdown to return error for missing bundle path") 14 | } 15 | } 16 | 17 | func TestStartPreview_ReturnsBundleError(t *testing.T) { 18 | if err := StartPreview("/path/does/not/exist"); err == nil { 19 | t.Fatal("Expected StartPreview to return error for missing bundle path") 20 | } 21 | } 22 | 23 | func TestStartPreviewWithConfig_PortInUseReturnsError(t *testing.T) { 24 | bundleDir := t.TempDir() 25 | if err := os.WriteFile(filepath.Join(bundleDir, "index.html"), []byte("<!doctype html><title>ok"), 0644); err != nil { 26 | t.Fatalf("WriteFile index.html: %v", err) 27 | } 28 | 29 | listener, err := net.Listen("tcp", "127.0.0.1:0") 30 | if err != nil { 31 | t.Fatalf("Listen: %v", err) 32 | } 33 | t.Cleanup(func() { _ = listener.Close() }) 34 | 35 | port := listener.Addr().(*net.TCPAddr).Port 36 | cfg := PreviewConfig{ 37 | BundlePath: bundleDir, 38 | Port: port, 39 | OpenBrowser: false, 40 | Quiet: true, 41 | } 42 | 43 | if err := StartPreviewWithConfig(cfg); err == nil { 44 | t.Fatal("Expected StartPreviewWithConfig to return error when port is already in use") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pkg/loader/loader_extra_test.go: -------------------------------------------------------------------------------- 1 | package loader_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/Dicklesworthstone/beads_viewer/pkg/loader" 10 | ) 11 | 12 | func TestParseIssuesWithOptions_LineTooLong(t *testing.T) { 13 | dir := t.TempDir() 14 | path := filepath.Join(dir, "large_line.jsonl") 15 | 16 | // Create a line that is slightly larger than our custom buffer size 17 | const bufferSize = 1024 // 1KB for test 18 | longLine := `{"id":"long","title":"` + strings.Repeat("a", bufferSize) + `"}` 19 | 20 | err := os.WriteFile(path, []byte(longLine+"\n"), 0644) 21 | if err != nil { 22 | t.Fatalf("failed to write test file: %v", err) 23 | } 24 | 25 | var warnings []string 26 | opts := loader.ParseOptions{ 27 | BufferSize: bufferSize, 28 | WarningHandler: func(msg string) { 29 | warnings = append(warnings, msg) 30 | }, 31 | } 32 | 33 | issues, err := loader.LoadIssuesFromFileWithOptions(path, opts) 34 | if err != nil { 35 | t.Fatalf("Expected success (skipping long line), got error: %v", err) 36 | } 37 | 38 | if len(issues) != 0 { 39 | t.Errorf("Expected 0 issues, got %d", len(issues)) 40 | } 41 | 42 | expectedWarning := "skipping line 1: line too long" 43 | found := false 44 | for _, w := range warnings { 45 | if strings.Contains(w, expectedWarning) { 46 | found = true 47 | break 48 | } 49 | } 50 | if !found { 51 | t.Errorf("Expected warning containing %q, got: %v", expectedWarning, warnings) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/search/embedder.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import "context" 4 | 5 | // Provider identifies an embedding backend. 6 | type Provider string 7 | 8 | const ( 9 | // ProviderHash is a pure-Go, dependency-free hashed-token embedder intended as a 10 | // deterministic fallback (and for tests). It is not a true "semantic" model. 11 | ProviderHash Provider = "hash" 12 | 13 | // ProviderPythonSentenceTransformers uses a Python subprocess running 14 | // sentence-transformers to generate high-quality embeddings (MVP choice for bv-9gf). 15 | ProviderPythonSentenceTransformers Provider = "python-sentence-transformers" 16 | 17 | // ProviderOpenAI uses a hosted embedding API. 18 | ProviderOpenAI Provider = "openai" 19 | ) 20 | 21 | const DefaultEmbeddingDim = 384 22 | 23 | const ( 24 | EnvSemanticEmbedder = "BV_SEMANTIC_EMBEDDER" 25 | EnvSemanticModel = "BV_SEMANTIC_MODEL" 26 | EnvSemanticDim = "BV_SEMANTIC_DIM" 27 | ) 28 | 29 | // EmbeddingConfig captures embedder selection/configuration. 30 | // Provider implementations may ignore fields they don't use. 31 | type EmbeddingConfig struct { 32 | Provider Provider 33 | Model string 34 | Dim int 35 | } 36 | 37 | func (c EmbeddingConfig) Normalized() EmbeddingConfig { 38 | if c.Dim <= 0 { 39 | c.Dim = DefaultEmbeddingDim 40 | } 41 | return c 42 | } 43 | 44 | // Embedder produces fixed-size dense vectors for text inputs. 45 | type Embedder interface { 46 | Provider() Provider 47 | Dim() int 48 | Embed(ctx context.Context, texts []string) ([][]float32, error) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/search/lexical_boost.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | ) 7 | 8 | const shortQueryDocBoost = 0.35 9 | 10 | // ShortQueryLexicalBoost returns a literal-match boost for short queries. 11 | // It operates on the same document text used for indexing. 12 | func ShortQueryLexicalBoost(query string, doc string) float64 { 13 | if !IsShortQuery(query) { 14 | return 0 15 | } 16 | needle := strings.ToLower(strings.TrimSpace(query)) 17 | if needle == "" || doc == "" { 18 | return 0 19 | } 20 | if strings.Contains(strings.ToLower(doc), needle) { 21 | return shortQueryDocBoost 22 | } 23 | return 0 24 | } 25 | 26 | // ApplyShortQueryLexicalBoost adds a literal-match boost to short-query results and re-sorts. 27 | func ApplyShortQueryLexicalBoost(results []SearchResult, query string, docs map[string]string) []SearchResult { 28 | if len(results) == 0 || len(docs) == 0 { 29 | return results 30 | } 31 | if !IsShortQuery(query) { 32 | return results 33 | } 34 | 35 | boosted := false 36 | for i := range results { 37 | doc, ok := docs[results[i].IssueID] 38 | if !ok { 39 | continue 40 | } 41 | boost := ShortQueryLexicalBoost(query, doc) 42 | if boost == 0 { 43 | continue 44 | } 45 | results[i].Score += boost 46 | boosted = true 47 | } 48 | 49 | if boosted { 50 | sort.Slice(results, func(i, j int) bool { 51 | if results[i].Score == results[j].Score { 52 | return results[i].IssueID < results[j].IssueID 53 | } 54 | return results[i].Score > results[j].Score 55 | }) 56 | } 57 | 58 | return results 59 | } 60 | -------------------------------------------------------------------------------- /pkg/hooks/config_yaml_test.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | func TestHookUnmarshalYAML(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | yamlData string 14 | wantTimeout time.Duration 15 | wantErr bool 16 | }{ 17 | { 18 | name: "valid duration string", 19 | yamlData: ` 20 | name: test 21 | command: echo 22 | timeout: 10s 23 | `, 24 | wantTimeout: 10 * time.Second, 25 | }, 26 | { 27 | name: "valid duration string minutes", 28 | yamlData: ` 29 | name: test 30 | command: echo 31 | timeout: 1m30s 32 | `, 33 | wantTimeout: 90 * time.Second, 34 | }, 35 | { 36 | name: "numeric timeout", 37 | yamlData: ` 38 | name: test 39 | command: echo 40 | timeout: 30 41 | `, 42 | wantTimeout: 30 * time.Second, 43 | }, 44 | { 45 | name: "invalid duration", 46 | yamlData: ` 47 | name: test 48 | command: echo 49 | timeout: invalid 50 | `, 51 | wantErr: true, 52 | }, 53 | { 54 | name: "empty timeout", 55 | yamlData: ` 56 | name: test 57 | command: echo 58 | `, 59 | wantTimeout: 0, 60 | }, 61 | } 62 | 63 | for _, tt := range tests { 64 | t.Run(tt.name, func(t *testing.T) { 65 | var h Hook 66 | err := yaml.Unmarshal([]byte(tt.yamlData), &h) 67 | if (err != nil) != tt.wantErr { 68 | t.Errorf("UnmarshalYAML() error = %v, wantErr %v", err, tt.wantErr) 69 | return 70 | } 71 | if !tt.wantErr && h.Timeout != tt.wantTimeout { 72 | t.Errorf("Timeout = %v, want %v", h.Timeout, tt.wantTimeout) 73 | } 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pkg/ui/workspace_repos.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | // normalizeRepoPrefixes normalizes workspace repo prefixes (e.g., "api-" -> "api") 10 | // for display and interactive filtering. 11 | func normalizeRepoPrefixes(prefixes []string) []string { 12 | if len(prefixes) == 0 { 13 | return nil 14 | } 15 | 16 | seen := make(map[string]bool, len(prefixes)) 17 | var out []string 18 | for _, raw := range prefixes { 19 | p := strings.TrimSpace(raw) 20 | p = strings.TrimRight(p, "-:_") 21 | p = strings.ToLower(p) 22 | if p == "" { 23 | continue 24 | } 25 | if seen[p] { 26 | continue 27 | } 28 | seen[p] = true 29 | out = append(out, p) 30 | } 31 | sort.Strings(out) 32 | return out 33 | } 34 | 35 | func sortedRepoKeys(selected map[string]bool) []string { 36 | if len(selected) == 0 { 37 | return nil 38 | } 39 | out := make([]string, 0, len(selected)) 40 | for k, v := range selected { 41 | if v { 42 | out = append(out, k) 43 | } 44 | } 45 | sort.Strings(out) 46 | return out 47 | } 48 | 49 | // formatRepoList formats a sorted list of repo keys, truncating after maxNames. 50 | // Example: ["api","web","lib"] with maxNames=2 -> "api,web+1". 51 | func formatRepoList(repos []string, maxNames int) string { 52 | if len(repos) == 0 { 53 | return "" 54 | } 55 | if maxNames <= 0 { 56 | return fmt.Sprintf("%d repos", len(repos)) 57 | } 58 | if len(repos) <= maxNames { 59 | return strings.Join(repos, ",") 60 | } 61 | head := strings.Join(repos[:maxNames], ",") 62 | return fmt.Sprintf("%s+%d", head, len(repos)-maxNames) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/correlation/gitlog_test.go: -------------------------------------------------------------------------------- 1 | package correlation 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestGitLogConstants(t *testing.T) { 9 | // Verify the header format contains expected placeholders 10 | // %H = full commit hash 11 | // %aI = author date ISO 8601 12 | // %an = author name 13 | // %ae = author email 14 | // %s = subject 15 | format := gitLogHeaderFormat 16 | 17 | if !strings.Contains(format, "%H") { 18 | t.Error("expected %H (commit hash) in format") 19 | } 20 | if !strings.Contains(format, "%aI") { 21 | t.Error("expected %aI (ISO date) in format") 22 | } 23 | if !strings.Contains(format, "%an") { 24 | t.Error("expected %an (author name) in format") 25 | } 26 | if !strings.Contains(format, "%ae") { 27 | t.Error("expected %ae (author email) in format") 28 | } 29 | if !strings.Contains(format, "%s") { 30 | t.Error("expected subject placeholder in format") 31 | } 32 | 33 | // Verify null separator is used 34 | if !strings.Contains(format, "%x00") { 35 | t.Error("expected null separator placeholder in format") 36 | } 37 | } 38 | 39 | func TestGitLogMaxScanTokenSize(t *testing.T) { 40 | // Verify the max scan token size is reasonable 41 | const minExpected = 1024 * 1024 // At least 1MB 42 | const maxExpected = 100 * 1024 * 1024 // At most 100MB 43 | 44 | if gitLogMaxScanTokenSize < minExpected { 45 | t.Errorf("gitLogMaxScanTokenSize too small: %d < %d", gitLogMaxScanTokenSize, minExpected) 46 | } 47 | if gitLogMaxScanTokenSize > maxExpected { 48 | t.Errorf("gitLogMaxScanTokenSize too large: %d > %d", gitLogMaxScanTokenSize, maxExpected) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/e2e/tui_snapshot_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | // TestTUIPrioritySnapshot launches the TUI briefly to ensure it initializes and exits cleanly. 12 | // We rely on BV_TUI_AUTOCLOSE_MS to avoid hanging in CI. 13 | func TestTUIPrioritySnapshot(t *testing.T) { 14 | skipIfNoScript(t) 15 | bv := buildBvBinary(t) 16 | 17 | tempDir := t.TempDir() 18 | beadsDir := filepath.Join(tempDir, ".beads") 19 | if err := os.MkdirAll(beadsDir, 0o755); err != nil { 20 | t.Fatalf("mkdir beads: %v", err) 21 | } 22 | // Minimal graph with a dependency to exercise insights/priority panes. 23 | beads := `{"id":"P1","title":"Parent","status":"open","priority":1,"issue_type":"task"} 24 | {"id":"C1","title":"Child","status":"open","priority":2,"issue_type":"task","dependencies":[{"issue_id":"C1","depends_on_id":"P1","type":"blocks"}]}` 25 | if err := os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(beads), 0o644); err != nil { 26 | t.Fatalf("write beads: %v", err) 27 | } 28 | 29 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 30 | defer cancel() 31 | 32 | cmd := scriptTUICommand(ctx, bv) 33 | cmd.Dir = tempDir 34 | cmd.Env = append(os.Environ(), 35 | "TERM=xterm-256color", 36 | "BV_TUI_AUTOCLOSE_MS=1500", 37 | ) 38 | 39 | out, err := cmd.CombinedOutput() 40 | if ctx.Err() == context.DeadlineExceeded { 41 | t.Skipf("skipping TUI snapshot: timed out (likely TTY/OS mismatch); output:\n%s", out) 42 | } 43 | if err != nil { 44 | t.Fatalf("TUI run failed: %v\n%s", err, out) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pkg/ui/attention.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/Dicklesworthstone/beads_viewer/pkg/analysis" 9 | "github.com/Dicklesworthstone/beads_viewer/pkg/model" 10 | ) 11 | 12 | // ComputeAttentionView builds a pre-rendered table for label attention 13 | // This keeps the TUI layer simple and deterministic for tests. 14 | func ComputeAttentionView(issues []model.Issue, width int) (string, error) { 15 | cfg := analysis.DefaultLabelHealthConfig() 16 | result := analysis.ComputeLabelAttentionScores(issues, cfg, time.Now().UTC()) 17 | 18 | headers := []string{"Rank", "Label", "Attention", "Reason"} 19 | colWidths := []int{4, 18, 10, width - 4 - 18 - 10 - 3} 20 | if colWidths[3] < 20 { 21 | colWidths[3] = 20 22 | } 23 | 24 | var b strings.Builder 25 | row := func(cells []string, _ bool) { 26 | var parts []string 27 | for i, c := range cells { 28 | c = truncate(c, colWidths[i]) 29 | parts = append(parts, padRight(c, colWidths[i])) 30 | } 31 | line := strings.Join(parts, " | ") 32 | b.WriteString(line) 33 | b.WriteString("\n") 34 | } 35 | 36 | row(headers, true) 37 | limit := len(result.Labels) 38 | if limit > 10 { 39 | limit = 10 40 | } 41 | for i := 0; i < limit; i++ { 42 | s := result.Labels[i] 43 | // Use BlockedCount (int) instead of BlockImpact (float) 44 | reason := fmt.Sprintf("blocked=%d stale=%d vel=%.1f", s.BlockedCount, s.StaleCount, s.VelocityFactor) 45 | row([]string{ 46 | fmt.Sprintf("%d", i+1), 47 | s.Label, 48 | fmt.Sprintf("%.2f", s.AttentionScore), 49 | reason, 50 | }, false) 51 | } 52 | 53 | return b.String(), nil 54 | } 55 | -------------------------------------------------------------------------------- /testdata/expected/cycle_5_metrics.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "5-node cycle: n0 -\u003e n1 -\u003e n2 -\u003e n3 -\u003e n4 -\u003e n0", 3 | "node_count": 5, 4 | "edge_count": 5, 5 | "density": 0.25, 6 | "pagerank": { 7 | "n0": 0.19999974227409537, 8 | "n1": 0.2000000772198309, 9 | "n2": 0.1999998373556322, 10 | "n3": 0.19999998379290923, 11 | "n4": 0.2000003593575358 12 | }, 13 | "betweenness": { 14 | "n0": 6, 15 | "n1": 6, 16 | "n2": 6, 17 | "n3": 6, 18 | "n4": 6 19 | }, 20 | "eigenvector": { 21 | "n0": 0.447213595499958, 22 | "n1": 0.447213595499958, 23 | "n2": 0.447213595499958, 24 | "n3": 0.447213595499958, 25 | "n4": 0.447213595499958 26 | }, 27 | "hubs": { 28 | "n0": 0.447213595499958, 29 | "n1": 0.447213595499958, 30 | "n2": 0.447213595499958, 31 | "n3": 0.447213595499958, 32 | "n4": 0.447213595499958 33 | }, 34 | "authorities": { 35 | "n0": 0.447213595499958, 36 | "n1": 0.447213595499958, 37 | "n2": 0.447213595499958, 38 | "n3": 0.447213595499958, 39 | "n4": 0.447213595499958 40 | }, 41 | "critical_path_score": {}, 42 | "core_number": { 43 | "n0": 2, 44 | "n1": 2, 45 | "n2": 2, 46 | "n3": 2, 47 | "n4": 2 48 | }, 49 | "has_cycles": true, 50 | "cycles": [ 51 | [ 52 | "n0", 53 | "n4", 54 | "n3", 55 | "n2", 56 | "n1", 57 | "n0" 58 | ] 59 | ], 60 | "out_degree": { 61 | "n0": 1, 62 | "n1": 1, 63 | "n2": 1, 64 | "n3": 1, 65 | "n4": 1 66 | }, 67 | "in_degree": { 68 | "n0": 1, 69 | "n1": 1, 70 | "n2": 1, 71 | "n3": 1, 72 | "n4": 1 73 | } 74 | } -------------------------------------------------------------------------------- /.beads/feedback.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "created_at": "2025-12-16T16:48:54.588218-05:00", 4 | "updated_at": "2025-12-16T16:49:06.367846-05:00", 5 | "events": [], 6 | "adjustments": [ 7 | { 8 | "name": "PageRank", 9 | "adjustment": 1, 10 | "samples": 0, 11 | "last_updated": "2025-12-16T16:49:06.367846-05:00" 12 | }, 13 | { 14 | "name": "Betweenness", 15 | "adjustment": 1, 16 | "samples": 0, 17 | "last_updated": "2025-12-16T16:49:06.367846-05:00" 18 | }, 19 | { 20 | "name": "BlockerRatio", 21 | "adjustment": 1, 22 | "samples": 0, 23 | "last_updated": "2025-12-16T16:49:06.367846-05:00" 24 | }, 25 | { 26 | "name": "Staleness", 27 | "adjustment": 1, 28 | "samples": 0, 29 | "last_updated": "2025-12-16T16:49:06.367846-05:00" 30 | }, 31 | { 32 | "name": "PriorityBoost", 33 | "adjustment": 1, 34 | "samples": 0, 35 | "last_updated": "2025-12-16T16:49:06.367846-05:00" 36 | }, 37 | { 38 | "name": "TimeToImpact", 39 | "adjustment": 1, 40 | "samples": 0, 41 | "last_updated": "2025-12-16T16:49:06.367846-05:00" 42 | }, 43 | { 44 | "name": "Urgency", 45 | "adjustment": 1, 46 | "samples": 0, 47 | "last_updated": "2025-12-16T16:49:06.367846-05:00" 48 | }, 49 | { 50 | "name": "Risk", 51 | "adjustment": 1, 52 | "samples": 0, 53 | "last_updated": "2025-12-16T16:49:06.367846-05:00" 54 | } 55 | ], 56 | "stats": { 57 | "total_accepted": 0, 58 | "total_ignored": 0, 59 | "avg_accept_score": 0, 60 | "avg_ignore_score": 0 61 | } 62 | } -------------------------------------------------------------------------------- /pkg/search/bench_helpers_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | type benchmarkMetricsLoader struct { 10 | metrics map[string]IssueMetrics 11 | dataHash string 12 | } 13 | 14 | func (l *benchmarkMetricsLoader) LoadMetrics() (map[string]IssueMetrics, error) { 15 | return l.metrics, nil 16 | } 17 | 18 | func (l *benchmarkMetricsLoader) ComputeDataHash() (string, error) { 19 | return l.dataHash, nil 20 | } 21 | 22 | func buildBenchmarkMetricsCache(tb testing.TB, size int) MetricsCache { 23 | tb.Helper() 24 | loader := &benchmarkMetricsLoader{ 25 | metrics: buildBenchmarkMetrics(size), 26 | dataHash: fmt.Sprintf("bench-%d", size), 27 | } 28 | cache := NewMetricsCache(loader) 29 | if err := cache.Refresh(); err != nil { 30 | tb.Fatalf("Refresh metrics cache: %v", err) 31 | } 32 | return cache 33 | } 34 | 35 | func buildBenchmarkMetrics(size int) map[string]IssueMetrics { 36 | metrics := make(map[string]IssueMetrics, size) 37 | statuses := []string{"open", "in_progress", "blocked", "closed"} 38 | base := time.Now().Add(-90 * 24 * time.Hour) 39 | for i := 0; i < size; i++ { 40 | id := fmt.Sprintf("issue-%d", i) 41 | metrics[id] = IssueMetrics{ 42 | IssueID: id, 43 | PageRank: float64(i%100) / 100.0, 44 | Status: statuses[i%len(statuses)], 45 | Priority: i % 5, 46 | BlockerCount: i % 10, 47 | UpdatedAt: base.Add(time.Duration(i%90) * 24 * time.Hour), 48 | } 49 | } 50 | return metrics 51 | } 52 | 53 | func buildBenchmarkIssueIDs(size int) []string { 54 | ids := make([]string, size) 55 | for i := 0; i < size; i++ { 56 | ids[i] = fmt.Sprintf("issue-%d", i) 57 | } 58 | return ids 59 | } 60 | -------------------------------------------------------------------------------- /pkg/ui/history_selection_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestHistoryModel_PreserveSelection(t *testing.T) { 8 | report := createTestHistoryReport() // Uses helper from history_test.go 9 | theme := testTheme() 10 | h := NewHistoryModel(report, theme) 11 | 12 | // We expect beads sorted by commit count descending: 13 | // bv-1 (2 commits) 14 | // bv-3 (2 commits) 15 | // bv-2 (1 commit) 16 | 17 | // Select "bv-3" (should be index 1) 18 | h.selectedBead = 1 19 | selectedID := h.SelectedBeadID() 20 | if selectedID != "bv-3" { 21 | t.Fatalf("setup: selectedBead=1 ID=%s, want bv-3. BeadIDs: %v", selectedID, h.beadIDs) 22 | } 23 | 24 | // Apply filter that keeps bv-3 but removes bv-1 25 | // bv-1 author: "Dev One" 26 | // bv-3 author: "Dev Two" 27 | // Filter by "Dev Two" should keep bv-3 and bv-2 28 | h.SetAuthorFilter("Dev Two") 29 | 30 | // Verify bv-3 is still selected 31 | // In the new list: 32 | // bv-3 (2 commits) 33 | // bv-2 (1 commit) 34 | // bv-3 should be index 0 now 35 | 36 | if h.SelectedBeadID() != "bv-3" { 37 | t.Errorf("selection lost after filter: ID=%s, want bv-3", h.SelectedBeadID()) 38 | } 39 | if h.selectedBead != 0 { 40 | t.Errorf("selectedBead index = %d, want 0", h.selectedBead) 41 | } 42 | 43 | // Now apply filter that removes bv-3 44 | // Filter by "Dev One" -> only bv-1 remains 45 | h.SetAuthorFilter("Dev One") 46 | 47 | // Verify selection reset to 0 (bv-1), since bv-3 is gone 48 | if h.SelectedBeadID() != "bv-1" { 49 | t.Errorf("selection should reset to valid item: ID=%s, want bv-1", h.SelectedBeadID()) 50 | } 51 | if h.selectedBead != 0 { 52 | t.Errorf("selectedBead index = %d, want 0", h.selectedBead) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /testdata/expected/diamond_5_metrics.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Diamond DAG: n0 is source, n1/n2 branch, n3 merges, n4 is sink. Tests betweenness.", 3 | "node_count": 5, 4 | "edge_count": 5, 5 | "density": 0.25, 6 | "pagerank": { 7 | "n0": 0.08943254619976536, 8 | "n1": 0.1274413325168216, 9 | "n2": 0.1274413325168216, 10 | "n3": 0.3060823882247097, 11 | "n4": 0.3496024005418817 12 | }, 13 | "betweenness": { 14 | "n1": 1, 15 | "n2": 1, 16 | "n3": 3 17 | }, 18 | "eigenvector": { 19 | "n0": 0, 20 | "n1": 0, 21 | "n2": 0, 22 | "n3": 0, 23 | "n4": 1 24 | }, 25 | "hubs": { 26 | "n0": 0.5773501774222938, 27 | "n1": 0.5773501774222938, 28 | "n2": 0.5773501774222938, 29 | "n3": 0.0005638185326389588, 30 | "n4": 0 31 | }, 32 | "authorities": { 33 | "n0": 0, 34 | "n1": 0.40824816068528846, 35 | "n2": 0.40824816068528846, 36 | "n3": 0.8164963213705769, 37 | "n4": 0.000797359688838454 38 | }, 39 | "critical_path_score": { 40 | "n0": 1, 41 | "n1": 2, 42 | "n2": 2, 43 | "n3": 3, 44 | "n4": 4 45 | }, 46 | "topological_order": [ 47 | "n4", 48 | "n3", 49 | "n2", 50 | "n1", 51 | "n0" 52 | ], 53 | "core_number": { 54 | "n0": 2, 55 | "n1": 2, 56 | "n2": 2, 57 | "n3": 2, 58 | "n4": 1 59 | }, 60 | "slack": { 61 | "n0": 0, 62 | "n1": 0, 63 | "n2": 0, 64 | "n3": 0, 65 | "n4": 0 66 | }, 67 | "has_cycles": false, 68 | "out_degree": { 69 | "n0": 2, 70 | "n1": 1, 71 | "n2": 1, 72 | "n3": 1, 73 | "n4": 0 74 | }, 75 | "in_degree": { 76 | "n0": 0, 77 | "n1": 1, 78 | "n2": 1, 79 | "n3": 2, 80 | "n4": 1 81 | } 82 | } -------------------------------------------------------------------------------- /tests/e2e/emit_script_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "os/exec" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestEmitScript_BashAndFish(t *testing.T) { 10 | bv := buildBvBinary(t) 11 | env := t.TempDir() 12 | 13 | // Ensure at least one actionable recommendation. 14 | writeBeads(t, env, `{"id":"A","title":"Unblocker","status":"open","priority":1,"issue_type":"task"} 15 | {"id":"B","title":"Blocked","status":"open","priority":2,"issue_type":"task","dependencies":[{"issue_id":"B","depends_on_id":"A","type":"blocks"}]}`) 16 | 17 | tests := []struct { 18 | name string 19 | formatFlag string 20 | wantShebang string 21 | wantExtra string 22 | }{ 23 | {name: "bash", formatFlag: "bash", wantShebang: "#!/usr/bin/env bash", wantExtra: "set -euo pipefail"}, 24 | {name: "fish", formatFlag: "fish", wantShebang: "#!/usr/bin/env fish", wantExtra: ""}, 25 | } 26 | 27 | for _, tt := range tests { 28 | t.Run(tt.name, func(t *testing.T) { 29 | cmd := exec.Command(bv, "--emit-script", "--script-limit=1", "--script-format="+tt.formatFlag) 30 | cmd.Dir = env 31 | out, err := cmd.CombinedOutput() 32 | if err != nil { 33 | t.Fatalf("run failed: %v\n%s", err, out) 34 | } 35 | s := string(out) 36 | if !strings.Contains(s, tt.wantShebang) { 37 | t.Fatalf("missing shebang %q:\n%s", tt.wantShebang, s) 38 | } 39 | if tt.wantExtra != "" && !strings.Contains(s, tt.wantExtra) { 40 | t.Fatalf("missing %q:\n%s", tt.wantExtra, s) 41 | } 42 | if !strings.Contains(s, "bd show A") { 43 | t.Fatalf("missing bd show command for A:\n%s", s) 44 | } 45 | if !strings.Contains(s, "# Data hash:") { 46 | t.Fatalf("missing data hash header:\n%s", s) 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /screenshots/convert_webp.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.13" 3 | # dependencies = ["pillow"] 4 | # /// 5 | """Convert images in current directory to WebP format.""" 6 | 7 | import os 8 | from concurrent.futures import ProcessPoolExecutor, as_completed 9 | from pathlib import Path 10 | 11 | 12 | def convert_image_to_webp(paths: tuple[Path, Path]) -> str: 13 | """Convert a single image to WebP format.""" 14 | from PIL import Image 15 | 16 | input_path, output_path = paths 17 | with Image.open(input_path) as img: 18 | img.save(output_path, "WEBP", lossless=False, quality=55, method=6) 19 | return input_path.name 20 | 21 | 22 | def batch_convert_to_webp(folder: Path) -> None: 23 | """Batch convert all PNG/JPG images in folder to WebP.""" 24 | extensions = {".png", ".jpg", ".jpeg"} 25 | image_files = [ 26 | f for f in folder.iterdir() 27 | if f.is_file() and f.suffix.lower() in extensions 28 | ] 29 | 30 | if not image_files: 31 | print("No images found to convert.") 32 | return 33 | 34 | tasks = [ 35 | (img, img.with_suffix(".webp")) 36 | for img in image_files 37 | ] 38 | 39 | workers = min(os.cpu_count() or 4, len(tasks)) 40 | print(f"Converting {len(tasks)} images using {workers} workers...") 41 | 42 | with ProcessPoolExecutor(max_workers=workers) as executor: 43 | futures = {executor.submit(convert_image_to_webp, t): t for t in tasks} 44 | for i, future in enumerate(as_completed(futures), 1): 45 | name = future.result() 46 | print(f"[{i}/{len(tasks)}] Converted: {name}") 47 | 48 | print("Done!") 49 | 50 | 51 | if __name__ == "__main__": 52 | batch_convert_to_webp(Path.cwd()) -------------------------------------------------------------------------------- /tests/testdata/search_hybrid.jsonl: -------------------------------------------------------------------------------- 1 | {"id":"sh-1","title":"Core Identity Service","description":"Core identity service used by login and bug fixes.","status":"open","priority":0,"issue_type":"feature","created_at":"2024-01-01T10:00:00Z","updated_at":"2024-02-01T12:00:00Z","labels":["identity","core"]} 2 | {"id":"sh-2","title":"OAuth UI","description":"User-facing OAuth login flow for auth features.","status":"open","priority":2,"issue_type":"feature","created_at":"2024-01-05T09:00:00Z","updated_at":"2024-01-15T12:00:00Z","labels":["auth","ui"],"dependencies":[{"issue_id":"sh-2","depends_on_id":"sh-1","type":"blocks"}]} 3 | {"id":"sh-3","title":"Fix auth bug in token refresh","description":"Bug fix for auth token refresh edge cases.","status":"open","priority":1,"issue_type":"bug","created_at":"2024-01-06T09:00:00Z","updated_at":"2024-01-20T12:00:00Z","labels":["auth","bug"],"dependencies":[{"issue_id":"sh-3","depends_on_id":"sh-1","type":"blocks"}]} 4 | {"id":"sh-4","title":"Logging pipeline","description":"Infrastructure for structured logs.","status":"in_progress","priority":3,"issue_type":"task","created_at":"2023-12-20T09:00:00Z","updated_at":"2024-01-10T12:00:00Z","labels":["infra"]} 5 | {"id":"sh-5","title":"Audit trail","description":"Audit trail for compliance and traceability.","status":"blocked","priority":2,"issue_type":"task","created_at":"2023-12-10T09:00:00Z","updated_at":"2023-12-15T12:00:00Z","labels":["infra","audit"],"dependencies":[{"issue_id":"sh-5","depends_on_id":"sh-4","type":"blocks"}]} 6 | {"id":"sh-6","title":"Auth docs","description":"Documentation for the auth system. auth auth auth.","status":"closed","priority":4,"issue_type":"chore","created_at":"2023-10-01T09:00:00Z","updated_at":"2023-10-10T12:00:00Z","closed_at":"2023-10-10T12:00:00Z","labels":["auth","docs"],"dependencies":[{"issue_id":"sh-6","depends_on_id":"sh-1","type":"blocks"}]} 7 | -------------------------------------------------------------------------------- /tests/testdata/synthetic_complex.jsonl: -------------------------------------------------------------------------------- 1 | {"id":"bd-101","title":"Implement OAuth2 Authentication","description":"We need to support Google and GitHub login.\n\n### Requirements\n- [ ] Google Strategy\n- [ ] GitHub Strategy\n- [ ] JWT Session handling\n\nSee [RFC 7519](https://tools.ietf.org/html/rfc7519) for details.","status":"in_progress","priority":0,"issue_type":"feature","assignee":"alice","created_at":"2023-10-01T10:00:00Z","updated_at":"2023-10-02T14:30:00Z","labels":["auth","security","backend"],"comments":[{"author":"bob","text":"I can handle the GitHub part.","created_at":"2023-10-01T11:00:00Z"}]} 2 | {"id":"bd-102","title":"Fix memory leak in image processor","description":"The `Resize` function is not releasing buffers.\n\n```go\nfunc Resize(img []byte) {\n // leaking here\n buf := make([]byte, 1024)\n}\n```","status":"open","priority":1,"issue_type":"bug","assignee":"charlie","created_at":"2023-10-03T09:00:00Z","dependencies":[{"issue_id":"bd-102","depends_on_id":"bd-101","type":"related"}]} 3 | {"id":"bd-103","title":"Database Migration for Users Table","description":"Add `last_login` column.","status":"blocked","priority":2,"issue_type":"task","created_at":"2023-10-04T12:00:00Z","dependencies":[{"issue_id":"bd-103","depends_on_id":"bd-101","type":"blocks"}]} 4 | {"id":"bd-104","title":"Update documentation","description":"The README is outdated.","status":"closed","priority":3,"issue_type":"chore","created_at":"2023-09-01T08:00:00Z","closed_at":"2023-09-05T10:00:00Z"} 5 | {"id":"bd-105","title":"Frontend: Login Page","description":"Design the login page using the new UI kit.","status":"in_progress","priority":1,"issue_type":"feature","assignee":"diana","created_at":"2023-10-02T10:00:00Z"} 6 | {"id":"bd-106","title":"Audit Logs","description":"Track all user actions.","status":"open","priority":2,"issue_type":"feature","created_at":"2023-10-05T15:00:00Z"} -------------------------------------------------------------------------------- /pkg/cass/doc.go: -------------------------------------------------------------------------------- 1 | // Package cass provides integration with the cass semantic code search tool. 2 | // 3 | // Cass (https://github.com/cass-lang/cass) is an external binary that provides 4 | // semantic code search capabilities. This package handles detection and health 5 | // checking to determine if cass is available before attempting integration. 6 | // 7 | // # Detection Flow 8 | // 9 | // The Detector performs a two-step detection process: 10 | // 11 | // 1. Check if "cass" exists in PATH using exec.LookPath 12 | // 2. Run "cass health" with a configurable timeout (default 2s) 13 | // 14 | // # Health Status 15 | // 16 | // Based on the exit code from "cass health": 17 | // 18 | // Exit 0: StatusHealthy - ready to search 19 | // Exit 1: StatusNeedsIndex - needs indexing before use 20 | // Exit 3: StatusNeedsIndex - index missing or corrupt 21 | // Other: StatusNotInstalled - treat as unavailable 22 | // 23 | // # Caching 24 | // 25 | // Detection results are cached for 5 minutes by default (configurable). 26 | // This avoids repeated subprocess calls during normal operation. 27 | // The cache can be invalidated explicitly via Invalidate() or will 28 | // automatically expire after the TTL. 29 | // 30 | // # Thread Safety 31 | // 32 | // The Detector is safe for concurrent use. All exported methods use 33 | // appropriate locking to prevent data races. 34 | // 35 | // # Example Usage 36 | // 37 | // detector := cass.NewDetector() 38 | // if detector.Check() == cass.StatusHealthy { 39 | // // Safe to use cass for searching 40 | // results := runCassSearch(query) 41 | // } 42 | // 43 | // # Silent Failure 44 | // 45 | // This package is designed for silent degradation. When cass is not available, 46 | // it returns StatusNotInstalled without logging errors or warnings. The 47 | // consuming code should simply disable cass-dependent features. 48 | package cass 49 | -------------------------------------------------------------------------------- /pkg/ui/graph_internal_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | "unicode/utf8" 6 | ) 7 | 8 | func TestSmartTruncateID(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | id string 12 | maxLen int 13 | expected string 14 | }{ 15 | {"Short ID fits", "foo", 10, "foo"}, 16 | {"Exact fit", "foo-bar", 7, "foo-bar"}, 17 | {"Simple truncation", "foo-bar-baz", 5, "foo-…"}, 18 | {"Hyphenated ID abbreviation", "service-auth-login", 10, "s-a-login"}, 19 | {"Underscore ID abbreviation", "service_auth_login", 10, "s_a_login"}, 20 | {"Mixed separators (hyphen priority)", "service-auth_login", 12, "s-a_login"}, 21 | {"Mixed separators (complex)", "foo-bar_baz-qux", 10, "f-b_b-qux"}, 22 | {"Very short limit", "abc-def", 3, "ab…"}, 23 | {"Single part ID truncation", "verylongsinglepartid", 5, "very…"}, 24 | } 25 | 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | got := smartTruncateID(tt.id, tt.maxLen) 29 | runeCount := utf8.RuneCountInString(got) 30 | if runeCount > tt.maxLen { 31 | t.Errorf("Result rune count %d exceeds maxLen %d. Got: %s", runeCount, tt.maxLen, got) 32 | } 33 | // We don't assert exact match for mixed/complex because the logic is heuristic 34 | // but we check that it produces *something* valid and doesn't crash or empty out 35 | if got == "" && tt.maxLen > 0 { 36 | t.Errorf("Result is empty") 37 | } 38 | 39 | // For specific mixed case that failed before fix: 40 | if tt.name == "Mixed separators (complex)" { 41 | // Before fix: split by '-' (defaulting sep to '-') would likely yield chunks that included '_' 42 | // After fix: FieldsFunc splits by both, so abbreviation logic should work better 43 | // Just verifying it doesn't look totally broken 44 | t.Logf("Input: %s, Max: %d, Got: %s", tt.id, tt.maxLen, got) 45 | } 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkg/ui/recipe_picker_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/Dicklesworthstone/beads_viewer/pkg/recipe" 8 | 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | func TestRecipePickerSelection(t *testing.T) { 13 | recipes := []recipe.Recipe{ 14 | {Name: "Triage", Description: "Focus on blockers"}, 15 | {Name: "Release", Description: "Prep for release"}, 16 | {Name: "Cleanup", Description: "Debt sweep"}, 17 | } 18 | 19 | m := NewRecipePickerModel(recipes, DefaultTheme(lipgloss.NewRenderer(nil))) 20 | m.SetSize(80, 24) 21 | 22 | if sel := m.SelectedRecipe(); sel == nil || sel.Name != "Triage" { 23 | t.Fatalf("expected initial selection Triage, got %+v", sel) 24 | } 25 | 26 | m.MoveDown() 27 | if sel := m.SelectedRecipe(); sel == nil || sel.Name != "Release" { 28 | t.Fatalf("expected selection Release after MoveDown, got %+v", sel) 29 | } 30 | 31 | m.MoveUp() 32 | if sel := m.SelectedRecipe(); sel == nil || sel.Name != "Triage" { 33 | t.Fatalf("expected back to Triage after MoveUp, got %+v", sel) 34 | } 35 | } 36 | 37 | func TestRecipePickerViewContainsNames(t *testing.T) { 38 | recipes := []recipe.Recipe{ 39 | {Name: "Alpha", Description: "First"}, 40 | } 41 | m := NewRecipePickerModel(recipes, DefaultTheme(lipgloss.NewRenderer(nil))) 42 | m.SetSize(60, 20) 43 | 44 | out := m.View() 45 | if !strings.Contains(out, "Alpha") { 46 | t.Fatalf("expected view to contain recipe name, got:\n%s", out) 47 | } 48 | if !strings.Contains(out, "Select Recipe") { 49 | t.Fatalf("expected view title, got:\n%s", out) 50 | } 51 | } 52 | 53 | func TestFormatRecipeInfo(t *testing.T) { 54 | if got := FormatRecipeInfo(nil); got != "" { 55 | t.Fatalf("expected empty string for nil recipe, got %q", got) 56 | } 57 | r := recipe.Recipe{Name: "Demo"} 58 | if got := FormatRecipeInfo(&r); got != "Recipe: Demo" { 59 | t.Fatalf("unexpected format: %s", got) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /pkg/search/hash_embedder_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | "math" 6 | "testing" 7 | ) 8 | 9 | func TestHashEmbedder_Deterministic(t *testing.T) { 10 | embedder := NewHashEmbedder(64) 11 | 12 | v1, err := embedder.Embed(context.Background(), []string{"memory leak bug"}) 13 | if err != nil { 14 | t.Fatalf("Embed failed: %v", err) 15 | } 16 | v2, err := embedder.Embed(context.Background(), []string{"memory leak bug"}) 17 | if err != nil { 18 | t.Fatalf("Embed failed: %v", err) 19 | } 20 | 21 | if len(v1) != 1 || len(v2) != 1 { 22 | t.Fatalf("Unexpected output sizes: %d %d", len(v1), len(v2)) 23 | } 24 | if len(v1[0]) != embedder.Dim() || len(v2[0]) != embedder.Dim() { 25 | t.Fatalf("Unexpected vector dims: %d %d", len(v1[0]), len(v2[0])) 26 | } 27 | 28 | for i := range v1[0] { 29 | if v1[0][i] != v2[0][i] { 30 | t.Fatalf("Vectors differ at %d: %v vs %v", i, v1[0][i], v2[0][i]) 31 | } 32 | } 33 | } 34 | 35 | func TestHashEmbedder_CosineSimilarityTokenOverlap(t *testing.T) { 36 | embedder := NewHashEmbedder(DefaultEmbeddingDim) 37 | 38 | vecs, err := embedder.Embed(context.Background(), []string{ 39 | "memory leak bug", 40 | "leak memory issue", 41 | "frontend styling polish", 42 | }) 43 | if err != nil { 44 | t.Fatalf("Embed failed: %v", err) 45 | } 46 | 47 | overlap := cosine(vecs[0], vecs[1]) 48 | disjoint := cosine(vecs[0], vecs[2]) 49 | 50 | if overlap <= disjoint { 51 | t.Fatalf("Expected overlap cosine > disjoint cosine; overlap=%f disjoint=%f", overlap, disjoint) 52 | } 53 | } 54 | 55 | func cosine(a, b []float32) float64 { 56 | if len(a) != len(b) || len(a) == 0 { 57 | return 0 58 | } 59 | var dot, na, nb float64 60 | for i := range a { 61 | ai := float64(a[i]) 62 | bi := float64(b[i]) 63 | dot += ai * bi 64 | na += ai * ai 65 | nb += bi * bi 66 | } 67 | if na == 0 || nb == 0 { 68 | return 0 69 | } 70 | return dot / (math.Sqrt(na) * math.Sqrt(nb)) 71 | } 72 | -------------------------------------------------------------------------------- /testdata/golden/graph_render/star_10_ascii.golden: -------------------------------------------------------------------------------- 1 | ╔═══════════════════════════════════════╗ 2 | ║ 🔵 ⚡ 📝 n0 ║ 3 | ║ n0 ║ 4 | ║ ⬆0 ⬇9 ║ 5 | ╚═══════════════════════════════════════╝ 6 | │ 7 | ├─┼─┼─┼─┤ 8 | ▼ 9 | ╭──────────────╮╭──────────────╮╭──────────────╮╭──────────────╮╭───────────── 10 | ─╮ 11 | │ 🔵 n1 ││ 🔵 n2 ││ 🔵 n3 ││ 🔵 n4 ││ 🔵 n5 12 | │+4 more 13 | ╰──────────────╯╰──────────────╯╰──────────────╯╰──────────────╯╰───────────── 14 | ─╯ 15 | ▼ BLOCKS (waiting on this) ▼ 16 | 17 | 📊 GRAPH METRICS 18 | ────────────────────────────────────────────────────────────────────────── 19 | Importance 20 | Critical Path 2.00 ██████ #1 21 | PageRank 0.4901 ██████ #1 22 | Eigenvector 1.00 ██████ #1 23 | 24 | Flow & Connectivity 25 | Betweenness 0.0000 ░░░░░░ #10 26 | Hub Score 0.0000 ░░░░░░ #10 27 | Authority 1.00 ██████ #1 28 | 29 | Connections 30 | In-Degree 9 ██████ #1 31 | Out-Degree 0 ░░░░░░ #10 32 | 33 | █ relative score │ #N rank of 10 issues 34 | 35 | j/k: navigate • enter: view details • g: back to list -------------------------------------------------------------------------------- /pkg/analysis/graph_extra_test.go: -------------------------------------------------------------------------------- 1 | package analysis 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/Dicklesworthstone/beads_viewer/pkg/model" 8 | ) 9 | 10 | // Cover getter and configured analysis pathways that were previously untested. 11 | func TestAnalyzerProfileAndGetters(t *testing.T) { 12 | issues := []model.Issue{ 13 | {ID: "A", Title: "Alpha", Status: model.StatusOpen, Dependencies: []*model.Dependency{{DependsOnID: "B", Type: model.DepBlocks}}}, 14 | {ID: "B", Title: "Beta", Status: model.StatusOpen}, 15 | } 16 | 17 | custom := ConfigForSize(len(issues), 1) 18 | a := NewAnalyzer(issues) 19 | a.SetConfig(&custom) 20 | 21 | stats, profile := a.AnalyzeWithProfile(custom) 22 | if profile == nil || stats == nil { 23 | t.Fatalf("expected stats and profile") 24 | } 25 | if !stats.IsPhase2Ready() { 26 | t.Fatalf("phase2 should be ready after AnalyzeWithProfile") 27 | } 28 | 29 | _ = a.GetIssue("A") 30 | _ = stats.GetPageRankScore("A") 31 | _ = stats.GetBetweennessScore("A") 32 | _ = stats.GetEigenvectorScore("A") 33 | _ = stats.GetHubScore("A") 34 | _ = stats.GetAuthorityScore("A") 35 | _ = stats.GetCriticalPathScore("A") 36 | } 37 | 38 | func TestAnalyzerAnalyzeWithConfigCachesPhase2(t *testing.T) { 39 | issues := []model.Issue{{ID: "X", Status: model.StatusOpen}} 40 | a := NewAnalyzer(issues) 41 | cfg := FullAnalysisConfig() 42 | stats := a.AnalyzeWithConfig(cfg) 43 | stats.WaitForPhase2() 44 | if stats.NodeCount != 1 || stats.EdgeCount != 0 { 45 | t.Fatalf("unexpected counts: nodes=%d edges=%d", stats.NodeCount, stats.EdgeCount) 46 | } 47 | if stats.IsPhase2Ready() == false { 48 | t.Fatalf("expected phase2 ready") 49 | } 50 | // Ensure empty graph path still returns non-nil profile 51 | a2 := NewAnalyzer(nil) 52 | if _, profile := a2.AnalyzeWithProfile(cfg); profile == nil { 53 | t.Fatalf("expected non-nil profile for empty graph") 54 | } 55 | // Tiny sleep to avoid zero durations in formatDuration paths 56 | time.Sleep(1 * time.Millisecond) 57 | } 58 | -------------------------------------------------------------------------------- /pkg/search/presets.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import "fmt" 4 | 5 | // PresetName identifies a named weight configuration. 6 | type PresetName string 7 | 8 | const ( 9 | PresetDefault PresetName = "default" 10 | PresetBugHunting PresetName = "bug-hunting" 11 | PresetSprintPlanning PresetName = "sprint-planning" 12 | PresetImpactFirst PresetName = "impact-first" 13 | PresetTextOnly PresetName = "text-only" 14 | ) 15 | 16 | var presets = map[PresetName]Weights{ 17 | PresetDefault: { 18 | TextRelevance: 0.40, 19 | PageRank: 0.20, 20 | Status: 0.15, 21 | Impact: 0.10, 22 | Priority: 0.10, 23 | Recency: 0.05, 24 | }, 25 | PresetBugHunting: { 26 | TextRelevance: 0.30, 27 | PageRank: 0.15, 28 | Status: 0.15, 29 | Impact: 0.15, 30 | Priority: 0.20, 31 | Recency: 0.05, 32 | }, 33 | PresetSprintPlanning: { 34 | TextRelevance: 0.30, 35 | PageRank: 0.20, 36 | Status: 0.25, 37 | Impact: 0.15, 38 | Priority: 0.05, 39 | Recency: 0.05, 40 | }, 41 | PresetImpactFirst: { 42 | TextRelevance: 0.25, 43 | PageRank: 0.30, 44 | Status: 0.10, 45 | Impact: 0.20, 46 | Priority: 0.10, 47 | Recency: 0.05, 48 | }, 49 | PresetTextOnly: { 50 | TextRelevance: 1.00, 51 | PageRank: 0.00, 52 | Status: 0.00, 53 | Impact: 0.00, 54 | Priority: 0.00, 55 | Recency: 0.00, 56 | }, 57 | } 58 | 59 | // GetPreset returns the weights for a named preset. 60 | func GetPreset(name PresetName) (Weights, error) { 61 | weights, ok := presets[name] 62 | if !ok { 63 | return Weights{}, fmt.Errorf("unknown preset %q", name) 64 | } 65 | return weights, nil 66 | } 67 | 68 | // ListPresets returns all available preset names. 69 | func ListPresets() []PresetName { 70 | return []PresetName{ 71 | PresetDefault, 72 | PresetBugHunting, 73 | PresetSprintPlanning, 74 | PresetImpactFirst, 75 | PresetTextOnly, 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/e2e/robot_suggest_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import "testing" 4 | 5 | func TestRobotSuggestContract(t *testing.T) { 6 | bv := buildBvBinary(t) 7 | env := t.TempDir() 8 | // Two similar issues to exercise suggestion pipeline (duplicates/labels may or may not trigger). 9 | writeBeads(t, env, `{"id":"A","title":"Login OAuth bug","status":"open","priority":1,"issue_type":"task","description":"OAuth login fails with 500 in auth handler"} 10 | {"id":"B","title":"OAuth login failure","status":"open","priority":2,"issue_type":"task","description":"Login via OAuth returns error; auth flow seems broken"}`) 11 | 12 | var first struct { 13 | GeneratedAt string `json:"generated_at"` 14 | DataHash string `json:"data_hash"` 15 | Suggestions struct { 16 | Suggestions []struct { 17 | Type string `json:"type"` 18 | TargetBead string `json:"target_bead"` 19 | Confidence float64 `json:"confidence"` 20 | } `json:"suggestions"` 21 | Stats struct { 22 | Total int `json:"total"` 23 | } `json:"stats"` 24 | } `json:"suggestions"` 25 | UsageHints []string `json:"usage_hints"` 26 | } 27 | runRobotJSON(t, bv, env, "--robot-suggest", &first) 28 | 29 | if first.GeneratedAt == "" { 30 | t.Fatalf("suggest missing generated_at") 31 | } 32 | if first.DataHash == "" { 33 | t.Fatalf("suggest missing data_hash") 34 | } 35 | if len(first.UsageHints) == 0 { 36 | t.Fatalf("suggest missing usage_hints") 37 | } 38 | if first.Suggestions.Stats.Total != len(first.Suggestions.Suggestions) { 39 | t.Fatalf("suggest stats.total mismatch: %d vs %d", first.Suggestions.Stats.Total, len(first.Suggestions.Suggestions)) 40 | } 41 | 42 | // Determinism: second call should share the same data_hash 43 | var second struct { 44 | DataHash string `json:"data_hash"` 45 | } 46 | runRobotJSON(t, bv, env, "--robot-suggest", &second) 47 | if first.DataHash != second.DataHash { 48 | t.Fatalf("suggest data_hash changed between calls: %v vs %v", first.DataHash, second.DataHash) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/search/query_adjust_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | ) 7 | 8 | func TestAdjustWeightsForQuery_ShortQueryBoostsText(t *testing.T) { 9 | weights, err := GetPreset(PresetImpactFirst) 10 | if err != nil { 11 | t.Fatalf("preset: %v", err) 12 | } 13 | adjusted := AdjustWeightsForQuery(weights, "benchmarks") 14 | if adjusted.TextRelevance < shortQueryMinTextWeight { 15 | t.Fatalf("expected text weight >= %.2f, got %.2f", shortQueryMinTextWeight, adjusted.TextRelevance) 16 | } 17 | if adjusted.TextRelevance <= weights.TextRelevance { 18 | t.Fatalf("expected text weight to increase for short query") 19 | } 20 | if adjusted.PageRank >= weights.PageRank { 21 | t.Fatalf("expected pagerank weight to decrease for short query") 22 | } 23 | sum := adjusted.sum() 24 | if math.Abs(sum-1.0) > 1e-6 { 25 | t.Fatalf("expected weights to sum to 1.0, got %.6f", sum) 26 | } 27 | } 28 | 29 | func TestAdjustWeightsForQuery_LongQueryNoChange(t *testing.T) { 30 | weights, err := GetPreset(PresetDefault) 31 | if err != nil { 32 | t.Fatalf("preset: %v", err) 33 | } 34 | query := "document steps to reproduce oauth login regression in staging" 35 | adjusted := AdjustWeightsForQuery(weights, query) 36 | if adjusted != weights { 37 | t.Fatalf("expected weights unchanged for long query") 38 | } 39 | } 40 | 41 | func TestHybridCandidateLimit(t *testing.T) { 42 | shortLimit := HybridCandidateLimit(5, 1000, "benchmarks") 43 | if shortLimit < hybridCandidateMinShort { 44 | t.Fatalf("expected short-query candidate limit >= %d, got %d", hybridCandidateMinShort, shortLimit) 45 | } 46 | longLimit := HybridCandidateLimit(5, 1000, "long descriptive query for hybrid search relevance") 47 | if longLimit < hybridCandidateMin { 48 | t.Fatalf("expected long-query candidate limit >= %d, got %d", hybridCandidateMin, longLimit) 49 | } 50 | capped := HybridCandidateLimit(5, 20, "benchmarks") 51 | if capped != 20 { 52 | t.Fatalf("expected candidate limit capped by total, got %d", capped) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/analysis/status_fullstats_test.go: -------------------------------------------------------------------------------- 1 | package analysis 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "sort" 7 | "testing" 8 | 9 | "github.com/Dicklesworthstone/beads_viewer/pkg/model" 10 | ) 11 | 12 | // TestMetricStatusAndFullStatsLimits verifies metric status population and map caps using real Analyzer. 13 | func TestMetricStatusAndFullStatsLimits(t *testing.T) { 14 | issues := []model.Issue{ 15 | {ID: "A", Title: "A", Status: model.StatusOpen, Priority: 1}, 16 | {ID: "B", Title: "B", Status: model.StatusOpen, Priority: 1, Dependencies: []*model.Dependency{{IssueID: "B", DependsOnID: "A", Type: model.DepBlocks}}}, 17 | {ID: "C", Title: "C", Status: model.StatusOpen, Priority: 1, Dependencies: []*model.Dependency{{IssueID: "C", DependsOnID: "A", Type: model.DepBlocks}}}, 18 | } 19 | 20 | // cap maps to 2 21 | os.Setenv("BV_INSIGHTS_MAP_LIMIT", "2") 22 | defer os.Unsetenv("BV_INSIGHTS_MAP_LIMIT") 23 | 24 | cached := NewCachedAnalyzer(issues, nil) 25 | stats := cached.AnalyzeAsync(context.Background()) 26 | stats.WaitForPhase2() 27 | 28 | status := stats.Status() 29 | if status.PageRank.State == "" { 30 | t.Fatalf("expected pagerank status populated") 31 | } 32 | 33 | // emulate full_stats trimming logic 34 | cap := 2 35 | trim := func(m map[string]float64, limit int) map[string]float64 { 36 | if limit <= 0 || limit >= len(m) { 37 | return m 38 | } 39 | type kv struct { 40 | k string 41 | v float64 42 | } 43 | var items []kv 44 | for k, v := range m { 45 | items = append(items, kv{k, v}) 46 | } 47 | sort.Slice(items, func(i, j int) bool { 48 | if items[i].v == items[j].v { 49 | return items[i].k < items[j].k 50 | } 51 | return items[i].v > items[j].v 52 | }) 53 | out := make(map[string]float64, limit) 54 | for i := 0; i < limit; i++ { 55 | out[items[i].k] = items[i].v 56 | } 57 | return out 58 | } 59 | 60 | prTrim := trim(stats.PageRank(), cap) 61 | if len(prTrim) != cap { 62 | t.Fatalf("expected trimmed pagerank size %d, got %d", cap, len(prTrim)) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/updater/download_test.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func TestDownloadFile_SizeVerified_Success(t *testing.T) { 12 | body := []byte("hello") 13 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | w.Header().Set("Content-Length", "5") 15 | w.WriteHeader(http.StatusOK) 16 | _, _ = w.Write(body) 17 | })) 18 | t.Cleanup(srv.Close) 19 | 20 | dest := filepath.Join(t.TempDir(), "file.bin") 21 | if err := downloadFile(srv.URL, dest, 5); err != nil { 22 | t.Fatalf("downloadFile: %v", err) 23 | } 24 | got, err := os.ReadFile(dest) 25 | if err != nil { 26 | t.Fatalf("read dest: %v", err) 27 | } 28 | if string(got) != "hello" { 29 | t.Fatalf("dest content=%q; want %q", string(got), "hello") 30 | } 31 | } 32 | 33 | func TestDownloadFile_SizeMismatch_Header(t *testing.T) { 34 | body := []byte("abcd") 35 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 36 | w.Header().Set("Content-Length", "4") 37 | w.WriteHeader(http.StatusOK) 38 | _, _ = w.Write(body) 39 | })) 40 | t.Cleanup(srv.Close) 41 | 42 | dest := filepath.Join(t.TempDir(), "file.bin") 43 | if err := downloadFile(srv.URL, dest, 5); err == nil { 44 | t.Fatalf("expected size mismatch error") 45 | } 46 | } 47 | 48 | func TestDownloadFile_SizeMismatch_WrittenBytes(t *testing.T) { 49 | // Force chunked transfer so ContentLength is not available to the client; 50 | // then rely on the post-download byte-count check. 51 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 52 | w.WriteHeader(http.StatusOK) 53 | if f, ok := w.(http.Flusher); ok { 54 | f.Flush() 55 | } 56 | _, _ = w.Write([]byte("abcd")) 57 | })) 58 | t.Cleanup(srv.Close) 59 | 60 | dest := filepath.Join(t.TempDir(), "file.bin") 61 | if err := downloadFile(srv.URL, dest, 5); err == nil { 62 | t.Fatalf("expected downloaded size mismatch error") 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/search/normalizers_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestNormalizeStatus(t *testing.T) { 10 | cases := map[string]float64{ 11 | "open": 1.0, 12 | "in_progress": 0.8, 13 | "blocked": 0.5, 14 | "closed": 0.1, 15 | "unknown": 0.5, 16 | } 17 | for status, expected := range cases { 18 | if got := normalizeStatus(status); got != expected { 19 | t.Fatalf("status %q: expected %f, got %f", status, expected, got) 20 | } 21 | } 22 | } 23 | 24 | func TestNormalizePriority(t *testing.T) { 25 | cases := map[int]float64{ 26 | 0: 1.0, 27 | 1: 0.8, 28 | 2: 0.6, 29 | 3: 0.4, 30 | 4: 0.2, 31 | 9: 0.5, 32 | } 33 | for priority, expected := range cases { 34 | if got := normalizePriority(priority); got != expected { 35 | t.Fatalf("priority %d: expected %f, got %f", priority, expected, got) 36 | } 37 | } 38 | } 39 | 40 | func TestNormalizeImpact(t *testing.T) { 41 | if got := normalizeImpact(1, 0); got != 0.5 { 42 | t.Fatalf("expected neutral impact for max=0, got %f", got) 43 | } 44 | if got := normalizeImpact(0, 5); got != 0 { 45 | t.Fatalf("expected 0 for blockerCount=0, got %f", got) 46 | } 47 | if got := normalizeImpact(5, 5); got != 1.0 { 48 | t.Fatalf("expected 1.0 for blockerCount=max, got %f", got) 49 | } 50 | if got := normalizeImpact(2, 4); got != 0.5 { 51 | t.Fatalf("expected 0.5 for blockerCount=2 max=4, got %f", got) 52 | } 53 | } 54 | 55 | func TestNormalizeRecency(t *testing.T) { 56 | if got := normalizeRecency(time.Time{}); got != 0.5 { 57 | t.Fatalf("expected neutral recency for zero time, got %f", got) 58 | } 59 | 60 | now := time.Now() 61 | if got := normalizeRecency(now); math.Abs(got-1.0) > 1e-6 { 62 | t.Fatalf("expected recency ~1.0 for now, got %f", got) 63 | } 64 | 65 | thirtyDaysAgo := now.Add(-30 * 24 * time.Hour) 66 | expected := math.Exp(-1) 67 | if got := normalizeRecency(thirtyDaysAgo); math.Abs(got-expected) > 1e-6 { 68 | t.Fatalf("expected recency %f for 30 days ago, got %f", expected, got) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /pkg/ui/truncate_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | "unicode/utf8" 6 | ) 7 | 8 | func TestTruncateString_UTF8Safe(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | input string 12 | maxLen int 13 | want string 14 | }{ 15 | {name: "zero max", input: "hello", maxLen: 0, want: ""}, 16 | {name: "fits", input: "hello", maxLen: 10, want: "hello"}, 17 | {name: "small max no ellipsis", input: "こんにちは", maxLen: 3, want: "こんに"}, 18 | {name: "ellipsis", input: "a🙂b🙂c", maxLen: 4, want: "a🙂b…"}, 19 | } 20 | 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(t *testing.T) { 23 | got := truncateString(tt.input, tt.maxLen) 24 | if got != tt.want { 25 | t.Fatalf("truncateString(%q, %d) = %q; want %q", tt.input, tt.maxLen, got, tt.want) 26 | } 27 | if !utf8.ValidString(got) { 28 | t.Fatalf("truncateString output is not valid UTF-8: %q", got) 29 | } 30 | if tt.maxLen >= 0 && len([]rune(got)) > tt.maxLen { 31 | t.Fatalf("truncateString output has %d runes; max %d", len([]rune(got)), tt.maxLen) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | func TestTruncateStrSprint_UTF8Safe(t *testing.T) { 38 | tests := []struct { 39 | name string 40 | input string 41 | maxLen int 42 | want string 43 | }{ 44 | {name: "zero max", input: "hello", maxLen: 0, want: ""}, 45 | {name: "fits", input: "hello", maxLen: 10, want: "hello"}, 46 | {name: "small max no ellipsis", input: "🙂🙂🙂", maxLen: 2, want: "🙂🙂"}, 47 | {name: "ellipsis", input: "a🙂b🙂c", maxLen: 4, want: "a🙂b…"}, 48 | } 49 | 50 | for _, tt := range tests { 51 | t.Run(tt.name, func(t *testing.T) { 52 | got := truncateStrSprint(tt.input, tt.maxLen) 53 | if got != tt.want { 54 | t.Fatalf("truncateStrSprint(%q, %d) = %q; want %q", tt.input, tt.maxLen, got, tt.want) 55 | } 56 | if !utf8.ValidString(got) { 57 | t.Fatalf("truncateStrSprint output is not valid UTF-8: %q", got) 58 | } 59 | if tt.maxLen >= 0 && len([]rune(got)) > tt.maxLen { 60 | t.Fatalf("truncateStrSprint output has %d runes; max %d", len([]rune(got)), tt.maxLen) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /scripts/e2e_hybrid_search.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) 5 | FIXTURE="$ROOT_DIR/tests/testdata/search_hybrid.jsonl" 6 | TMP_DIR=$(mktemp -d) 7 | BEADS_DIR="$TMP_DIR/.beads" 8 | QUERY="auth" 9 | LIMIT=5 10 | 11 | log() { 12 | printf "[%s] %s\n" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$*" 13 | } 14 | 15 | cleanup() { 16 | rm -rf "$TMP_DIR" 17 | } 18 | trap cleanup EXIT 19 | 20 | mkdir -p "$BEADS_DIR" 21 | cp "$FIXTURE" "$BEADS_DIR/beads.jsonl" 22 | 23 | log "Using BEADS_DIR=$BEADS_DIR" 24 | log "Fixture: $FIXTURE" 25 | log "Query: $QUERY" 26 | log "Limit: $LIMIT" 27 | 28 | export BEADS_DIR 29 | export BV_SEMANTIC_EMBEDDER=hash 30 | export BV_SEMANTIC_DIM=384 31 | 32 | log "Running text-only search..." 33 | TEXT_JSON=$(bv --search "$QUERY" --search-limit "$LIMIT" --search-mode text --robot-search) 34 | log "Text-only results (top IDs): $(echo "$TEXT_JSON" | jq -r '.results[].issue_id' | paste -sd ',' -)" 35 | 36 | log "Running hybrid search (impact-first)..." 37 | HYBRID_JSON=$(bv --search "$QUERY" --search-limit "$LIMIT" --search-mode hybrid --search-preset impact-first --robot-search) 38 | log "Hybrid results (top IDs): $(echo "$HYBRID_JSON" | jq -r '.results[].issue_id' | paste -sd ',' -)" 39 | 40 | log "Hybrid metadata: mode=$(echo "$HYBRID_JSON" | jq -r '.mode') preset=$(echo "$HYBRID_JSON" | jq -r '.preset')" 41 | log "Data hashes: text=$(echo "$TEXT_JSON" | jq -r '.data_hash') hybrid=$(echo "$HYBRID_JSON" | jq -r '.data_hash')" 42 | 43 | log "Top result comparison:" 44 | TEXT_TOP=$(echo "$TEXT_JSON" | jq -r '.results[0].issue_id') 45 | HYBRID_TOP=$(echo "$HYBRID_JSON" | jq -r '.results[0].issue_id') 46 | log " text=$TEXT_TOP hybrid=$HYBRID_TOP" 47 | 48 | if [ "$TEXT_TOP" != "$HYBRID_TOP" ]; then 49 | log "✅ Hybrid re-ranking changed the top result" 50 | else 51 | log "⚠️ Hybrid re-ranking did not change the top result" 52 | fi 53 | 54 | log "Text-only full JSON written to $TMP_DIR/text.json" 55 | log "Hybrid full JSON written to $TMP_DIR/hybrid.json" 56 | 57 | printf "%s\n" "$TEXT_JSON" > "$TMP_DIR/text.json" 58 | printf "%s\n" "$HYBRID_JSON" > "$TMP_DIR/hybrid.json" 59 | 60 | log "Done" 61 | -------------------------------------------------------------------------------- /.beads/config.yaml: -------------------------------------------------------------------------------- 1 | # Beads Configuration File 2 | # This file configures default behavior for all bd commands in this repository 3 | # All settings can also be set via environment variables (BD_* prefix) 4 | # or overridden with command-line flags 5 | 6 | # Issue prefix for this repository (used by bd init) 7 | # If not set, bd init will auto-detect from directory name 8 | # Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. 9 | # issue-prefix: "" 10 | 11 | # Use no-db mode: load from JSONL, no SQLite, write back after each command 12 | # When true, bd will use .beads/issues.jsonl as the source of truth 13 | # instead of SQLite database 14 | # no-db: false 15 | 16 | # Disable daemon for RPC communication (forces direct database access) 17 | # no-daemon: false 18 | 19 | # Disable auto-flush of database to JSONL after mutations 20 | # no-auto-flush: false 21 | 22 | # Disable auto-import from JSONL when it's newer than database 23 | # no-auto-import: false 24 | 25 | # Enable JSON output by default 26 | # json: false 27 | 28 | # Default actor for audit trails (overridden by BD_ACTOR or --actor) 29 | # actor: "" 30 | 31 | # Path to database (overridden by BEADS_DB or --db) 32 | # db: "" 33 | 34 | # Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) 35 | # auto-start-daemon: true 36 | 37 | # Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) 38 | # flush-debounce: "5s" 39 | 40 | # Multi-repo configuration (experimental - bd-307) 41 | # Allows hydrating from multiple repositories and routing writes to the correct JSONL 42 | # repos: 43 | # primary: "." # Primary repo (where this database lives) 44 | # additional: # Additional repos to hydrate from (read-only) 45 | # - ~/beads-planning # Personal planning repo 46 | # - ~/work-planning # Work planning repo 47 | 48 | # Integration settings (access with 'bd config get/set') 49 | # These are stored in the database, not in this file: 50 | # - jira.url 51 | # - jira.project 52 | # - linear.url 53 | # - linear.api-key 54 | # - github.org 55 | # - github.repo 56 | # - sync.branch - Git branch for beads commits (use BEADS_SYNC_BRANCH env var or bd config set) 57 | -------------------------------------------------------------------------------- /pkg/ui/theme_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | func TestDefaultTheme(t *testing.T) { 10 | renderer := lipgloss.NewRenderer(nil) 11 | theme := DefaultTheme(renderer) 12 | 13 | if theme.Renderer != renderer { 14 | t.Error("DefaultTheme renderer mismatch") 15 | } 16 | // Check a few known colors are set (not zero value) 17 | if isColorEmpty(theme.Primary) { 18 | t.Error("DefaultTheme Primary color is empty") 19 | } 20 | if isColorEmpty(theme.Open) { 21 | t.Error("DefaultTheme Open color is empty") 22 | } 23 | } 24 | 25 | func isColorEmpty(c lipgloss.AdaptiveColor) bool { 26 | return c.Light == "" && c.Dark == "" 27 | } 28 | 29 | func TestGetStatusColor(t *testing.T) { 30 | renderer := lipgloss.NewRenderer(nil) 31 | theme := DefaultTheme(renderer) 32 | 33 | tests := []struct { 34 | status string 35 | want lipgloss.AdaptiveColor 36 | }{ 37 | {"open", theme.Open}, 38 | {"in_progress", theme.InProgress}, 39 | {"blocked", theme.Blocked}, 40 | {"closed", theme.Closed}, 41 | {"unknown", theme.Subtext}, 42 | {"", theme.Subtext}, 43 | } 44 | 45 | for _, tt := range tests { 46 | got := theme.GetStatusColor(tt.status) 47 | if got != tt.want { 48 | t.Errorf("GetStatusColor(%q) = %v, want %v", tt.status, got, tt.want) 49 | } 50 | } 51 | } 52 | 53 | func TestGetTypeIcon(t *testing.T) { 54 | renderer := lipgloss.NewRenderer(nil) 55 | theme := DefaultTheme(renderer) 56 | 57 | tests := []struct { 58 | typ string 59 | wantIcon string 60 | wantCol lipgloss.AdaptiveColor 61 | }{ 62 | {"bug", "🐛", theme.Bug}, 63 | {"feature", "✨", theme.Feature}, 64 | {"task", "📋", theme.Task}, 65 | {"epic", "🚀", theme.Epic}, // Changed from 🏔️ - variation selector caused width issues 66 | {"chore", "🧹", theme.Chore}, 67 | {"unknown", "•", theme.Subtext}, 68 | } 69 | 70 | for _, tt := range tests { 71 | icon, col := theme.GetTypeIcon(tt.typ) 72 | if icon != tt.wantIcon { 73 | t.Errorf("GetTypeIcon(%q) icon = %q, want %q", tt.typ, icon, tt.wantIcon) 74 | } 75 | if col != tt.wantCol { 76 | t.Errorf("GetTypeIcon(%q) color = %v, want %v", tt.typ, col, tt.wantCol) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pkg/export/wizard_flow_test.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | // Note: Interactive form tests have been removed since huh forms 9 | // cannot be easily tested with stdin injection. The wizard's interactive 10 | // behavior is tested via E2E tests instead. 11 | 12 | func TestWizard_PerformDeploy_Local(t *testing.T) { 13 | wizard := NewWizard("/tmp/test") 14 | wizard.config.DeployTarget = "local" 15 | wizard.bundlePath = "/tmp/bundle" 16 | 17 | result, err := wizard.PerformDeploy() 18 | if err != nil { 19 | t.Fatalf("PerformDeploy returned error: %v", err) 20 | } 21 | if result == nil { 22 | t.Fatal("PerformDeploy returned nil result") 23 | } 24 | if result.DeployTarget != "local" { 25 | t.Fatalf("Expected DeployTarget %q, got %q", "local", result.DeployTarget) 26 | } 27 | if result.BundlePath != "/tmp/bundle" { 28 | t.Fatalf("Expected BundlePath %q, got %q", "/tmp/bundle", result.BundlePath) 29 | } 30 | } 31 | 32 | func TestWizard_collectTargetConfig_NoTarget(t *testing.T) { 33 | wizard := NewWizard("/tmp/test") 34 | // Empty deploy target should return nil error 35 | wizard.config.DeployTarget = "" 36 | err := wizard.collectTargetConfig() 37 | if err != nil { 38 | t.Fatalf("collectTargetConfig returned error: %v", err) 39 | } 40 | } 41 | 42 | func TestWizard_SuggestProjectName(t *testing.T) { 43 | // Only test cases that don't depend on cwd 44 | tests := []struct { 45 | name string 46 | input string 47 | expected string 48 | }{ 49 | {"simple", "/path/to/my_project/bv-pages", "my-project-pages"}, 50 | {"with hyphens", "/path/to/my-project/bv-pages", "my-project-pages"}, 51 | } 52 | 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | result := SuggestProjectName(tt.input) 56 | if result != tt.expected { 57 | t.Errorf("SuggestProjectName(%q) = %q, want %q", tt.input, result, tt.expected) 58 | } 59 | }) 60 | } 61 | } 62 | 63 | func TestWizard_printBanner_NoPanic(t *testing.T) { 64 | wizard := NewWizard("/tmp/test") 65 | // Redirect stdout to discard 66 | old := os.Stdout 67 | os.Stdout, _ = os.Open(os.DevNull) 68 | defer func() { os.Stdout = old }() 69 | 70 | // Should not panic 71 | wizard.printBanner() 72 | } 73 | -------------------------------------------------------------------------------- /pkg/search/weights.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | ) 8 | 9 | const weightSumTolerance = 0.001 10 | 11 | // Weights defines the relative importance of each ranking factor. 12 | // All weights should sum to 1.0 for normalized scoring. 13 | // 14 | // Normalization reference: 15 | // - StatusWeight: {open: 1.0, in_progress: 0.8, blocked: 0.5, closed: 0.1} 16 | // - PriorityWeight: P0=1.0, P1=0.8, P2=0.6, P3=0.4, P4=0.2 17 | // - RecencyWeight: exp(-days_since_update / 30) 18 | // - ImpactWeight: blocker_count / max_blocker_count (or 0.5 if max=0) 19 | type Weights struct { 20 | TextRelevance float64 `json:"text"` // Core search match quality 21 | PageRank float64 `json:"pagerank"` // Graph centrality importance 22 | Status float64 `json:"status"` // Actionability (open > closed) 23 | Impact float64 `json:"impact"` // Blocker count normalized 24 | Priority float64 `json:"priority"` // User-assigned priority 25 | Recency float64 `json:"recency"` // Temporal decay 26 | } 27 | 28 | // Validate checks that weights are valid (non-negative, sum to ~1.0). 29 | // It logs a warning when text relevance is very low. 30 | func (w Weights) Validate() error { 31 | if w.TextRelevance < 0 || w.PageRank < 0 || w.Status < 0 || 32 | w.Impact < 0 || w.Priority < 0 || w.Recency < 0 { 33 | return fmt.Errorf("weights must be non-negative") 34 | } 35 | 36 | if w.TextRelevance < 0.1 { 37 | log.Printf("WARNING: text weight %.2f is very low; results may not match query", w.TextRelevance) 38 | } 39 | 40 | sum := w.sum() 41 | if math.Abs(sum-1.0) > weightSumTolerance { 42 | return fmt.Errorf("weights must sum to 1.0, got %.3f", sum) 43 | } 44 | 45 | return nil 46 | } 47 | 48 | // Normalize scales weights to sum to 1.0. 49 | func (w Weights) Normalize() Weights { 50 | sum := w.sum() 51 | if sum == 0 { 52 | return w 53 | } 54 | 55 | return Weights{ 56 | TextRelevance: w.TextRelevance / sum, 57 | PageRank: w.PageRank / sum, 58 | Status: w.Status / sum, 59 | Impact: w.Impact / sum, 60 | Priority: w.Priority / sum, 61 | Recency: w.Recency / sum, 62 | } 63 | } 64 | 65 | func (w Weights) sum() float64 { 66 | return w.TextRelevance + w.PageRank + w.Status + w.Impact + w.Priority + w.Recency 67 | } 68 | -------------------------------------------------------------------------------- /pkg/ui/actionable_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/Dicklesworthstone/beads_viewer/pkg/analysis" 8 | 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | func newTestTheme() Theme { 13 | return DefaultTheme(lipgloss.NewRenderer(nil)) 14 | } 15 | 16 | func TestActionableRenderEmpty(t *testing.T) { 17 | m := NewActionableModel(analysis.ExecutionPlan{}, newTestTheme()) 18 | m.SetSize(80, 20) 19 | 20 | out := m.Render() 21 | if !strings.Contains(out, "No actionable items") { 22 | t.Fatalf("expected empty state message, got:\n%s", out) 23 | } 24 | } 25 | 26 | func TestActionableNavigationAcrossTracks(t *testing.T) { 27 | plan := analysis.ExecutionPlan{ 28 | Tracks: []analysis.ExecutionTrack{ 29 | {TrackID: "track-A", Items: []analysis.PlanItem{{ID: "A1", Title: "First"}}}, 30 | {TrackID: "track-B", Items: []analysis.PlanItem{{ID: "B1", Title: "Second"}}}, 31 | }, 32 | } 33 | 34 | m := NewActionableModel(plan, newTestTheme()) 35 | m.SetSize(80, 20) 36 | 37 | if got := m.SelectedIssueID(); got != "A1" { 38 | t.Fatalf("expected initial selection A1, got %s", got) 39 | } 40 | 41 | m.MoveDown() // should move to next track/item 42 | if got := m.SelectedIssueID(); got != "B1" { 43 | t.Fatalf("expected selection B1 after MoveDown, got %s", got) 44 | } 45 | 46 | m.MoveUp() 47 | if got := m.SelectedIssueID(); got != "A1" { 48 | t.Fatalf("expected selection back to A1 after MoveUp, got %s", got) 49 | } 50 | } 51 | 52 | func TestActionableRenderShowsSummary(t *testing.T) { 53 | plan := analysis.ExecutionPlan{ 54 | Tracks: []analysis.ExecutionTrack{ 55 | { 56 | TrackID: "track-A", 57 | Items: []analysis.PlanItem{{ID: "ROOT", Title: "Root", Priority: 1, UnblocksIDs: []string{"X", "Y"}}}, 58 | }, 59 | }, 60 | Summary: analysis.PlanSummary{ 61 | HighestImpact: "ROOT", 62 | ImpactReason: "Unblocks multiple tasks", 63 | UnblocksCount: 2, 64 | }, 65 | } 66 | 67 | m := NewActionableModel(plan, newTestTheme()) 68 | m.SetSize(100, 30) 69 | 70 | out := m.Render() 71 | if !strings.Contains(out, "Start with ROOT") { 72 | t.Fatalf("expected summary callout for ROOT, got:\n%s", out) 73 | } 74 | if !strings.Contains(out, "→2") { 75 | t.Fatalf("expected unblocks count badge, got:\n%s", out) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pkg/search/hybrid_scorer_impl.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import "fmt" 4 | 5 | type hybridScorer struct { 6 | weights Weights 7 | cache MetricsCache 8 | } 9 | 10 | // NewHybridScorer creates a scorer with the given weights and metrics cache. 11 | func NewHybridScorer(weights Weights, cache MetricsCache) HybridScorer { 12 | normalized := weights.Normalize() 13 | if err := normalized.Validate(); err != nil { 14 | if preset, presetErr := GetPreset(PresetDefault); presetErr == nil { 15 | normalized = preset 16 | } else { 17 | normalized = Weights{TextRelevance: 1.0} 18 | } 19 | } 20 | return &hybridScorer{ 21 | weights: normalized, 22 | cache: cache, 23 | } 24 | } 25 | 26 | func (s *hybridScorer) Score(issueID string, textScore float64) (HybridScore, error) { 27 | if issueID == "" { 28 | return HybridScore{}, fmt.Errorf("issueID is required") 29 | } 30 | 31 | if s.cache == nil { 32 | return HybridScore{ 33 | IssueID: issueID, 34 | FinalScore: textScore, 35 | TextScore: textScore, 36 | }, nil 37 | } 38 | 39 | metrics, found := s.cache.Get(issueID) 40 | if !found { 41 | return HybridScore{ 42 | IssueID: issueID, 43 | FinalScore: textScore, 44 | TextScore: textScore, 45 | }, nil 46 | } 47 | 48 | statusScore := normalizeStatus(metrics.Status) 49 | priorityScore := normalizePriority(metrics.Priority) 50 | impactScore := normalizeImpact(metrics.BlockerCount, s.cache.MaxBlockerCount()) 51 | recencyScore := normalizeRecency(metrics.UpdatedAt) 52 | 53 | final := s.weights.TextRelevance*textScore + 54 | s.weights.PageRank*metrics.PageRank + 55 | s.weights.Status*statusScore + 56 | s.weights.Impact*impactScore + 57 | s.weights.Priority*priorityScore + 58 | s.weights.Recency*recencyScore 59 | 60 | return HybridScore{ 61 | IssueID: issueID, 62 | FinalScore: final, 63 | TextScore: textScore, 64 | ComponentScores: map[string]float64{ 65 | "pagerank": metrics.PageRank, 66 | "status": statusScore, 67 | "impact": impactScore, 68 | "priority": priorityScore, 69 | "recency": recencyScore, 70 | }, 71 | }, nil 72 | } 73 | 74 | func (s *hybridScorer) Configure(weights Weights) error { 75 | if err := weights.Validate(); err != nil { 76 | return err 77 | } 78 | s.weights = weights 79 | return nil 80 | } 81 | 82 | func (s *hybridScorer) GetWeights() Weights { 83 | return s.weights 84 | } 85 | -------------------------------------------------------------------------------- /pkg/watcher/debouncer.go: -------------------------------------------------------------------------------- 1 | // Package watcher provides file watching with debouncing and fallback polling. 2 | package watcher 3 | 4 | import ( 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // DefaultDebounceDuration is the default debounce window. 10 | const DefaultDebounceDuration = 250 * time.Millisecond 11 | 12 | // Debouncer coalesces rapid events into a single callback invocation. 13 | // When Trigger is called multiple times within the debounce duration, 14 | // only the last callback is executed after the duration elapses. 15 | type Debouncer struct { 16 | duration time.Duration 17 | timer *time.Timer 18 | mu sync.Mutex 19 | seq uint64 20 | } 21 | 22 | // NewDebouncer creates a new Debouncer with the specified duration. 23 | // If duration is 0, DefaultDebounceDuration is used. 24 | func NewDebouncer(duration time.Duration) *Debouncer { 25 | if duration == 0 { 26 | duration = DefaultDebounceDuration 27 | } 28 | return &Debouncer{ 29 | duration: duration, 30 | } 31 | } 32 | 33 | // Trigger schedules the callback to be called after the debounce duration. 34 | // If Trigger is called again before the duration elapses, the previous 35 | // scheduled callback is cancelled and a new one is scheduled. 36 | func (d *Debouncer) Trigger(callback func()) { 37 | d.mu.Lock() 38 | defer d.mu.Unlock() 39 | d.seq++ 40 | seq := d.seq 41 | 42 | if d.timer != nil { 43 | d.timer.Stop() 44 | } 45 | d.timer = time.AfterFunc(d.duration, func() { 46 | shouldRun := func() bool { 47 | d.mu.Lock() 48 | defer d.mu.Unlock() 49 | 50 | // Only run the most recently scheduled callback. This avoids races where 51 | // Stop() returns false because the timer has already fired and the old 52 | // callback starts running concurrently. 53 | if seq != d.seq { 54 | return false 55 | } 56 | d.timer = nil 57 | return true 58 | }() 59 | if !shouldRun { 60 | return 61 | } 62 | 63 | callback() 64 | }) 65 | } 66 | 67 | // Cancel cancels any pending callback. 68 | func (d *Debouncer) Cancel() { 69 | d.mu.Lock() 70 | defer d.mu.Unlock() 71 | 72 | // Invalidate any callback that might already be executing due to timer races. 73 | d.seq++ 74 | 75 | if d.timer != nil { 76 | d.timer.Stop() 77 | d.timer = nil 78 | } 79 | } 80 | 81 | // Duration returns the debounce duration. 82 | func (d *Debouncer) Duration() time.Duration { 83 | return d.duration 84 | } 85 | -------------------------------------------------------------------------------- /scripts/benchmark_compare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Benchmark script for hybrid search performance 3 | # Usage: 4 | # ./scripts/benchmark_compare.sh # Run benchmarks 5 | # ./scripts/benchmark_compare.sh baseline # Save as baseline 6 | # ./scripts/benchmark_compare.sh compare # Compare against baseline 7 | # ./scripts/benchmark_compare.sh quick # Quick subset 8 | 9 | set -e 10 | 11 | BENCHMARK_DIR="benchmarks" 12 | BASELINE_FILE="$BENCHMARK_DIR/search_baseline.txt" 13 | CURRENT_FILE="$BENCHMARK_DIR/search_current.txt" 14 | BENCH_PACKAGES=(./pkg/search/... ./tests/e2e/...) 15 | 16 | mkdir -p "$BENCHMARK_DIR" 17 | 18 | run_benchmarks() { 19 | echo "Running hybrid search benchmarks..." 20 | go test -run=^$ -bench=. -benchmem -count=3 "${BENCH_PACKAGES[@]}" 2>&1 | tee "$CURRENT_FILE" 21 | echo "" 22 | echo "Results saved to $CURRENT_FILE" 23 | } 24 | 25 | save_baseline() { 26 | echo "Running benchmarks and saving as baseline..." 27 | go test -run=^$ -bench=. -benchmem -count=3 "${BENCH_PACKAGES[@]}" 2>&1 | tee "$BASELINE_FILE" 28 | echo "" 29 | echo "Baseline saved to $BASELINE_FILE" 30 | } 31 | 32 | compare_benchmarks() { 33 | if [ ! -f "$BASELINE_FILE" ]; then 34 | echo "No baseline found at $BASELINE_FILE" 35 | echo "Run './scripts/benchmark_compare.sh baseline' first" 36 | exit 1 37 | fi 38 | 39 | run_benchmarks 40 | 41 | echo "" 42 | echo "=== Comparing against baseline ===" 43 | echo "" 44 | 45 | if command -v benchstat &> /dev/null; then 46 | benchstat "$BASELINE_FILE" "$CURRENT_FILE" 47 | else 48 | echo "benchstat not found. Install with: go install golang.org/x/perf/cmd/benchstat@latest" 49 | echo "" 50 | echo "Manual comparison:" 51 | echo "Baseline: $BASELINE_FILE" 52 | echo "Current: $CURRENT_FILE" 53 | fi 54 | } 55 | 56 | run_quick() { 57 | echo "Running quick hybrid search benchmarks..." 58 | go test -run=^$ -bench='Benchmark(SearchTextVsHybrid/|SearchAtScale/n=100$|HybridScorerScore$|MetricsCacheGet$)' \ 59 | -benchmem -count=1 "${BENCH_PACKAGES[@]}" 2>&1 | tee "$CURRENT_FILE" 60 | } 61 | 62 | case "${1:-run}" in 63 | baseline) 64 | save_baseline 65 | ;; 66 | compare) 67 | compare_benchmarks 68 | ;; 69 | quick) 70 | run_quick 71 | ;; 72 | run|*) 73 | run_benchmarks 74 | ;; 75 | esac 76 | -------------------------------------------------------------------------------- /scripts/benchmark.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Benchmark script for bv graph analysis 3 | # Usage: 4 | # ./scripts/benchmark.sh # Run all benchmarks 5 | # ./scripts/benchmark.sh baseline # Save as baseline 6 | # ./scripts/benchmark.sh compare # Compare against baseline 7 | 8 | set -e 9 | 10 | BENCHMARK_DIR="benchmarks" 11 | BASELINE_FILE="$BENCHMARK_DIR/baseline.txt" 12 | CURRENT_FILE="$BENCHMARK_DIR/current.txt" 13 | BENCH_PACKAGES=(./pkg/analysis/... ./pkg/ui/... ./pkg/export/...) 14 | 15 | mkdir -p "$BENCHMARK_DIR" 16 | 17 | run_benchmarks() { 18 | echo "Running benchmarks..." 19 | go test -bench=. -benchmem -count=3 "${BENCH_PACKAGES[@]}" 2>&1 | tee "$CURRENT_FILE" 20 | echo "" 21 | echo "Results saved to $CURRENT_FILE" 22 | } 23 | 24 | save_baseline() { 25 | echo "Running benchmarks and saving as baseline..." 26 | go test -bench=. -benchmem -count=3 "${BENCH_PACKAGES[@]}" 2>&1 | tee "$BASELINE_FILE" 27 | echo "" 28 | echo "Baseline saved to $BASELINE_FILE" 29 | } 30 | 31 | compare_benchmarks() { 32 | if [ ! -f "$BASELINE_FILE" ]; then 33 | echo "No baseline found at $BASELINE_FILE" 34 | echo "Run './scripts/benchmark.sh baseline' first" 35 | exit 1 36 | fi 37 | 38 | run_benchmarks 39 | 40 | echo "" 41 | echo "=== Comparing against baseline ===" 42 | echo "" 43 | 44 | # Check if benchstat is available 45 | if command -v benchstat &> /dev/null; then 46 | benchstat "$BASELINE_FILE" "$CURRENT_FILE" 47 | else 48 | echo "benchstat not found. Install with: go install golang.org/x/perf/cmd/benchstat@latest" 49 | echo "" 50 | echo "Manual comparison:" 51 | echo "Baseline: $BASELINE_FILE" 52 | echo "Current: $CURRENT_FILE" 53 | fi 54 | } 55 | 56 | # Quick benchmarks for CI (subset of critical tests) 57 | run_quick() { 58 | echo "Running quick benchmarks (CI mode)..." 59 | go test -bench='Benchmark(FullAnalysis_(Sparse100|Dense100|ManyCycles20)|GraphModel_Rebuild_Layered1000|GraphSnapshot_BuildLayoutAndRenderSVG_Layered1000)' \ 60 | -benchmem -count=1 "${BENCH_PACKAGES[@]}" 2>&1 | tee "$CURRENT_FILE" 61 | } 62 | 63 | case "${1:-run}" in 64 | baseline) 65 | save_baseline 66 | ;; 67 | compare) 68 | compare_benchmarks 69 | ;; 70 | quick) 71 | run_quick 72 | ;; 73 | run|*) 74 | run_benchmarks 75 | ;; 76 | esac 77 | -------------------------------------------------------------------------------- /tests/e2e/brief_exports_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestPriorityBrief_AndAgentBriefBundle(t *testing.T) { 13 | bv := buildBvBinary(t) 14 | env := t.TempDir() 15 | 16 | // A is actionable, B is blocked; ensures triage has content. 17 | writeBeads(t, env, `{"id":"A","title":"Unblocker","status":"open","priority":1,"issue_type":"task"} 18 | {"id":"B","title":"Blocked","status":"open","priority":2,"issue_type":"task","dependencies":[{"issue_id":"B","depends_on_id":"A","type":"blocks"}]}`) 19 | 20 | briefPath := filepath.Join(env, "brief.md") 21 | cmd := exec.Command(bv, "--priority-brief", briefPath) 22 | cmd.Dir = env 23 | out, err := cmd.CombinedOutput() 24 | if err != nil { 25 | t.Fatalf("--priority-brief failed: %v\n%s", err, out) 26 | } 27 | 28 | briefBytes, err := os.ReadFile(briefPath) 29 | if err != nil { 30 | t.Fatalf("read brief.md: %v", err) 31 | } 32 | brief := string(briefBytes) 33 | if !strings.Contains(brief, "# 📊 Priority Brief") { 34 | t.Fatalf("brief missing header:\n%s", brief) 35 | } 36 | if !strings.Contains(brief, "**Hash:** `") { 37 | t.Fatalf("brief missing hash line:\n%s", brief) 38 | } 39 | if !strings.Contains(brief, "**A**") { 40 | t.Fatalf("brief missing issue A:\n%s", brief) 41 | } 42 | 43 | // Agent brief bundle 44 | bundleDir := filepath.Join(env, "agent-brief") 45 | cmd = exec.Command(bv, "--agent-brief", bundleDir) 46 | cmd.Dir = env 47 | out, err = cmd.CombinedOutput() 48 | if err != nil { 49 | t.Fatalf("--agent-brief failed: %v\n%s", err, out) 50 | } 51 | 52 | for _, name := range []string{"triage.json", "insights.json", "brief.md", "helpers.md", "meta.json"} { 53 | if _, err := os.Stat(filepath.Join(bundleDir, name)); err != nil { 54 | t.Fatalf("bundle missing %s: %v", name, err) 55 | } 56 | } 57 | 58 | metaBytes, err := os.ReadFile(filepath.Join(bundleDir, "meta.json")) 59 | if err != nil { 60 | t.Fatalf("read meta.json: %v", err) 61 | } 62 | var meta struct { 63 | DataHash string `json:"data_hash"` 64 | Files []string `json:"files"` 65 | } 66 | if err := json.Unmarshal(metaBytes, &meta); err != nil { 67 | t.Fatalf("meta.json decode: %v", err) 68 | } 69 | if meta.DataHash == "" { 70 | t.Fatalf("meta.json missing data_hash") 71 | } 72 | if len(meta.Files) == 0 { 73 | t.Fatalf("meta.json missing files list") 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /testdata/golden/graph_render/chain_10_ascii.golden: -------------------------------------------------------------------------------- 1 | ▲ BLOCKED BY (must complete first) ▲ 2 | ╭────────────────────╮ 3 | │ 🔵 n6 │ 4 | │ n6 │ 5 | ╰────────────────────╯ 6 | │ 7 | │ 8 | ▼ 9 | ╔═══════════════════════════════════════╗ 10 | ║ 🔵 ⚡ 📝 n5 ║ 11 | ║ n5 ║ 12 | ║ ⬆1 ⬇1 ║ 13 | ╚═══════════════════════════════════════╝ 14 | │ 15 | │ 16 | ▼ 17 | ╭────────────────────╮ 18 | │ 🔵 n4 │ 19 | │ n4 │ 20 | ╰────────────────────╯ 21 | ▼ BLOCKS (waiting on this) ▼ 22 | 23 | 📊 GRAPH METRICS 24 | ────────────────────────────────────────────────────────────────────────── 25 | Importance 26 | Critical Path 6.00 ███░░░ #5 27 | PageRank 0.1143 ████░░ #5 28 | Eigenvector 0.0000 ░░░░░░ #7 29 | 30 | Flow & Connectivity 31 | Betweenness 20.00 ██████ #2 32 | Hub Score 0.3333 ██████ #6 33 | Authority 0.3333 ██████ #5 34 | 35 | Connections 36 | In-Degree 1 ██████ #5 37 | Out-Degree 1 ██████ #6 38 | 39 | █ relative score │ #N rank of 10 issues 40 | 41 | j/k: navigate • enter: view details • g: back to list -------------------------------------------------------------------------------- /testdata/golden/graph_render/diamond_5_ascii.golden: -------------------------------------------------------------------------------- 1 | ▲ BLOCKED BY (must complete first) ▲ 2 | ╭────────────────────╮ 3 | │ 🔵 n4 │ 4 | │ n4 │ 5 | ╰────────────────────╯ 6 | │ 7 | │ 8 | ▼ 9 | ╔═══════════════════════════════════════╗ 10 | ║ 🔵 ⚡ 📝 n3 ║ 11 | ║ n3 ║ 12 | ║ ⬆1 ⬇2 ║ 13 | ╚═══════════════════════════════════════╝ 14 | │ 15 | ├─┼─┤ 16 | ▼ 17 | ╭────────────────────╮╭────────────────────╮ 18 | │ 🔵 n1 ││ 🔵 n2 │ 19 | │ n1 ││ n2 │ 20 | ╰────────────────────╯╰────────────────────╯ 21 | ▼ BLOCKS (waiting on this) ▼ 22 | 23 | 📊 GRAPH METRICS 24 | ────────────────────────────────────────────────────────────────────────── 25 | Importance 26 | Critical Path 3.00 ████░░ #2 27 | PageRank 0.3061 █████░ #2 28 | Eigenvector 0.0000 ░░░░░░ #5 29 | 30 | Flow & Connectivity 31 | Betweenness 3.00 ██████ #1 32 | Hub Score 0.0006 ░░░░░░ #4 33 | Authority 0.8165 ██████ #1 34 | 35 | Connections 36 | In-Degree 2 ██████ #1 37 | Out-Degree 1 ███░░░ #4 38 | 39 | █ relative score │ #N rank of 5 issues 40 | 41 | j/k: navigate • enter: view details • g: back to list -------------------------------------------------------------------------------- /testdata/golden/graph_render/complex_20_ascii.golden: -------------------------------------------------------------------------------- 1 | ▲ BLOCKED BY (must complete first) ▲ 2 | ╭────────────────────╮╭────────────────────╮ 3 | │ 🔵 task-12 ││ 🔵 task-13 │ 4 | │ task-12 ││ task-13 │ 5 | ╰────────────────────╯╰────────────────────╯ 6 | │ 7 | ├─┼─┤ 8 | ▼ 9 | ╔═══════════════════════════════════════╗ 10 | ║ 🔵 ⚡ 📝 task-14 ║ 11 | ║ task-14 ║ 12 | ║ ⬆2 ⬇1 ║ 13 | ╚═══════════════════════════════════════╝ 14 | │ 15 | │ 16 | ▼ 17 | ╭────────────────────╮ 18 | │ 🔵 task-16 │ 19 | │ task-16 │ 20 | ╰────────────────────╯ 21 | ▼ BLOCKS (waiting on this) ▼ 22 | 23 | 📊 GRAPH METRICS 24 | ────────────────────────────────────────────────────────────────────────── 25 | Importance 26 | Critical Path 4.00 ███░░░ #10 27 | PageRank 0.0358 ░░░░░░ #10 28 | Eigenvector 0.0000 ░░░░░░ #8 29 | 30 | Flow & Connectivity 31 | Betweenness 11.60 ████░░ #3 32 | Hub Score 0.0000 ░░░░░░ #11 33 | Authority 0.0000 ░░░░░░ #15 34 | 35 | Connections 36 | In-Degree 1 █░░░░░ #11 37 | Out-Degree 2 ██████ #2 38 | 39 | █ relative score │ #N rank of 20 issues 40 | 41 | j/k: navigate • enter: view details • g: back to list -------------------------------------------------------------------------------- /.beads/README.md: -------------------------------------------------------------------------------- 1 | # Beads - AI-Native Issue Tracking 2 | 3 | Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. 4 | 5 | ## What is Beads? 6 | 7 | Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. 8 | 9 | **Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) 10 | 11 | ## Quick Start 12 | 13 | ### Essential Commands 14 | 15 | ```bash 16 | # Create new issues 17 | bd create "Add user authentication" 18 | 19 | # View all issues 20 | bd list 21 | 22 | # View issue details 23 | bd show 24 | 25 | # Update issue status 26 | bd update --status in_progress 27 | bd update --status done 28 | 29 | # Sync with git remote 30 | bd sync 31 | ``` 32 | 33 | ### Working with Issues 34 | 35 | Issues in Beads are: 36 | - **Git-native**: Stored in `.beads/issues.jsonl` and synced like code 37 | - **AI-friendly**: CLI-first design works perfectly with AI coding agents 38 | - **Branch-aware**: Issues can follow your branch workflow 39 | - **Always in sync**: Auto-syncs with your commits 40 | 41 | ## Why Beads? 42 | 43 | ✨ **AI-Native Design** 44 | - Built specifically for AI-assisted development workflows 45 | - CLI-first interface works seamlessly with AI coding agents 46 | - No context switching to web UIs 47 | 48 | 🚀 **Developer Focused** 49 | - Issues live in your repo, right next to your code 50 | - Works offline, syncs when you push 51 | - Fast, lightweight, and stays out of your way 52 | 53 | 🔧 **Git Integration** 54 | - Automatic sync with git commits 55 | - Branch-aware issue tracking 56 | - Intelligent JSONL merge resolution 57 | 58 | ## Get Started with Beads 59 | 60 | Try Beads in your own projects: 61 | 62 | ```bash 63 | # Install Beads 64 | curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash 65 | 66 | # Initialize in your repo 67 | bd init 68 | 69 | # Create your first issue 70 | bd create "Try out Beads" 71 | ``` 72 | 73 | ## Learn More 74 | 75 | - **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) 76 | - **Quick Start Guide**: Run `bd quickstart` 77 | - **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) 78 | 79 | --- 80 | 81 | *Beads: Issue tracking that moves at the speed of thought* ⚡ 82 | -------------------------------------------------------------------------------- /pkg/search/hash_embedder.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | "math" 6 | "unicode" 7 | ) 8 | 9 | // HashEmbedder is a dependency-free embedding fallback. 10 | // It uses feature hashing over tokens into a fixed-size dense vector. 11 | type HashEmbedder struct { 12 | dim int 13 | } 14 | 15 | func NewHashEmbedder(dim int) *HashEmbedder { 16 | if dim <= 0 { 17 | dim = DefaultEmbeddingDim 18 | } 19 | return &HashEmbedder{dim: dim} 20 | } 21 | 22 | func (*HashEmbedder) Provider() Provider { return ProviderHash } 23 | func (h *HashEmbedder) Dim() int { return h.dim } 24 | 25 | func (h *HashEmbedder) Embed(ctx context.Context, texts []string) ([][]float32, error) { 26 | out := make([][]float32, len(texts)) 27 | for i, text := range texts { 28 | if err := ctx.Err(); err != nil { 29 | return nil, err 30 | } 31 | vec := make([]float32, h.dim) 32 | hashEmbedInto(vec, text) 33 | normalizeL2(vec) 34 | out[i] = vec 35 | } 36 | return out, nil 37 | } 38 | 39 | func hashEmbedInto(vec []float32, text string) { 40 | if len(vec) == 0 { 41 | return 42 | } 43 | dim := uint64(len(vec)) 44 | 45 | tokenStart := -1 46 | for idx, r := range text { 47 | isTokenChar := unicode.IsLetter(r) || unicode.IsDigit(r) 48 | if isTokenChar { 49 | if tokenStart == -1 { 50 | tokenStart = idx 51 | } 52 | continue 53 | } 54 | if tokenStart != -1 { 55 | addHashedToken(vec, dim, text[tokenStart:idx]) 56 | tokenStart = -1 57 | } 58 | } 59 | if tokenStart != -1 { 60 | addHashedToken(vec, dim, text[tokenStart:]) 61 | } 62 | } 63 | 64 | func addHashedToken(vec []float32, dim uint64, token string) { 65 | // FNV-1a 64-bit over UTF-8 bytes. 66 | const ( 67 | offset64 = 14695981039346656037 68 | prime64 = 1099511628211 69 | ) 70 | var h uint64 = offset64 71 | for i := 0; i < len(token); i++ { 72 | b := token[i] 73 | // Lowercase ASCII fast-path; unicode case folding isn't worth it for a fallback embedder. 74 | if b >= 'A' && b <= 'Z' { 75 | b += 'a' - 'A' 76 | } 77 | h ^= uint64(b) 78 | h *= prime64 79 | } 80 | 81 | idx := int(h % dim) 82 | sign := float32(1.0) 83 | if (h>>63)&1 == 1 { 84 | sign = -1.0 85 | } 86 | vec[idx] += sign 87 | } 88 | 89 | func normalizeL2(vec []float32) { 90 | var sum float64 91 | for _, v := range vec { 92 | sum += float64(v) * float64(v) 93 | } 94 | if sum == 0 { 95 | return 96 | } 97 | scale := float32(1.0 / math.Sqrt(sum)) 98 | for i := range vec { 99 | vec[i] *= scale 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /pkg/drift/summary_test.go: -------------------------------------------------------------------------------- 1 | package drift 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestSummary_NoDrift(t *testing.T) { 9 | r := &Result{HasDrift: false} 10 | got := r.Summary() 11 | want := "No drift detected" 12 | if !strings.Contains(got, want) { 13 | t.Errorf("Summary() = %q, want substring %q", got, want) 14 | } 15 | } 16 | 17 | func TestSummary_MixedSeverities(t *testing.T) { 18 | r := &Result{ 19 | HasDrift: true, 20 | CriticalCount: 1, 21 | WarningCount: 1, 22 | InfoCount: 1, 23 | Alerts: []Alert{ 24 | { 25 | Type: AlertNewCycle, 26 | Severity: SeverityCritical, 27 | Message: "Critical Alert", 28 | Details: []string{"Detail A", "Detail B"}, 29 | }, 30 | { 31 | Type: AlertBlockedIncrease, 32 | Severity: SeverityWarning, 33 | Message: "Warning Alert", 34 | }, 35 | { 36 | Type: AlertNodeCountChange, 37 | Severity: SeverityInfo, 38 | Message: "Info Alert", 39 | }, 40 | }, 41 | } 42 | 43 | got := r.Summary() 44 | 45 | // Check headers 46 | if !strings.Contains(got, "🔴 CRITICAL: 1") { 47 | t.Error("Summary missing critical count") 48 | } 49 | if !strings.Contains(got, "🟡 WARNING: 1") { 50 | t.Error("Summary missing warning count") 51 | } 52 | if !strings.Contains(got, "🔵 INFO: 1") { 53 | t.Error("Summary missing info count") 54 | } 55 | 56 | // Check icons and messages 57 | if !strings.Contains(got, "🔴 [new_cycle] Critical Alert") { 58 | t.Error("Summary missing critical alert line") 59 | } 60 | if !strings.Contains(got, "🟡 [blocked_increase] Warning Alert") { 61 | t.Error("Summary missing warning alert line") 62 | } 63 | if !strings.Contains(got, "ℹ️ [node_count_change] Info Alert") { 64 | t.Error("Summary missing info alert line") 65 | } 66 | 67 | // Check details 68 | if !strings.Contains(got, "- Detail A") { 69 | t.Error("Summary missing detail A") 70 | } 71 | if !strings.Contains(got, "- Detail B") { 72 | t.Error("Summary missing detail B") 73 | } 74 | } 75 | 76 | func TestSummary_OnlyInfo(t *testing.T) { 77 | r := &Result{ 78 | HasDrift: true, 79 | InfoCount: 2, 80 | Alerts: []Alert{ 81 | {Type: AlertNodeCountChange, Severity: SeverityInfo, Message: "Info 1"}, 82 | {Type: AlertEdgeCountChange, Severity: SeverityInfo, Message: "Info 2"}, 83 | }, 84 | } 85 | 86 | got := r.Summary() 87 | 88 | if strings.Contains(got, "CRITICAL") { 89 | t.Error("Summary should not contain CRITICAL") 90 | } 91 | if strings.Contains(got, "WARNING") { 92 | t.Error("Summary should not contain WARNING") 93 | } 94 | if !strings.Contains(got, "🔵 INFO: 2") { 95 | t.Error("Summary missing info count") 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/search/weights_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "math" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestWeightsValidate_Presets(t *testing.T) { 12 | for _, preset := range ListPresets() { 13 | weights, err := GetPreset(preset) 14 | if err != nil { 15 | t.Fatalf("expected preset %q, got error: %v", preset, err) 16 | } 17 | if err := weights.Validate(); err != nil { 18 | t.Fatalf("preset %q should validate, got error: %v", preset, err) 19 | } 20 | } 21 | } 22 | 23 | func TestWeightsValidate_Negative(t *testing.T) { 24 | weights := Weights{ 25 | TextRelevance: -0.1, 26 | PageRank: 0.4, 27 | Status: 0.2, 28 | Impact: 0.2, 29 | Priority: 0.2, 30 | Recency: 0.1, 31 | } 32 | if err := weights.Validate(); err == nil { 33 | t.Fatal("expected error for negative weights") 34 | } 35 | } 36 | 37 | func TestWeightsValidate_SumTolerance(t *testing.T) { 38 | weights := Weights{ 39 | TextRelevance: 0.2, 40 | PageRank: 0.2, 41 | Status: 0.2, 42 | Impact: 0.2, 43 | Priority: 0.2, 44 | Recency: 0.2, 45 | } 46 | if err := weights.Validate(); err == nil { 47 | t.Fatal("expected error for weights summing above tolerance") 48 | } 49 | } 50 | 51 | func TestWeightsValidate_LowTextWarns(t *testing.T) { 52 | var buf bytes.Buffer 53 | orig := log.Writer() 54 | log.SetOutput(&buf) 55 | t.Cleanup(func() { 56 | log.SetOutput(orig) 57 | }) 58 | 59 | weights := Weights{ 60 | TextRelevance: 0.05, 61 | PageRank: 0.20, 62 | Status: 0.20, 63 | Impact: 0.20, 64 | Priority: 0.20, 65 | Recency: 0.15, 66 | } 67 | if err := weights.Validate(); err != nil { 68 | t.Fatalf("unexpected error: %v", err) 69 | } 70 | if !strings.Contains(buf.String(), "WARNING: text weight") { 71 | t.Fatalf("expected warning log for low text weight, got %q", buf.String()) 72 | } 73 | } 74 | 75 | func TestWeightsNormalize(t *testing.T) { 76 | weights := Weights{ 77 | TextRelevance: 1, 78 | PageRank: 2, 79 | Status: 3, 80 | Impact: 4, 81 | Priority: 5, 82 | Recency: 6, 83 | } 84 | normalized := weights.Normalize() 85 | sum := normalized.TextRelevance + normalized.PageRank + normalized.Status + normalized.Impact + normalized.Priority + normalized.Recency 86 | if math.Abs(sum-1.0) > 1e-9 { 87 | t.Fatalf("expected normalized weights to sum to 1.0, got %f", sum) 88 | } 89 | } 90 | 91 | func TestWeightsNormalize_ZeroSum(t *testing.T) { 92 | weights := Weights{} 93 | normalized := weights.Normalize() 94 | if normalized != weights { 95 | t.Fatalf("expected zero-sum weights to remain unchanged") 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /testdata/expected/star_10_metrics.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Star topology with center node n0 connected to n1-n9: n1,n2,...,n9 all point to n0", 3 | "node_count": 10, 4 | "edge_count": 9, 5 | "density": 0.1, 6 | "pagerank": { 7 | "n0": 0.49008539264197837, 8 | "n1": 0.05665717859533555, 9 | "n2": 0.05665717859533555, 10 | "n3": 0.05665717859533555, 11 | "n4": 0.05665717859533555, 12 | "n5": 0.05665717859533555, 13 | "n6": 0.05665717859533555, 14 | "n7": 0.05665717859533555, 15 | "n8": 0.05665717859533555, 16 | "n9": 0.05665717859533555 17 | }, 18 | "betweenness": {}, 19 | "eigenvector": { 20 | "n0": 1, 21 | "n1": 0, 22 | "n2": 0, 23 | "n3": 0, 24 | "n4": 0, 25 | "n5": 0, 26 | "n6": 0, 27 | "n7": 0, 28 | "n8": 0, 29 | "n9": 0 30 | }, 31 | "hubs": { 32 | "n0": 0, 33 | "n1": 0.3333333333333333, 34 | "n2": 0.3333333333333333, 35 | "n3": 0.3333333333333333, 36 | "n4": 0.3333333333333333, 37 | "n5": 0.3333333333333333, 38 | "n6": 0.3333333333333333, 39 | "n7": 0.3333333333333333, 40 | "n8": 0.3333333333333333, 41 | "n9": 0.3333333333333333 42 | }, 43 | "authorities": { 44 | "n0": 1, 45 | "n1": 0, 46 | "n2": 0, 47 | "n3": 0, 48 | "n4": 0, 49 | "n5": 0, 50 | "n6": 0, 51 | "n7": 0, 52 | "n8": 0, 53 | "n9": 0 54 | }, 55 | "critical_path_score": { 56 | "n0": 2, 57 | "n1": 1, 58 | "n2": 1, 59 | "n3": 1, 60 | "n4": 1, 61 | "n5": 1, 62 | "n6": 1, 63 | "n7": 1, 64 | "n8": 1, 65 | "n9": 1 66 | }, 67 | "topological_order": [ 68 | "n0", 69 | "n2", 70 | "n4", 71 | "n5", 72 | "n6", 73 | "n7", 74 | "n8", 75 | "n3", 76 | "n9", 77 | "n1" 78 | ], 79 | "core_number": { 80 | "n0": 1, 81 | "n1": 1, 82 | "n2": 1, 83 | "n3": 1, 84 | "n4": 1, 85 | "n5": 1, 86 | "n6": 1, 87 | "n7": 1, 88 | "n8": 1, 89 | "n9": 1 90 | }, 91 | "slack": { 92 | "n0": 0, 93 | "n1": 0, 94 | "n2": 0, 95 | "n3": 0, 96 | "n4": 0, 97 | "n5": 0, 98 | "n6": 0, 99 | "n7": 0, 100 | "n8": 0, 101 | "n9": 0 102 | }, 103 | "has_cycles": false, 104 | "out_degree": { 105 | "n0": 0, 106 | "n1": 1, 107 | "n2": 1, 108 | "n3": 1, 109 | "n4": 1, 110 | "n5": 1, 111 | "n6": 1, 112 | "n7": 1, 113 | "n8": 1, 114 | "n9": 1 115 | }, 116 | "in_degree": { 117 | "n0": 9, 118 | "n1": 0, 119 | "n2": 0, 120 | "n3": 0, 121 | "n4": 0, 122 | "n5": 0, 123 | "n6": 0, 124 | "n7": 0, 125 | "n8": 0, 126 | "n9": 0 127 | } 128 | } -------------------------------------------------------------------------------- /pkg/ui/styles_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | func TestRenderPriorityBadge(t *testing.T) { 12 | tests := []struct { 13 | prio int 14 | want string 15 | }{ 16 | {0, "P0"}, 17 | {1, "P1"}, 18 | {2, "P2"}, 19 | {3, "P3"}, 20 | {4, "P4"}, 21 | {99, "P?"}, 22 | } 23 | 24 | for _, tt := range tests { 25 | got := RenderPriorityBadge(tt.prio) 26 | if !strings.Contains(got, tt.want) { 27 | t.Errorf("RenderPriorityBadge(%d) = %q, want to contain %q", tt.prio, got, tt.want) 28 | } 29 | } 30 | } 31 | 32 | func TestRenderStatusBadge(t *testing.T) { 33 | tests := []struct { 34 | status string 35 | want string 36 | }{ 37 | {"open", "OPEN"}, 38 | {"in_progress", "PROG"}, 39 | {"blocked", "BLKD"}, 40 | {"closed", "DONE"}, 41 | {"unknown", "????"}, 42 | } 43 | 44 | for _, tt := range tests { 45 | got := RenderStatusBadge(tt.status) 46 | if !strings.Contains(got, tt.want) { 47 | t.Errorf("RenderStatusBadge(%q) = %q, want to contain %q", tt.status, got, tt.want) 48 | } 49 | } 50 | } 51 | 52 | func TestRenderMiniBar(t *testing.T) { 53 | renderer := lipgloss.NewRenderer(io.Discard) 54 | theme := DefaultTheme(renderer) 55 | 56 | tests := []struct { 57 | val float64 58 | width int 59 | }{ 60 | {0.0, 10}, 61 | {0.5, 10}, 62 | {1.0, 10}, 63 | {-0.1, 10}, // Should clamp to 0 64 | {1.5, 10}, // Should clamp to 1 65 | {0.5, 0}, // Should return empty 66 | {0.5, -5}, // Should return empty (no panic) 67 | } 68 | 69 | for _, tt := range tests { 70 | got := RenderMiniBar(tt.val, tt.width, theme) 71 | if tt.width <= 0 { 72 | if got != "" { 73 | t.Errorf("RenderMiniBar(%v, %d) = %q, want empty string", tt.val, tt.width, got) 74 | } 75 | continue 76 | } 77 | // Basic sanity check: output should not be empty 78 | if got == "" { 79 | t.Errorf("RenderMiniBar(%v, %d) returned empty string", tt.val, tt.width) 80 | } 81 | // Check expected fullness characters approximately 82 | if tt.val > 0 { 83 | if !strings.Contains(got, "█") && !strings.Contains(got, "░") { 84 | t.Errorf("RenderMiniBar output expected bar chars, got %q", got) 85 | } 86 | } 87 | } 88 | } 89 | 90 | func TestRenderRankBadge(t *testing.T) { 91 | tests := []struct { 92 | rank int 93 | total int 94 | want string 95 | }{ 96 | {1, 100, "#1"}, 97 | {50, 100, "#50"}, 98 | {0, 0, "#?"}, 99 | } 100 | 101 | for _, tt := range tests { 102 | got := RenderRankBadge(tt.rank, tt.total) 103 | if !strings.Contains(got, tt.want) { 104 | t.Errorf("RenderRankBadge(%d, %d) = %q, want to contain %q", tt.rank, tt.total, got, tt.want) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/e2e/workspace_robot_output_e2e_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestWorkspaceRobotTriageCleanOutput(t *testing.T) { 14 | bv := buildBvBinary(t) 15 | 16 | workspaceRoot := t.TempDir() 17 | configPath := filepath.Join(workspaceRoot, ".bv", "workspace.yaml") 18 | 19 | // Create two repos with issues. 20 | apiBeadsDir := filepath.Join(workspaceRoot, "services", "api", ".beads") 21 | webBeadsDir := filepath.Join(workspaceRoot, "apps", "web", ".beads") 22 | if err := os.MkdirAll(apiBeadsDir, 0o755); err != nil { 23 | t.Fatalf("mkdir api beads: %v", err) 24 | } 25 | if err := os.MkdirAll(webBeadsDir, 0o755); err != nil { 26 | t.Fatalf("mkdir web beads: %v", err) 27 | } 28 | 29 | apiIssues := `{"id":"AUTH-1","title":"API auth","status":"open","priority":1,"issue_type":"task"}` 30 | if err := os.WriteFile(filepath.Join(apiBeadsDir, "issues.jsonl"), []byte(apiIssues+"\n"), 0o644); err != nil { 31 | t.Fatalf("write api issues.jsonl: %v", err) 32 | } 33 | 34 | // Cross-repo dependency references must already be namespaced. 35 | webIssues := `{"id":"UI-1","title":"Web UI","status":"open","priority":2,"issue_type":"task","dependencies":[{"issue_id":"UI-1","depends_on_id":"api-AUTH-1","type":"blocks"}]}` 36 | if err := os.WriteFile(filepath.Join(webBeadsDir, "issues.jsonl"), []byte(webIssues+"\n"), 0o644); err != nil { 37 | t.Fatalf("write web issues.jsonl: %v", err) 38 | } 39 | 40 | config := ` 41 | name: test-workspace 42 | repos: 43 | - name: api 44 | path: services/api 45 | prefix: api- 46 | - name: web 47 | path: apps/web 48 | prefix: web- 49 | discovery: 50 | enabled: false 51 | ` 52 | if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { 53 | t.Fatalf("mkdir .bv: %v", err) 54 | } 55 | if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil { 56 | t.Fatalf("write workspace.yaml: %v", err) 57 | } 58 | 59 | cmd := exec.Command(bv, "--robot-triage", "--workspace", configPath) 60 | cmd.Dir = workspaceRoot 61 | var stdout, stderr bytes.Buffer 62 | cmd.Stdout = &stdout 63 | cmd.Stderr = &stderr 64 | if err := cmd.Run(); err != nil { 65 | t.Fatalf("--robot-triage --workspace failed: %v\nstderr=%s\nstdout=%s", err, stderr.String(), stdout.String()) 66 | } 67 | if got := strings.TrimSpace(stderr.String()); got != "" { 68 | t.Fatalf("expected empty stderr for robot JSON, got: %s", got) 69 | } 70 | 71 | var payload map[string]any 72 | if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { 73 | t.Fatalf("invalid JSON on stdout: %v\nstdout=%s", err, stdout.String()) 74 | } 75 | if _, ok := payload["generated_at"]; !ok { 76 | t.Fatalf("missing generated_at") 77 | } 78 | if _, ok := payload["triage"]; !ok { 79 | t.Fatalf("missing triage") 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pkg/search/hybrid_scorer_real_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/Dicklesworthstone/beads_viewer/pkg/loader" 8 | "github.com/Dicklesworthstone/beads_viewer/pkg/model" 9 | ) 10 | 11 | func loadHybridFixtureIssues(t *testing.T) []model.Issue { 12 | t.Helper() 13 | path := filepath.Join("..", "..", "tests", "testdata", "search_hybrid.jsonl") 14 | issues, err := loader.LoadIssuesFromFile(path) 15 | if err != nil { 16 | t.Fatalf("load fixture issues: %v", err) 17 | } 18 | if len(issues) == 0 { 19 | t.Fatalf("fixture issues empty") 20 | } 21 | return issues 22 | } 23 | 24 | func TestHybridScorer_RealMetrics(t *testing.T) { 25 | issues := loadHybridFixtureIssues(t) 26 | cache := NewMetricsCache(NewAnalyzerMetricsLoader(issues)) 27 | if err := cache.Refresh(); err != nil { 28 | t.Fatalf("refresh metrics cache: %v", err) 29 | } 30 | 31 | weights, err := GetPreset(PresetDefault) 32 | if err != nil { 33 | t.Fatalf("preset default: %v", err) 34 | } 35 | 36 | scorer := NewHybridScorer(weights, cache) 37 | metrics, ok := cache.Get("sh-1") 38 | if !ok { 39 | t.Fatalf("expected metrics for sh-1") 40 | } 41 | 42 | textScore := 0.75 43 | result, err := scorer.Score("sh-1", textScore) 44 | if err != nil { 45 | t.Fatalf("score sh-1: %v", err) 46 | } 47 | 48 | expected := weights.TextRelevance*textScore + 49 | weights.PageRank*metrics.PageRank + 50 | weights.Status*normalizeStatus(metrics.Status) + 51 | weights.Impact*normalizeImpact(metrics.BlockerCount, cache.MaxBlockerCount()) + 52 | weights.Priority*normalizePriority(metrics.Priority) + 53 | weights.Recency*normalizeRecency(metrics.UpdatedAt) 54 | 55 | if diff := result.FinalScore - expected; diff > 1e-9 || diff < -1e-9 { 56 | t.Fatalf("expected final score %f, got %f", expected, result.FinalScore) 57 | } 58 | 59 | if result.ComponentScores["pagerank"] != metrics.PageRank { 60 | t.Fatalf("pagerank component mismatch: %f vs %f", result.ComponentScores["pagerank"], metrics.PageRank) 61 | } 62 | } 63 | 64 | func TestHybridScorer_RealMetricsOrdering(t *testing.T) { 65 | issues := loadHybridFixtureIssues(t) 66 | cache := NewMetricsCache(NewAnalyzerMetricsLoader(issues)) 67 | if err := cache.Refresh(); err != nil { 68 | t.Fatalf("refresh metrics cache: %v", err) 69 | } 70 | 71 | weights, err := GetPreset(PresetDefault) 72 | if err != nil { 73 | t.Fatalf("preset default: %v", err) 74 | } 75 | scorer := NewHybridScorer(weights, cache) 76 | 77 | textScore := 0.6 78 | left, err := scorer.Score("sh-1", textScore) 79 | if err != nil { 80 | t.Fatalf("score sh-1: %v", err) 81 | } 82 | right, err := scorer.Score("sh-4", textScore) 83 | if err != nil { 84 | t.Fatalf("score sh-4: %v", err) 85 | } 86 | 87 | if left.FinalScore <= right.FinalScore { 88 | t.Fatalf("expected sh-1 score %f to exceed sh-4 score %f", left.FinalScore, right.FinalScore) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/e2e/robot_capacity_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os/exec" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestRobotCapacity_EstimatedDaysDropsWithMoreAgents(t *testing.T) { 12 | bv := buildBvBinary(t) 13 | env := t.TempDir() 14 | 15 | now := time.Now().UTC().Format(time.RFC3339) 16 | 17 | // Three independent tasks; estimated_minutes drives total_minutes deterministically. 18 | writeBeads(t, env, fmt.Sprintf( 19 | `{"id":"A","title":"A","status":"open","priority":1,"issue_type":"task","estimated_minutes":480,"labels":["backend"],"created_at":"%s","updated_at":"%s"} 20 | {"id":"B","title":"B","status":"open","priority":1,"issue_type":"task","estimated_minutes":480,"labels":["backend"],"created_at":"%s","updated_at":"%s"} 21 | {"id":"C","title":"C","status":"open","priority":1,"issue_type":"task","estimated_minutes":480,"labels":["frontend"],"created_at":"%s","updated_at":"%s"}`, 22 | now, now, now, now, now, now, 23 | )) 24 | 25 | run := func(args ...string) struct { 26 | Agents int `json:"agents"` 27 | Label string `json:"label"` 28 | OpenIssueCount int `json:"open_issue_count"` 29 | EstimatedDays float64 `json:"estimated_days"` 30 | TotalMinutes int `json:"total_minutes"` 31 | } { 32 | t.Helper() 33 | cmd := exec.Command(bv, args...) 34 | cmd.Dir = env 35 | out, err := cmd.CombinedOutput() 36 | if err != nil { 37 | t.Fatalf("%v failed: %v\n%s", args, err, out) 38 | } 39 | var payload struct { 40 | Agents int `json:"agents"` 41 | Label string `json:"label"` 42 | OpenIssueCount int `json:"open_issue_count"` 43 | EstimatedDays float64 `json:"estimated_days"` 44 | TotalMinutes int `json:"total_minutes"` 45 | } 46 | if err := json.Unmarshal(out, &payload); err != nil { 47 | t.Fatalf("json decode: %v\nout=%s", err, out) 48 | } 49 | return payload 50 | } 51 | 52 | one := run("--robot-capacity", "--agents=1") 53 | three := run("--robot-capacity", "--agents=3") 54 | 55 | if one.OpenIssueCount != 3 || three.OpenIssueCount != 3 { 56 | t.Fatalf("open_issue_count mismatch: one=%d three=%d", one.OpenIssueCount, three.OpenIssueCount) 57 | } 58 | if one.TotalMinutes <= 0 || three.TotalMinutes != one.TotalMinutes { 59 | t.Fatalf("total_minutes mismatch: one=%d three=%d", one.TotalMinutes, three.TotalMinutes) 60 | } 61 | if !(three.EstimatedDays < one.EstimatedDays) { 62 | t.Fatalf("expected estimated_days to drop with more agents: one=%.3f three=%.3f", one.EstimatedDays, three.EstimatedDays) 63 | } 64 | 65 | // Label filter. 66 | backend := run("--robot-capacity", "--capacity-label=backend", "--agents=1") 67 | if backend.Label != "backend" { 68 | t.Fatalf("label=%q; want backend", backend.Label) 69 | } 70 | if backend.OpenIssueCount != 2 { 71 | t.Fatalf("backend open_issue_count=%d; want 2", backend.OpenIssueCount) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/search/vector_index_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestVectorIndex_SaveLoad_RoundTrip(t *testing.T) { 9 | idx := NewVectorIndex(4) 10 | 11 | if err := idx.Upsert("A", ComputeContentHash("a"), []float32{1, 0, 0, 0}); err != nil { 12 | t.Fatalf("Upsert failed: %v", err) 13 | } 14 | if err := idx.Upsert("B", ComputeContentHash("b"), []float32{0, 1, 0, 0}); err != nil { 15 | t.Fatalf("Upsert failed: %v", err) 16 | } 17 | 18 | path := filepath.Join(t.TempDir(), "semantic", "index.bvvi") 19 | if err := idx.Save(path); err != nil { 20 | t.Fatalf("Save failed: %v", err) 21 | } 22 | 23 | loaded, err := LoadVectorIndex(path) 24 | if err != nil { 25 | t.Fatalf("LoadVectorIndex failed: %v", err) 26 | } 27 | if loaded.Dim != 4 { 28 | t.Fatalf("Dim mismatch: got %d want %d", loaded.Dim, 4) 29 | } 30 | if loaded.Size() != 2 { 31 | t.Fatalf("Size mismatch: got %d want %d", loaded.Size(), 2) 32 | } 33 | 34 | a, ok := loaded.Get("A") 35 | if !ok { 36 | t.Fatalf("Expected entry A") 37 | } 38 | if a.ContentHash != ComputeContentHash("a") { 39 | t.Fatalf("Content hash mismatch for A") 40 | } 41 | if len(a.Vector) != 4 || a.Vector[0] != 1 { 42 | t.Fatalf("Vector mismatch for A: %#v", a.Vector) 43 | } 44 | } 45 | 46 | func TestVectorIndex_SearchTopK_OrderAndTieBreak(t *testing.T) { 47 | idx := NewVectorIndex(2) 48 | 49 | // Both entries have equal similarity to query; tie-break should be IssueID ascending. 50 | if err := idx.Upsert("B", ComputeContentHash("b"), []float32{1, 0}); err != nil { 51 | t.Fatalf("Upsert failed: %v", err) 52 | } 53 | if err := idx.Upsert("A", ComputeContentHash("a"), []float32{1, 0}); err != nil { 54 | t.Fatalf("Upsert failed: %v", err) 55 | } 56 | 57 | results, err := idx.SearchTopK([]float32{1, 0}, 2) 58 | if err != nil { 59 | t.Fatalf("SearchTopK failed: %v", err) 60 | } 61 | if len(results) != 2 { 62 | t.Fatalf("Expected 2 results, got %d", len(results)) 63 | } 64 | if results[0].IssueID != "A" || results[1].IssueID != "B" { 65 | t.Fatalf("Unexpected order: %#v", results) 66 | } 67 | } 68 | 69 | func TestVectorIndex_Errors(t *testing.T) { 70 | idx := NewVectorIndex(3) 71 | 72 | if err := idx.Upsert("A", ComputeContentHash("a"), []float32{1, 2}); err == nil { 73 | t.Fatalf("Expected dim mismatch error on Upsert") 74 | } 75 | if _, err := idx.SearchTopK([]float32{1, 2}, 1); err == nil { 76 | t.Fatalf("Expected dim mismatch error on SearchTopK") 77 | } 78 | } 79 | 80 | func TestContentHash_HexRoundTrip(t *testing.T) { 81 | h := ComputeContentHash("hello world") 82 | hexStr := h.Hex() 83 | if len(hexStr) != 64 { 84 | t.Fatalf("Expected 64-char hex, got %d", len(hexStr)) 85 | } 86 | parsed, err := ParseContentHashHex(hexStr) 87 | if err != nil { 88 | t.Fatalf("ParseContentHashHex failed: %v", err) 89 | } 90 | if parsed != h { 91 | t.Fatalf("Hash round-trip mismatch") 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /GOLANG_BEST_PRACTICES.md: -------------------------------------------------------------------------------- 1 | # Golang Best Practices 2 | 3 | This guide outlines the best practices for Go development within this project, modeled after high-quality TUI applications. 4 | 5 | ## 1. Project Structure 6 | - **cmd/**: Contains the main applications. Each subdirectory should be a main package (e.g., `cmd/bv/main.go`). 7 | - **pkg/**: Library code that can be imported by other projects. 8 | - **internal/**: Private library code that should not be imported by outside projects. Use this for core logic specific to the application. 9 | - **internal/ui/**: User Interface components (Bubble Tea models, views). 10 | - **internal/model/**: Domain models and data structures. 11 | - **internal/config/**: Configuration loading and management. 12 | 13 | ## 2. Code Style 14 | - **Formatting**: Always use `gofmt` (or `goimports`). 15 | - **Naming**: 16 | - Use `CamelCase` for exported identifiers. 17 | - Use `camelCase` for unexported identifiers. 18 | - Keep variable names short but descriptive (e.g., `i` for index, `ctx` for context). 19 | - Package names should be short, lowercase, and singular (e.g., `model`, `ui`, `auth`). 20 | - **Error Handling**: 21 | - Return errors as the last return value. 22 | - Check errors immediately. 23 | - Use `fmt.Errorf` with `%w` to wrap errors for context. 24 | - Don't panic unless it's a truly unrecoverable initialization error. 25 | 26 | ## 3. TUI Development (Charmbracelet Stack) 27 | - **Architecture**: Follow The Elm Architecture (Model, View, Update) via `bubbletea`. 28 | - **Components**: Break down complex UIs into smaller, reusable `tea.Model` components (e.g., `ListView`, `DetailView`, `FilterBar`). 29 | - **Styling**: Use `lipgloss` for all styling. Define a central `styles.go` to maintain consistency (colors, margins, padding). 30 | - **State**: Keep the main model clean. Delegate update logic to sub-models. 31 | 32 | ## 4. Configuration & Data 33 | - **Config**: Use struct-based configuration. Load from environment variables or config files (YAML/JSON). 34 | - **Data Access**: separate data loading (Loader/Repository pattern) from the UI logic. The UI should receive data, not fetch it directly if possible. 35 | 36 | ## 5. Testing 37 | - Write unit tests for logic-heavy packages. 38 | - Use table-driven tests for parser/validator logic. 39 | - Run tests with `go test ./...`. 40 | 41 | ## 6. Dependencies 42 | - Use `go mod` for dependency management. 43 | - specific versions should be pinned in `go.mod`. 44 | - Vendor dependencies if necessary for offline builds, but standard `go mod` is usually sufficient. 45 | 46 | ## 7. Documentation 47 | - Add comments to exported functions and types (`// TypeName represents...`). 48 | - Maintain a `README.md` with installation and usage instructions. 49 | 50 | ## 8. Concurrency 51 | - Use channels for communication between goroutines. 52 | - Use `sync.Mutex` for protecting shared state if not using channels. 53 | - Avoid global state where possible. 54 | -------------------------------------------------------------------------------- /pkg/updater/network_test.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestCheckForUpdates_Network(t *testing.T) { 11 | // Assume current version is v0.9.2 from version.go (hardcoded knowledge, but acceptable for unit tests) 12 | // Better: we can't easily mock version.Version without changing that package or doing link-time substitution. 13 | // Instead, we'll construct scenarios based on whatever version.Version is, assuming it's valid. 14 | 15 | tests := []struct { 16 | name string 17 | responseBody string 18 | responseStatus int 19 | expectTag string 20 | expectURL string 21 | expectErr bool 22 | }{ 23 | { 24 | name: "Newer version available", 25 | responseBody: `{"tag_name": "v99.0.0", "html_url": "http://example.com/release"}`, 26 | responseStatus: http.StatusOK, 27 | expectTag: "v99.0.0", 28 | expectURL: "http://example.com/release", 29 | expectErr: false, 30 | }, 31 | { 32 | name: "Same version (no update)", 33 | responseBody: `{"tag_name": "v0.0.0", "html_url": "http://example.com/release"}`, // Assumes current > v0.0.0 34 | responseStatus: http.StatusOK, 35 | expectTag: "", 36 | expectURL: "", 37 | expectErr: false, 38 | }, 39 | { 40 | name: "Rate limit (403)", 41 | responseBody: `{"message": "rate limit exceeded"}`, 42 | responseStatus: http.StatusForbidden, 43 | expectTag: "", 44 | expectURL: "", 45 | expectErr: false, // Should swallow error 46 | }, 47 | { 48 | name: "Server error (500)", 49 | responseBody: "", 50 | responseStatus: http.StatusInternalServerError, 51 | expectTag: "", 52 | expectURL: "", 53 | expectErr: true, 54 | }, 55 | { 56 | name: "Invalid JSON", 57 | responseBody: `{invalid json}`, 58 | responseStatus: http.StatusOK, 59 | expectTag: "", 60 | expectURL: "", 61 | expectErr: true, 62 | }, 63 | } 64 | 65 | for _, tt := range tests { 66 | t.Run(tt.name, func(t *testing.T) { 67 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 68 | w.WriteHeader(tt.responseStatus) 69 | w.Write([]byte(tt.responseBody)) 70 | })) 71 | defer server.Close() 72 | 73 | client := server.Client() 74 | client.Timeout = 1 * time.Second 75 | 76 | tag, url, err := checkForUpdates(client, server.URL) 77 | 78 | if (err != nil) != tt.expectErr { 79 | t.Errorf("checkForUpdates() error = %v, expectErr %v", err, tt.expectErr) 80 | return 81 | } 82 | 83 | if tag != tt.expectTag { 84 | t.Errorf("checkForUpdates() tag = %v, want %v", tag, tt.expectTag) 85 | } 86 | if url != tt.expectURL { 87 | t.Errorf("checkForUpdates() url = %v, want %v", url, tt.expectURL) 88 | } 89 | }) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /pkg/updater/fileops_test.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "compress/gzip" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestCopyFile_PreservesContentAndMode(t *testing.T) { 14 | tmpDir := t.TempDir() 15 | src := filepath.Join(tmpDir, "src.bin") 16 | dst := filepath.Join(tmpDir, "dst.bin") 17 | 18 | data := []byte("copy me") 19 | if err := os.WriteFile(src, data, 0o644); err != nil { 20 | t.Fatalf("write src: %v", err) 21 | } 22 | if err := os.Chmod(src, 0o755); err != nil { 23 | t.Fatalf("chmod src: %v", err) 24 | } 25 | 26 | if err := copyFile(src, dst); err != nil { 27 | t.Fatalf("copyFile failed: %v", err) 28 | } 29 | 30 | got, err := os.ReadFile(dst) 31 | if err != nil { 32 | t.Fatalf("read dst: %v", err) 33 | } 34 | if !bytes.Equal(got, data) { 35 | t.Fatalf("content mismatch: got %q want %q", got, data) 36 | } 37 | 38 | srcInfo, err := os.Stat(src) 39 | if err != nil { 40 | t.Fatalf("stat src: %v", err) 41 | } 42 | dstInfo, err := os.Stat(dst) 43 | if err != nil { 44 | t.Fatalf("stat dst: %v", err) 45 | } 46 | if dstInfo.Mode() != srcInfo.Mode() { 47 | t.Fatalf("mode mismatch: dst=%v src=%v", dstInfo.Mode(), srcInfo.Mode()) 48 | } 49 | } 50 | 51 | func TestExtractBinary_FromArchive(t *testing.T) { 52 | tmpDir := t.TempDir() 53 | archivePath := filepath.Join(tmpDir, "bv.tar.gz") 54 | destPath := filepath.Join(tmpDir, "bv") 55 | 56 | var buf bytes.Buffer 57 | gzw := gzip.NewWriter(&buf) 58 | tw := tar.NewWriter(gzw) 59 | 60 | payload := []byte("fake-binary") 61 | hdr := &tar.Header{ 62 | Name: "bv", 63 | Mode: 0o755, 64 | Size: int64(len(payload)), 65 | } 66 | if err := tw.WriteHeader(hdr); err != nil { 67 | t.Fatalf("write tar header: %v", err) 68 | } 69 | if _, err := tw.Write(payload); err != nil { 70 | t.Fatalf("write tar payload: %v", err) 71 | } 72 | if err := tw.Close(); err != nil { 73 | t.Fatalf("close tar: %v", err) 74 | } 75 | if err := gzw.Close(); err != nil { 76 | t.Fatalf("close gzip: %v", err) 77 | } 78 | 79 | if err := os.WriteFile(archivePath, buf.Bytes(), 0o644); err != nil { 80 | t.Fatalf("write archive: %v", err) 81 | } 82 | 83 | if err := extractBinary(archivePath, destPath); err != nil { 84 | t.Fatalf("extractBinary failed: %v", err) 85 | } 86 | 87 | got, err := os.ReadFile(destPath) 88 | if err != nil { 89 | t.Fatalf("read extracted: %v", err) 90 | } 91 | if !bytes.Equal(got, payload) { 92 | t.Fatalf("payload mismatch: got %q want %q", got, payload) 93 | } 94 | } 95 | 96 | func TestRollback_NoBackup(t *testing.T) { 97 | err := Rollback() 98 | if err == nil { 99 | t.Fatalf("expected rollback to fail when no backup exists") 100 | } 101 | if !strings.Contains(err.Error(), "no backup found") { 102 | t.Fatalf("unexpected error: %v", err) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /testdata/expected/chain_10_metrics.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Linear chain of 10 nodes: n0 -\u003e n1 -\u003e n2 -\u003e ... -\u003e n9", 3 | "node_count": 10, 4 | "edge_count": 9, 5 | "density": 0.1, 6 | "pagerank": { 7 | "n0": 0.027528244769930488, 8 | "n1": 0.050927193665541055, 9 | "n2": 0.07081622611612273, 10 | "n3": 0.08772185777895704, 11 | "n4": 0.10209166774042029, 12 | "n5": 0.11430606985970494, 13 | "n6": 0.12468849362743735, 14 | "n7": 0.13351372165067496, 15 | "n8": 0.14101520837249087, 16 | "n9": 0.14739131641871978 17 | }, 18 | "betweenness": { 19 | "n1": 8, 20 | "n2": 14, 21 | "n3": 18, 22 | "n4": 20, 23 | "n5": 20, 24 | "n6": 18, 25 | "n7": 14, 26 | "n8": 8 27 | }, 28 | "eigenvector": { 29 | "n0": 0, 30 | "n1": 0, 31 | "n2": 0, 32 | "n3": 0, 33 | "n4": 0, 34 | "n5": 0, 35 | "n6": 0, 36 | "n7": 0, 37 | "n8": 0, 38 | "n9": 1 39 | }, 40 | "hubs": { 41 | "n0": 0.33333333333333337, 42 | "n1": 0.33333333333333337, 43 | "n2": 0.33333333333333337, 44 | "n3": 0.33333333333333337, 45 | "n4": 0.33333333333333337, 46 | "n5": 0.33333333333333337, 47 | "n6": 0.33333333333333337, 48 | "n7": 0.33333333333333337, 49 | "n8": 0.33333333333333337, 50 | "n9": 0 51 | }, 52 | "authorities": { 53 | "n0": 0, 54 | "n1": 0.33333333333333337, 55 | "n2": 0.33333333333333337, 56 | "n3": 0.33333333333333337, 57 | "n4": 0.33333333333333337, 58 | "n5": 0.33333333333333337, 59 | "n6": 0.33333333333333337, 60 | "n7": 0.33333333333333337, 61 | "n8": 0.33333333333333337, 62 | "n9": 0.33333333333333337 63 | }, 64 | "critical_path_score": { 65 | "n0": 1, 66 | "n1": 2, 67 | "n2": 3, 68 | "n3": 4, 69 | "n4": 5, 70 | "n5": 6, 71 | "n6": 7, 72 | "n7": 8, 73 | "n8": 9, 74 | "n9": 10 75 | }, 76 | "topological_order": [ 77 | "n9", 78 | "n8", 79 | "n7", 80 | "n6", 81 | "n5", 82 | "n4", 83 | "n3", 84 | "n2", 85 | "n1", 86 | "n0" 87 | ], 88 | "core_number": { 89 | "n0": 1, 90 | "n1": 1, 91 | "n2": 1, 92 | "n3": 1, 93 | "n4": 1, 94 | "n5": 1, 95 | "n6": 1, 96 | "n7": 1, 97 | "n8": 1, 98 | "n9": 1 99 | }, 100 | "slack": { 101 | "n0": 0, 102 | "n1": 0, 103 | "n2": 0, 104 | "n3": 0, 105 | "n4": 0, 106 | "n5": 0, 107 | "n6": 0, 108 | "n7": 0, 109 | "n8": 0, 110 | "n9": 0 111 | }, 112 | "has_cycles": false, 113 | "out_degree": { 114 | "n0": 1, 115 | "n1": 1, 116 | "n2": 1, 117 | "n3": 1, 118 | "n4": 1, 119 | "n5": 1, 120 | "n6": 1, 121 | "n7": 1, 122 | "n8": 1, 123 | "n9": 0 124 | }, 125 | "in_degree": { 126 | "n0": 0, 127 | "n1": 1, 128 | "n2": 1, 129 | "n3": 1, 130 | "n4": 1, 131 | "n5": 1, 132 | "n6": 1, 133 | "n7": 1, 134 | "n8": 1, 135 | "n9": 1 136 | } 137 | } -------------------------------------------------------------------------------- /tests/e2e/robot_stderr_cleanliness_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "testing" 10 | ) 11 | 12 | func writeIssuesJSONL(t *testing.T, repoDir, content string) { 13 | t.Helper() 14 | beadsDir := filepath.Join(repoDir, ".beads") 15 | if err := os.MkdirAll(beadsDir, 0o755); err != nil { 16 | t.Fatalf("mkdir .beads: %v", err) 17 | } 18 | if err := os.WriteFile(filepath.Join(beadsDir, "issues.jsonl"), []byte(content), 0o644); err != nil { 19 | t.Fatalf("write issues.jsonl: %v", err) 20 | } 21 | } 22 | 23 | func TestRobotTriage_MalformedIssuesLine_NoStderr(t *testing.T) { 24 | bv := buildBvBinary(t) 25 | repoDir := t.TempDir() 26 | 27 | issues := `{"id":"A","title":"Alpha","status":"open","priority":1,"issue_type":"task"}` + "\n" + 28 | `{this is not json}` + "\n" 29 | writeIssuesJSONL(t, repoDir, issues) 30 | 31 | cmd := exec.Command(bv, "--robot-triage") 32 | cmd.Dir = repoDir 33 | var stdout bytes.Buffer 34 | var stderr bytes.Buffer 35 | cmd.Stdout = &stdout 36 | cmd.Stderr = &stderr 37 | 38 | if err := cmd.Run(); err != nil { 39 | t.Fatalf("--robot-triage failed: %v\nstdout=%s\nstderr=%s", err, stdout.String(), stderr.String()) 40 | } 41 | if got := stderr.String(); got != "" { 42 | t.Fatalf("expected empty stderr; got:\n%s", got) 43 | } 44 | 45 | var payload struct { 46 | DataHash string `json:"data_hash"` 47 | Triage any `json:"triage"` 48 | } 49 | if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { 50 | t.Fatalf("json decode: %v\nout=%s", err, stdout.String()) 51 | } 52 | if payload.DataHash == "" { 53 | t.Fatalf("expected data_hash to be set") 54 | } 55 | if payload.Triage == nil { 56 | t.Fatalf("expected triage payload to be present") 57 | } 58 | } 59 | 60 | func TestRobotSprintList_MalformedSprintLine_NoStderr(t *testing.T) { 61 | bv := buildBvBinary(t) 62 | sprints := `{"id":"sprint-1","name":"Sprint 1","bead_ids":["A"]}` + "\n" + 63 | `{this is not json}` + "\n" 64 | repoDir := createSprintRepo(t, sprints) 65 | 66 | cmd := exec.Command(bv, "--robot-sprint-list") 67 | cmd.Dir = repoDir 68 | var stdout bytes.Buffer 69 | var stderr bytes.Buffer 70 | cmd.Stdout = &stdout 71 | cmd.Stderr = &stderr 72 | 73 | if err := cmd.Run(); err != nil { 74 | t.Fatalf("--robot-sprint-list failed: %v\nstdout=%s\nstderr=%s", err, stdout.String(), stderr.String()) 75 | } 76 | if got := stderr.String(); got != "" { 77 | t.Fatalf("expected empty stderr; got:\n%s", got) 78 | } 79 | 80 | var payload struct { 81 | SprintCount int `json:"sprint_count"` 82 | Sprints []struct { 83 | ID string `json:"id"` 84 | } `json:"sprints"` 85 | } 86 | if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { 87 | t.Fatalf("json decode: %v\nout=%s", err, stdout.String()) 88 | } 89 | if payload.SprintCount != 1 { 90 | t.Fatalf("expected sprint_count=1, got %d", payload.SprintCount) 91 | } 92 | if len(payload.Sprints) != 1 || payload.Sprints[0].ID != "sprint-1" { 93 | t.Fatalf("expected sprint-1 only, got %+v", payload.Sprints) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /pkg/search/index_sync_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestSyncVectorIndex_IncrementalUpdates(t *testing.T) { 10 | embedder, err := NewEmbedderFromConfig(EmbeddingConfig{Provider: ProviderHash, Dim: 16}) 11 | if err != nil { 12 | t.Fatalf("NewEmbedderFromConfig: %v", err) 13 | } 14 | 15 | idx := NewVectorIndex(embedder.Dim()) 16 | docs1 := map[string]string{ 17 | "A": "Fix login flow\nAdd OAuth redirect handling", 18 | "B": "Update docs\nReadme improvements", 19 | } 20 | 21 | stats, err := SyncVectorIndex(context.Background(), idx, embedder, docs1, 0) 22 | if err != nil { 23 | t.Fatalf("SyncVectorIndex: %v", err) 24 | } 25 | if stats.Added != 2 || stats.Embedded != 2 || stats.Updated != 0 || stats.Removed != 0 { 26 | t.Fatalf("unexpected stats: %+v", stats) 27 | } 28 | if idx.Size() != 2 { 29 | t.Fatalf("expected 2 entries, got %d", idx.Size()) 30 | } 31 | 32 | // Second sync with identical docs should not re-embed. 33 | stats2, err := SyncVectorIndex(context.Background(), idx, embedder, docs1, 0) 34 | if err != nil { 35 | t.Fatalf("SyncVectorIndex: %v", err) 36 | } 37 | if stats2.Skipped != 2 || stats2.Embedded != 0 || stats2.Added != 0 || stats2.Updated != 0 || stats2.Removed != 0 { 38 | t.Fatalf("unexpected stats: %+v", stats2) 39 | } 40 | 41 | // Change A, remove B, add C. 42 | docs2 := map[string]string{ 43 | "A": "Fix login flow\nHandle PKCE code verifier", 44 | "C": "Add tests\nCover edge cases", 45 | } 46 | stats3, err := SyncVectorIndex(context.Background(), idx, embedder, docs2, 0) 47 | if err != nil { 48 | t.Fatalf("SyncVectorIndex: %v", err) 49 | } 50 | if stats3.Updated != 1 || stats3.Added != 1 || stats3.Removed != 1 || stats3.Embedded != 2 { 51 | t.Fatalf("unexpected stats: %+v", stats3) 52 | } 53 | if idx.Size() != 2 { 54 | t.Fatalf("expected 2 entries after update, got %d", idx.Size()) 55 | } 56 | if _, ok := idx.Get("B"); ok { 57 | t.Fatalf("expected B to be removed") 58 | } 59 | if _, ok := idx.Get("C"); !ok { 60 | t.Fatalf("expected C to be present") 61 | } 62 | } 63 | 64 | func TestLoadOrNewVectorIndex(t *testing.T) { 65 | embedder := NewHashEmbedder(8) 66 | path := filepath.Join(t.TempDir(), "semantic", "index.bvvi") 67 | 68 | idx, loaded, err := LoadOrNewVectorIndex(path, embedder.Dim()) 69 | if err != nil { 70 | t.Fatalf("LoadOrNewVectorIndex: %v", err) 71 | } 72 | if loaded { 73 | t.Fatalf("expected loaded=false for missing file") 74 | } 75 | if idx.Dim != embedder.Dim() { 76 | t.Fatalf("dim mismatch: got %d want %d", idx.Dim, embedder.Dim()) 77 | } 78 | 79 | if err := idx.Upsert("A", ComputeContentHash("a"), []float32{1, 0, 0, 0, 0, 0, 0, 0}); err != nil { 80 | t.Fatalf("Upsert: %v", err) 81 | } 82 | if err := idx.Save(path); err != nil { 83 | t.Fatalf("Save: %v", err) 84 | } 85 | 86 | loadedIdx, loaded2, err := LoadOrNewVectorIndex(path, embedder.Dim()) 87 | if err != nil { 88 | t.Fatalf("LoadOrNewVectorIndex: %v", err) 89 | } 90 | if !loaded2 { 91 | t.Fatalf("expected loaded=true after save") 92 | } 93 | if loadedIdx.Size() != 1 { 94 | t.Fatalf("expected 1 entry, got %d", loadedIdx.Size()) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/e2e/tui_hybrid_search_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "sort" 7 | "testing" 8 | 9 | "github.com/Dicklesworthstone/beads_viewer/pkg/loader" 10 | "github.com/Dicklesworthstone/beads_viewer/pkg/search" 11 | "github.com/Dicklesworthstone/beads_viewer/pkg/ui" 12 | ) 13 | 14 | func TestTUIHybridSearchSmoke(t *testing.T) { 15 | path := filepath.Join("..", "..", "tests", "testdata", "search_hybrid.jsonl") 16 | issues, err := loader.LoadIssuesFromFile(path) 17 | if err != nil { 18 | t.Fatalf("load fixture: %v", err) 19 | } 20 | if len(issues) == 0 { 21 | t.Fatalf("fixture issues empty") 22 | } 23 | 24 | docs := search.DocumentsFromIssues(issues) 25 | embedder := search.NewHashEmbedder(search.DefaultEmbeddingDim) 26 | idx := search.NewVectorIndex(embedder.Dim()) 27 | if _, err := search.SyncVectorIndex(context.Background(), idx, embedder, docs, 64); err != nil { 28 | t.Fatalf("sync index: %v", err) 29 | } 30 | 31 | ids := make([]string, 0, len(issues)) 32 | for _, issue := range issues { 33 | ids = append(ids, issue.ID) 34 | } 35 | sort.Strings(ids) 36 | 37 | cache := search.NewMetricsCache(search.NewAnalyzerMetricsLoader(issues)) 38 | if err := cache.Refresh(); err != nil { 39 | t.Fatalf("refresh metrics: %v", err) 40 | } 41 | 42 | hybrid := ui.NewSemanticSearch() 43 | hybrid.SetIndex(idx, embedder) 44 | hybrid.SetIDs(ids) 45 | hybrid.SetMetricsCache(cache) 46 | hybrid.SetHybridConfig(true, search.PresetImpactFirst) 47 | 48 | ranks := hybrid.ComputeSemanticResults("auth") 49 | if len(ranks) == 0 { 50 | t.Fatalf("expected hybrid ranks") 51 | } 52 | 53 | scores, ok := hybrid.Scores("auth") 54 | if !ok { 55 | t.Fatalf("expected hybrid scores for term") 56 | } 57 | 58 | entries := make([]struct { 59 | id string 60 | score float64 61 | text float64 62 | components map[string]float64 63 | }, 0, len(scores)) 64 | for id, score := range scores { 65 | entries = append(entries, struct { 66 | id string 67 | score float64 68 | text float64 69 | components map[string]float64 70 | }{ 71 | id: id, 72 | score: score.Score, 73 | text: score.TextScore, 74 | components: score.Components, 75 | }) 76 | } 77 | if len(entries) == 0 { 78 | t.Fatalf("expected score entries") 79 | } 80 | 81 | foundComponents := false 82 | for _, entry := range entries { 83 | if entry.components != nil { 84 | foundComponents = true 85 | break 86 | } 87 | } 88 | if !foundComponents { 89 | t.Fatalf("expected hybrid component scores") 90 | } 91 | 92 | sort.Slice(entries, func(i, j int) bool { 93 | if entries[i].score == entries[j].score { 94 | return entries[i].id < entries[j].id 95 | } 96 | return entries[i].score > entries[j].score 97 | }) 98 | 99 | max := 5 100 | if len(entries) < max { 101 | max = len(entries) 102 | } 103 | for i := 0; i < max; i++ { 104 | entry := entries[i] 105 | t.Logf("hybrid[%d] id=%s score=%.4f text=%.4f components=%v", i, entry.id, entry.score, entry.text, entry.components) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /pkg/export/init_and_push_test.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestInitAndPush_UsesForceFallbackOnPushError(t *testing.T) { 13 | if runtime.GOOS == "windows" { 14 | t.Skip("shell script stubs not supported on windows in this test") 15 | } 16 | 17 | binDir := t.TempDir() 18 | 19 | ghScript := `#!/bin/sh 20 | set -eu 21 | if [ "${1-}" = "api" ]; then 22 | # RepoHasContent calls: gh api repos//contents -q length 23 | echo "1" 24 | exit 0 25 | fi 26 | exit 0 27 | ` 28 | gitScript := `#!/bin/sh 29 | set -eu 30 | cmd="${1-}" 31 | shift || true 32 | 33 | case "$cmd" in 34 | remote) 35 | sub="${1-}" 36 | shift || true 37 | case "$sub" in 38 | get-url) 39 | # Pretend there is no existing origin. 40 | exit 1 41 | ;; 42 | remove) 43 | exit 0 44 | ;; 45 | add) 46 | exit 0 47 | ;; 48 | esac 49 | ;; 50 | init) 51 | exit 0 52 | ;; 53 | add) 54 | exit 0 55 | ;; 56 | commit) 57 | echo "nothing to commit" 58 | exit 1 59 | ;; 60 | branch) 61 | echo "already" 62 | exit 1 63 | ;; 64 | push) 65 | # First push uses --force-with-lease when overwriting. 66 | if echo "$*" | grep -q -- "--force-with-lease"; then 67 | echo "cannot be resolved" 68 | exit 1 69 | fi 70 | # Second push retries with --force. 71 | exit 0 72 | ;; 73 | esac 74 | 75 | exit 0 76 | ` 77 | 78 | writeExecutable(t, binDir, "gh", ghScript) 79 | writeExecutable(t, binDir, "git", gitScript) 80 | 81 | origPath := os.Getenv("PATH") 82 | t.Setenv("PATH", fmt.Sprintf("%s%c%s", binDir, os.PathListSeparator, origPath)) 83 | 84 | bundleDir := t.TempDir() 85 | // Ensure the directory contains at least one file for realism. 86 | if err := os.WriteFile(filepath.Join(bundleDir, "index.html"), []byte(""), 0644); err != nil { 87 | t.Fatalf("WriteFile index.html: %v", err) 88 | } 89 | 90 | if err := InitAndPush(bundleDir, "alice/repo", true); err != nil { 91 | t.Fatalf("InitAndPush returned error: %v", err) 92 | } 93 | } 94 | 95 | func TestInitAndPush_RequiresForceOverwriteWhenRepoHasContent(t *testing.T) { 96 | if runtime.GOOS == "windows" { 97 | t.Skip("shell script stubs not supported on windows in this test") 98 | } 99 | 100 | binDir := t.TempDir() 101 | 102 | ghScript := `#!/bin/sh 103 | set -eu 104 | if [ "${1-}" = "api" ]; then 105 | echo "1" 106 | exit 0 107 | fi 108 | exit 0 109 | ` 110 | writeExecutable(t, binDir, "gh", ghScript) 111 | 112 | origPath := os.Getenv("PATH") 113 | t.Setenv("PATH", fmt.Sprintf("%s%c%s", binDir, os.PathListSeparator, origPath)) 114 | 115 | bundleDir := t.TempDir() 116 | if err := InitAndPush(bundleDir, "alice/repo", false); err == nil { 117 | t.Fatal("Expected InitAndPush to return error when repo has content and ForceOverwrite=false") 118 | } else if !strings.Contains(strings.ToLower(err.Error()), "forceoverwrite") { 119 | t.Fatalf("Unexpected InitAndPush error: %v", err) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /pkg/loader/sprint_test.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "github.com/Dicklesworthstone/beads_viewer/pkg/model" 9 | ) 10 | 11 | func TestLoadSprintsMissingFileIsOK(t *testing.T) { 12 | tmp := t.TempDir() 13 | got, err := LoadSprints(tmp) 14 | if err != nil { 15 | t.Fatalf("unexpected error: %v", err) 16 | } 17 | if len(got) != 0 { 18 | t.Fatalf("expected no sprints, got %d", len(got)) 19 | } 20 | } 21 | 22 | func TestParseSprints(t *testing.T) { 23 | now := time.Now().UTC().Truncate(time.Second) 24 | later := now.AddDate(0, 0, 14) 25 | 26 | tests := []struct { 27 | name string 28 | input string 29 | want int 30 | }{ 31 | { 32 | name: "empty input", 33 | input: "", 34 | want: 0, 35 | }, 36 | { 37 | name: "valid sprint", 38 | input: `{"id":"sprint-1","name":"Sprint 1","start_date":"` + now.Format(time.RFC3339) + `","end_date":"` + later.Format(time.RFC3339) + `","bead_ids":["bv-1","bv-2"],"velocity_target":10}`, 39 | want: 1, 40 | }, 41 | { 42 | name: "missing id (skipped)", 43 | input: `{"name":"Sprint 1","start_date":"2025-01-01T00:00:00Z","end_date":"2025-01-14T00:00:00Z"}`, 44 | want: 0, 45 | }, 46 | { 47 | name: "skip malformed json", 48 | input: `{"id":"sprint-1","name":"Sprint 1","start_date":"2025-01-01T00:00:00Z","end_date":"2025-01-14T00:00:00Z"} 49 | {invalid json} 50 | {"id":"sprint-2","name":"Sprint 2","start_date":"2025-01-15T00:00:00Z","end_date":"2025-01-28T00:00:00Z"}`, 51 | want: 2, 52 | }, 53 | } 54 | 55 | for _, tt := range tests { 56 | t.Run(tt.name, func(t *testing.T) { 57 | got, err := ParseSprints(strings.NewReader(tt.input)) 58 | if err != nil { 59 | t.Fatalf("ParseSprints() error = %v", err) 60 | } 61 | if len(got) != tt.want { 62 | t.Fatalf("ParseSprints() got %d sprints, want %d", len(got), tt.want) 63 | } 64 | }) 65 | } 66 | } 67 | 68 | func TestSaveAndLoadSprintsRoundTrip(t *testing.T) { 69 | tmp := t.TempDir() 70 | 71 | start := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC) 72 | end := time.Date(2025, 12, 14, 0, 0, 0, 0, time.UTC) 73 | in := []model.Sprint{ 74 | { 75 | ID: "sprint-1", 76 | Name: "Sprint 1", 77 | StartDate: start, 78 | EndDate: end, 79 | BeadIDs: []string{"bv-1", "bv-2"}, 80 | VelocityTarget: 12.5, 81 | }, 82 | } 83 | 84 | if err := SaveSprints(tmp, in); err != nil { 85 | t.Fatalf("SaveSprints: %v", err) 86 | } 87 | 88 | out, err := LoadSprints(tmp) 89 | if err != nil { 90 | t.Fatalf("LoadSprints: %v", err) 91 | } 92 | if len(out) != 1 { 93 | t.Fatalf("expected 1 sprint, got %d", len(out)) 94 | } 95 | if out[0].ID != in[0].ID || out[0].Name != in[0].Name { 96 | t.Fatalf("mismatch: got %+v want %+v", out[0], in[0]) 97 | } 98 | if !out[0].StartDate.Equal(start) || !out[0].EndDate.Equal(end) { 99 | t.Fatalf("dates mismatch: got %v-%v want %v-%v", out[0].StartDate, out[0].EndDate, start, end) 100 | } 101 | if len(out[0].BeadIDs) != 2 || out[0].BeadIDs[0] != "bv-1" || out[0].BeadIDs[1] != "bv-2" { 102 | t.Fatalf("bead IDs mismatch: %+v", out[0].BeadIDs) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /tests/e2e/robot_search_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "os/exec" 7 | "testing" 8 | ) 9 | 10 | func TestRobotSearchContract(t *testing.T) { 11 | bv := buildBvBinary(t) 12 | env := t.TempDir() 13 | // Use a very distinctive token with many repeats to make hashed-vector ranking stable. 14 | writeBeads(t, env, `{"id":"A","title":"Semantic search target","description":"interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken interstellarkraken","status":"open","priority":1,"issue_type":"task"} 15 | {"id":"B","title":"Unrelated docs","description":"readme changelog docs","status":"open","priority":2,"issue_type":"task"}`) 16 | 17 | cmd := exec.Command(bv, "--search", "interstellarkraken", "--robot-search") 18 | cmd.Dir = env 19 | cmd.Env = append(os.Environ(), 20 | "BV_SEMANTIC_EMBEDDER=hash", 21 | "BV_SEMANTIC_DIM=2048", 22 | ) 23 | out, err := cmd.CombinedOutput() 24 | if err != nil { 25 | t.Fatalf("robot-search failed: %v\n%s", err, out) 26 | } 27 | 28 | var payload struct { 29 | GeneratedAt string `json:"generated_at"` 30 | DataHash string `json:"data_hash"` 31 | Query string `json:"query"` 32 | Provider string `json:"provider"` 33 | Dim int `json:"dim"` 34 | IndexPath string `json:"index_path"` 35 | Loaded bool `json:"loaded"` 36 | Limit int `json:"limit"` 37 | Index struct { 38 | Total int `json:"total"` 39 | Added int `json:"added"` 40 | Updated int `json:"updated"` 41 | Removed int `json:"removed"` 42 | Skipped int `json:"skipped"` 43 | Embedded int `json:"embedded"` 44 | } `json:"index"` 45 | Results []struct { 46 | IssueID string `json:"issue_id"` 47 | Score float64 `json:"score"` 48 | Title string `json:"title"` 49 | } `json:"results"` 50 | UsageHints []string `json:"usage_hints"` 51 | } 52 | if err := json.Unmarshal(out, &payload); err != nil { 53 | t.Fatalf("robot-search json decode: %v\nout=%s", err, out) 54 | } 55 | 56 | if payload.GeneratedAt == "" || payload.DataHash == "" { 57 | t.Fatalf("robot-search missing metadata: generated_at=%q data_hash=%q", payload.GeneratedAt, payload.DataHash) 58 | } 59 | if payload.Query != "interstellarkraken" { 60 | t.Fatalf("unexpected query: %q", payload.Query) 61 | } 62 | if payload.Provider != "hash" { 63 | t.Fatalf("unexpected provider: %q", payload.Provider) 64 | } 65 | if payload.Dim != 2048 { 66 | t.Fatalf("unexpected dim: %d", payload.Dim) 67 | } 68 | if payload.IndexPath == "" { 69 | t.Fatalf("missing index_path") 70 | } 71 | if payload.Limit <= 0 { 72 | t.Fatalf("missing/invalid limit: %d", payload.Limit) 73 | } 74 | if len(payload.Results) == 0 { 75 | t.Fatalf("expected at least one result") 76 | } 77 | if payload.Results[0].IssueID != "A" { 78 | t.Fatalf("expected top match A, got %s (%+v)", payload.Results[0].IssueID, payload.Results) 79 | } 80 | if len(payload.UsageHints) == 0 { 81 | t.Fatalf("expected usage_hints") 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /pkg/export/viewer_assets/hybrid_scorer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * HybridScorer - Client-side graph-aware search ranking 3 | * Mirrors pkg/search/hybrid_scorer_impl.go normalization logic. 4 | */ 5 | 6 | const HYBRID_PRESETS = { 7 | default: { text: 0.40, pagerank: 0.20, status: 0.15, impact: 0.10, priority: 0.10, recency: 0.05 }, 8 | 'bug-hunting': { text: 0.30, pagerank: 0.15, status: 0.15, impact: 0.15, priority: 0.20, recency: 0.05 }, 9 | 'sprint-planning': { text: 0.30, pagerank: 0.20, status: 0.25, impact: 0.15, priority: 0.05, recency: 0.05 }, 10 | 'impact-first': { text: 0.25, pagerank: 0.30, status: 0.10, impact: 0.20, priority: 0.10, recency: 0.05 }, 11 | 'text-only': { text: 1.00, pagerank: 0.00, status: 0.00, impact: 0.00, priority: 0.00, recency: 0.00 } 12 | }; 13 | 14 | class HybridScorer { 15 | constructor(weights = HYBRID_PRESETS.default) { 16 | this.weights = { ...weights }; 17 | this.maxBlockerCount = 0; 18 | } 19 | 20 | score(result) { 21 | const statusScore = this.normalizeStatus(result.status); 22 | const priorityScore = this.normalizePriority(result.priority); 23 | const impactScore = this.normalizeImpact(result.blockerCount || 0); 24 | const recencyScore = this.normalizeRecency(result.updatedAt); 25 | const pagerank = typeof result.pagerank === 'number' ? result.pagerank : 0.5; 26 | 27 | const finalScore = 28 | this.weights.text * result.textScore + 29 | this.weights.pagerank * pagerank + 30 | this.weights.status * statusScore + 31 | this.weights.impact * impactScore + 32 | this.weights.priority * priorityScore + 33 | this.weights.recency * recencyScore; 34 | 35 | return { 36 | ...result, 37 | hybrid_score: finalScore, 38 | component_scores: { 39 | pagerank, 40 | status: statusScore, 41 | impact: impactScore, 42 | priority: priorityScore, 43 | recency: recencyScore, 44 | }, 45 | }; 46 | } 47 | 48 | scoreAndRank(results) { 49 | if (!Array.isArray(results) || results.length === 0) { 50 | return []; 51 | } 52 | const maxBlocker = results.reduce( 53 | (max, r) => Math.max(max, r.blockerCount || 0), 54 | 0 55 | ); 56 | this.maxBlockerCount = maxBlocker; 57 | 58 | return results 59 | .map(r => this.score(r)) 60 | .sort((a, b) => b.hybrid_score - a.hybrid_score); 61 | } 62 | 63 | normalizeStatus(status) { 64 | const STATUS_WEIGHTS = { open: 1.0, in_progress: 0.8, blocked: 0.5, closed: 0.1 }; 65 | return STATUS_WEIGHTS[status] ?? 0.5; 66 | } 67 | 68 | normalizePriority(priority) { 69 | const PRIORITY_WEIGHTS = [1.0, 0.8, 0.6, 0.4, 0.2]; 70 | return PRIORITY_WEIGHTS[priority] ?? 0.5; 71 | } 72 | 73 | normalizeImpact(blockerCount) { 74 | if (this.maxBlockerCount === 0) return 0.5; 75 | if (blockerCount <= 0) return 0; 76 | if (blockerCount >= this.maxBlockerCount) return 1.0; 77 | return blockerCount / this.maxBlockerCount; 78 | } 79 | 80 | normalizeRecency(updatedAt) { 81 | if (!updatedAt) return 0.5; 82 | const daysSince = (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24); 83 | return Math.exp(-daysSince / 30); 84 | } 85 | } 86 | 87 | window.HybridScorer = HybridScorer; 88 | window.HYBRID_PRESETS = HYBRID_PRESETS; 89 | -------------------------------------------------------------------------------- /pkg/search/query_adjust.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "strings" 5 | "unicode" 6 | "unicode/utf8" 7 | ) 8 | 9 | const ( 10 | shortQueryTokenLimit = 2 11 | shortQueryRuneLimit = 12 12 | shortQueryMinTextWeight = 0.55 13 | hybridCandidateMin = 200 14 | hybridCandidateMinShort = 300 15 | hybridCandidateDefaultLimit = 10 16 | ) 17 | 18 | // QueryStats summarizes simple heuristics about a search query. 19 | type QueryStats struct { 20 | Tokens int 21 | Length int 22 | IsShort bool 23 | } 24 | 25 | // AnalyzeQuery returns lightweight stats used to tune hybrid scoring heuristics. 26 | func AnalyzeQuery(query string) QueryStats { 27 | trimmed := strings.TrimSpace(query) 28 | if trimmed == "" { 29 | return QueryStats{Tokens: 0, Length: 0, IsShort: true} 30 | } 31 | tokens := countTokens(trimmed) 32 | length := utf8.RuneCountInString(trimmed) 33 | isShort := tokens <= shortQueryTokenLimit || length <= shortQueryRuneLimit 34 | return QueryStats{Tokens: tokens, Length: length, IsShort: isShort} 35 | } 36 | 37 | // IsShortQuery reports whether the query should favor literal text matching. 38 | func IsShortQuery(query string) bool { 39 | return AnalyzeQuery(query).IsShort 40 | } 41 | 42 | // AdjustWeightsForQuery boosts text relevance for short queries to prevent 43 | // unrelated high-impact items from dominating hybrid ranking. 44 | func AdjustWeightsForQuery(weights Weights, query string) Weights { 45 | if !IsShortQuery(query) { 46 | return weights 47 | } 48 | if weights.TextRelevance >= shortQueryMinTextWeight { 49 | return weights 50 | } 51 | 52 | target := shortQueryMinTextWeight 53 | if target >= 1.0 { 54 | return Weights{TextRelevance: 1.0} 55 | } 56 | 57 | remaining := weights.sum() - weights.TextRelevance 58 | if remaining <= 0 { 59 | return Weights{TextRelevance: 1.0} 60 | } 61 | 62 | scale := (1.0 - target) / remaining 63 | adjusted := Weights{ 64 | TextRelevance: target, 65 | PageRank: weights.PageRank * scale, 66 | Status: weights.Status * scale, 67 | Impact: weights.Impact * scale, 68 | Priority: weights.Priority * scale, 69 | Recency: weights.Recency * scale, 70 | } 71 | return adjusted.Normalize() 72 | } 73 | 74 | // HybridCandidateLimit returns the number of candidates to consider for hybrid re-ranking. 75 | // It widens the candidate pool for short queries to improve recall of literal matches. 76 | func HybridCandidateLimit(limit int, total int, query string) int { 77 | if limit <= 0 { 78 | limit = hybridCandidateDefaultLimit 79 | } 80 | base := limit * 3 81 | min := hybridCandidateMin 82 | if IsShortQuery(query) { 83 | min = hybridCandidateMinShort 84 | } 85 | candidate := base 86 | if candidate < min { 87 | candidate = min 88 | } 89 | if total > 0 && candidate > total { 90 | candidate = total 91 | } 92 | return candidate 93 | } 94 | 95 | func countTokens(query string) int { 96 | inToken := false 97 | count := 0 98 | for _, r := range query { 99 | if unicode.IsLetter(r) || unicode.IsDigit(r) { 100 | if !inToken { 101 | count++ 102 | inToken = true 103 | } 104 | continue 105 | } 106 | inToken = false 107 | } 108 | return count 109 | } 110 | -------------------------------------------------------------------------------- /cmd/bv/burndown_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/Dicklesworthstone/beads_viewer/pkg/model" 8 | ) 9 | 10 | func TestCalculateBurndownAt_OnTrackWithProgress(t *testing.T) { 11 | start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) 12 | end := start.AddDate(0, 0, 4) // inclusive = 5 days 13 | now := start.AddDate(0, 0, 1) // day 2 14 | 15 | closedAt := start.Add(12 * time.Hour) 16 | issues := []model.Issue{ 17 | {ID: "A", Title: "Done", Status: model.StatusClosed, Priority: 1, IssueType: model.TypeTask, ClosedAt: &closedAt}, 18 | {ID: "B", Title: "Remaining", Status: model.StatusOpen, Priority: 1, IssueType: model.TypeTask}, 19 | } 20 | 21 | sprint := &model.Sprint{ 22 | ID: "sprint-1", 23 | Name: "Sprint 1", 24 | StartDate: start, 25 | EndDate: end, 26 | BeadIDs: []string{"A", "B"}, 27 | } 28 | 29 | out := calculateBurndownAt(sprint, issues, now) 30 | 31 | if out.TotalDays != 5 { 32 | t.Fatalf("TotalDays=%d; want 5", out.TotalDays) 33 | } 34 | if out.ElapsedDays != 2 { 35 | t.Fatalf("ElapsedDays=%d; want 2", out.ElapsedDays) 36 | } 37 | if out.RemainingDays != 3 { 38 | t.Fatalf("RemainingDays=%d; want 3", out.RemainingDays) 39 | } 40 | 41 | if out.TotalIssues != 2 || out.CompletedIssues != 1 || out.RemainingIssues != 1 { 42 | t.Fatalf("issues totals mismatch: total=%d completed=%d remaining=%d", out.TotalIssues, out.CompletedIssues, out.RemainingIssues) 43 | } 44 | 45 | if out.ProjectedComplete == nil { 46 | t.Fatalf("ProjectedComplete is nil; want non-nil") 47 | } 48 | wantProjected := now.AddDate(0, 0, 3) // see calculateBurndownAt: int(daysToComplete)+1 49 | if !out.ProjectedComplete.Equal(wantProjected) { 50 | t.Fatalf("ProjectedComplete=%s; want %s", out.ProjectedComplete.UTC().Format(time.RFC3339), wantProjected.Format(time.RFC3339)) 51 | } 52 | if !out.OnTrack { 53 | t.Fatalf("OnTrack=false; want true") 54 | } 55 | 56 | if got, want := len(out.DailyPoints), out.ElapsedDays; got != want { 57 | t.Fatalf("DailyPoints=%d; want %d", got, want) 58 | } 59 | if got, want := len(out.IdealLine), out.TotalDays+1; got != want { 60 | t.Fatalf("IdealLine=%d; want %d", got, want) 61 | } 62 | } 63 | 64 | func TestCalculateBurndownAt_NoProgressSetsOnTrackFalse(t *testing.T) { 65 | start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) 66 | end := start.AddDate(0, 0, 4) 67 | now := start.AddDate(0, 0, 2) // day 3 68 | 69 | issues := []model.Issue{ 70 | {ID: "A", Title: "Open 1", Status: model.StatusOpen, Priority: 1, IssueType: model.TypeTask}, 71 | {ID: "B", Title: "Open 2", Status: model.StatusOpen, Priority: 1, IssueType: model.TypeTask}, 72 | } 73 | 74 | sprint := &model.Sprint{ 75 | ID: "sprint-1", 76 | Name: "Sprint 1", 77 | StartDate: start, 78 | EndDate: end, 79 | BeadIDs: []string{"A", "B"}, 80 | } 81 | 82 | out := calculateBurndownAt(sprint, issues, now) 83 | 84 | if out.ElapsedDays <= 0 { 85 | t.Fatalf("ElapsedDays=%d; want >0", out.ElapsedDays) 86 | } 87 | if out.CompletedIssues != 0 { 88 | t.Fatalf("CompletedIssues=%d; want 0", out.CompletedIssues) 89 | } 90 | if out.ProjectedComplete != nil { 91 | t.Fatalf("ProjectedComplete=%v; want nil", out.ProjectedComplete) 92 | } 93 | if out.OnTrack { 94 | t.Fatalf("OnTrack=true; want false") 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Dicklesworthstone/beads_viewer 2 | 3 | // Keep this in sync with CI (see .github/workflows/ci.yml) and the minimum 4 | // version available in common dev environments. 5 | go 1.25 6 | 7 | toolchain go1.25.5 8 | 9 | require ( 10 | git.sr.ht/~sbinet/gg v0.6.0 11 | github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b 12 | github.com/atotto/clipboard v0.1.4 13 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 14 | github.com/charmbracelet/bubbletea v1.3.10 15 | github.com/charmbracelet/glamour v0.10.0 16 | github.com/charmbracelet/huh v0.8.0 17 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 18 | github.com/fsnotify/fsnotify v1.9.0 19 | github.com/mattn/go-runewidth v0.0.16 20 | golang.org/x/image v0.25.0 21 | golang.org/x/sync v0.16.0 22 | golang.org/x/term v0.31.0 23 | gonum.org/v1/gonum v0.16.0 24 | gopkg.in/yaml.v3 v3.0.1 25 | modernc.org/sqlite v1.40.1 26 | ) 27 | 28 | require ( 29 | github.com/alecthomas/chroma/v2 v2.14.0 // indirect 30 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 31 | github.com/aymerick/douceur v0.2.0 // indirect 32 | github.com/campoy/embedmd v1.0.0 // indirect 33 | github.com/catppuccin/go v0.3.0 // indirect 34 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 35 | github.com/charmbracelet/x/ansi v0.10.1 // indirect 36 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 37 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect 38 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect 39 | github.com/charmbracelet/x/term v0.2.1 // indirect 40 | github.com/dlclark/regexp2 v1.11.0 // indirect 41 | github.com/dustin/go-humanize v1.0.1 // indirect 42 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 43 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 44 | github.com/google/uuid v1.6.0 // indirect 45 | github.com/gorilla/css v1.0.1 // indirect 46 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 47 | github.com/mattn/go-isatty v0.0.20 // indirect 48 | github.com/mattn/go-localereader v0.0.1 // indirect 49 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect 50 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 51 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 52 | github.com/muesli/cancelreader v0.2.2 // indirect 53 | github.com/muesli/reflow v0.3.0 // indirect 54 | github.com/muesli/termenv v0.16.0 // indirect 55 | github.com/ncruces/go-strftime v0.1.9 // indirect 56 | github.com/pmezard/go-difflib v1.0.0 // indirect 57 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 58 | github.com/rivo/uniseg v0.4.7 // indirect 59 | github.com/sahilm/fuzzy v0.1.1 // indirect 60 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 61 | github.com/yuin/goldmark v1.7.8 // indirect 62 | github.com/yuin/goldmark-emoji v1.0.5 // indirect 63 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 64 | golang.org/x/net v0.38.0 // indirect 65 | golang.org/x/sys v0.36.0 // indirect 66 | golang.org/x/text v0.24.0 // indirect 67 | modernc.org/libc v1.66.10 // indirect 68 | modernc.org/mathutil v1.7.1 // indirect 69 | modernc.org/memory v1.11.0 // indirect 70 | ) 71 | -------------------------------------------------------------------------------- /cmd/bv/main_robot_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | // TestRobotPlanAndPriorityIncludeMetadata runs the built binary against a tiny fixture project 12 | // to assert that robot-plan and robot-priority include data_hash, analysis_config, and status. 13 | func TestRobotPlanAndPriorityIncludeMetadata(t *testing.T) { 14 | dir := t.TempDir() 15 | // create minimal .beads directory with beads.jsonl 16 | beadsDir := filepath.Join(dir, ".beads") 17 | if err := os.MkdirAll(beadsDir, 0o755); err != nil { 18 | t.Fatalf("mkdir beads: %v", err) 19 | } 20 | beads := `{"id":"TEST-1","title":"A","status":"open","priority":1,"issue_type":"task"} 21 | {"id":"TEST-2","title":"B","status":"open","priority":2,"issue_type":"task","dependencies":[{"issue_id":"TEST-2","depends_on_id":"TEST-1","type":"blocks"}]} 22 | ` 23 | if err := os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(beads), 0o644); err != nil { 24 | t.Fatalf("write beads: %v", err) 25 | } 26 | 27 | exe := buildTestBinary(t) 28 | 29 | runAndCheck := func(flag string) { 30 | cmd := exec.Command(exe, flag) 31 | cmd.Dir = dir 32 | out, err := cmd.Output() 33 | if err != nil { 34 | t.Fatalf("%s failed: %v, out=%s", flag, err, string(out)) 35 | } 36 | var payload map[string]any 37 | if err := json.Unmarshal(out, &payload); err != nil { 38 | t.Fatalf("%s json: %v", flag, err) 39 | } 40 | if _, ok := payload["data_hash"]; !ok { 41 | t.Fatalf("%s missing data_hash", flag) 42 | } 43 | if _, ok := payload["analysis_config"]; !ok { 44 | t.Fatalf("%s missing analysis_config", flag) 45 | } 46 | statusAny, ok := payload["status"] 47 | if !ok { 48 | t.Fatalf("%s missing status", flag) 49 | } 50 | 51 | status, ok := statusAny.(map[string]any) 52 | if !ok { 53 | t.Fatalf("%s status not an object", flag) 54 | } 55 | 56 | // Ensure the status contract is usable at process exit (no pending/empty states). 57 | expected := []string{"PageRank", "Betweenness", "Eigenvector", "HITS", "Critical", "Cycles", "KCore", "Articulation", "Slack"} 58 | for _, metric := range expected { 59 | entryAny, ok := status[metric] 60 | if !ok { 61 | t.Fatalf("%s status missing %s", flag, metric) 62 | } 63 | entry, ok := entryAny.(map[string]any) 64 | if !ok { 65 | t.Fatalf("%s status.%s not an object", flag, metric) 66 | } 67 | stateAny, ok := entry["state"] 68 | if !ok { 69 | t.Fatalf("%s status.%s missing state", flag, metric) 70 | } 71 | state, _ := stateAny.(string) 72 | if state == "" { 73 | t.Fatalf("%s status.%s state empty", flag, metric) 74 | } 75 | if state == "pending" { 76 | t.Fatalf("%s status.%s still pending at exit", flag, metric) 77 | } 78 | } 79 | } 80 | 81 | runAndCheck("--robot-plan") 82 | runAndCheck("--robot-priority") 83 | } 84 | 85 | // buildTestBinary builds the current module's bv binary for testing. 86 | func buildTestBinary(t *testing.T) string { 87 | t.Helper() 88 | exe := filepath.Join(t.TempDir(), "bv-testbin") 89 | cmd := exec.Command("go", "build", "-o", exe, ".") 90 | cmd.Dir = "." // build current package 91 | cmd.Env = append(os.Environ(), "CGO_ENABLED=0") 92 | if out, err := cmd.CombinedOutput(); err != nil { 93 | t.Fatalf("build bv: %v, out=%s", err, string(out)) 94 | } 95 | return exe 96 | } 97 | -------------------------------------------------------------------------------- /pkg/hooks/runhooks_test.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func writeHooksFile(t *testing.T, dir, content string) { 11 | bv := filepath.Join(dir, ".bv") 12 | if err := os.MkdirAll(bv, 0o755); err != nil { 13 | t.Fatalf("mkdir .bv: %v", err) 14 | } 15 | path := filepath.Join(bv, "hooks.yaml") 16 | if err := os.WriteFile(path, []byte(content), 0o644); err != nil { 17 | t.Fatalf("write hooks.yaml: %v", err) 18 | } 19 | } 20 | 21 | func TestRunHooksNoHooksFlagAndMissingConfig(t *testing.T) { 22 | tmp := t.TempDir() 23 | exec, err := RunHooks(tmp, ExportContext{}, true) 24 | if err != nil || exec != nil { 25 | t.Fatalf("noHooks should short-circuit, got exec=%v err=%v", exec, err) 26 | } 27 | 28 | exec, err = RunHooks(tmp, ExportContext{}, false) 29 | if err != nil || exec != nil { 30 | t.Fatalf("missing config should return nil executor without error, got exec=%v err=%v", exec, err) 31 | } 32 | } 33 | 34 | func TestRunHooksLoadsExecutor(t *testing.T) { 35 | tmp := t.TempDir() 36 | writeHooksFile(t, tmp, ` 37 | hooks: 38 | pre-export: 39 | - name: hello 40 | command: echo hi 41 | `) 42 | 43 | ctx := ExportContext{ExportPath: "out.md", ExportFormat: "markdown", IssueCount: 1, Timestamp: time.Now()} 44 | exec, err := RunHooks(tmp, ctx, false) 45 | if err != nil { 46 | t.Fatalf("RunHooks returned error: %v", err) 47 | } 48 | if exec == nil { 49 | t.Fatalf("expected executor when hooks present") 50 | } 51 | 52 | // Config() should return same pointer after load 53 | if exec.config == nil || len(exec.config.Hooks.PreExport) != 1 { 54 | t.Fatalf("executor config not initialized correctly") 55 | } 56 | 57 | if res := exec.Results(); len(res) != 0 { 58 | t.Fatalf("results should be empty before runs: %v", res) 59 | } 60 | } 61 | 62 | func TestLoadDefaultUsesCWD(t *testing.T) { 63 | tmp := t.TempDir() 64 | writeHooksFile(t, tmp, "hooks:\n post-export:\n - command: echo ok\n") 65 | orig, _ := os.Getwd() 66 | defer os.Chdir(orig) 67 | if err := os.Chdir(tmp); err != nil { 68 | t.Fatalf("chdir: %v", err) 69 | } 70 | 71 | loader, err := LoadDefault() 72 | if err != nil { 73 | t.Fatalf("LoadDefault error: %v", err) 74 | } 75 | if !loader.HasHooks() { 76 | t.Fatalf("expected hooks loaded via cwd") 77 | } 78 | } 79 | 80 | func TestRunPostExportFailStopsOnFail(t *testing.T) { 81 | config := &Config{Hooks: HooksByPhase{PostExport: []Hook{ 82 | {Name: "fail", Command: "exit 1", Timeout: 200 * time.Millisecond, OnError: "fail"}, 83 | {Name: "skip", Command: "echo ok", Timeout: 200 * time.Millisecond, OnError: "continue"}, 84 | }}} 85 | 86 | exec := NewExecutor(config, ExportContext{}) 87 | err := exec.RunPostExport() 88 | if err == nil { 89 | t.Fatalf("expected error when post-export hook fails with on_error=fail") 90 | } 91 | if len(exec.Results()) != 2 { 92 | t.Fatalf("expected both hooks recorded, got %d", len(exec.Results())) 93 | } 94 | } 95 | 96 | func TestTruncateBehaviour(t *testing.T) { 97 | if got := truncate("short", 10); got != "short" { 98 | t.Fatalf("truncate should return original when shorter, got %q", got) 99 | } 100 | if got := truncate("abcdefghijklmnopqrstuvwxyz", 8); got != "abcde..." { 101 | t.Fatalf("unexpected truncation output: %q", got) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/testdata/real/srps.jsonl: -------------------------------------------------------------------------------- 1 | {"id":"system_resource_protection_script-e5e","content_hash":"112ca52b1c58d0b8668dfbe8bc5e08461332206b4f6c4b89b58c2a9fff7fa770","title":"Sysmon Go TUI rewrite","description":"Rebuild sysmon as a robust Go/Bubble Tea TUI with static binary, JSON parity, and installer integration. Goals: eliminate bash/awk fragility, add world-class terminal UX, keep zero-deps install path.","status":"open","priority":2,"issue_type":"epic","created_at":"2025-11-23T19:39:23.743465958Z","updated_at":"2025-11-23T19:39:23.743465958Z","source_repo":".","labels":["sysmon","tui"],"comments":[{"id":1,"issue_id":"system_resource_protection_script-e5e","author":"ubuntu","text":"Decision: Go/BubbleTea implementation approved with strict UX constraint—end users never compile. Installer will fetch prebuilt static binary; legacy bash sysmon kept as fallback. Maintainers handle toolchain in CI/toolbox. Goal is world-class TUI; fallback ensures zero-regression for users without Go.","created_at":"2025-11-23T19:42:44Z"},{"id":6,"issue_id":"system_resource_protection_script-e5e","author":"ubuntu","text":"Scaffold \u0026 model done: Go module + BubbleTea stub + CI go job landed (commits 2a3917f, c37e6c7). Next: implement real samplers, rich UI, installer binary drop/fallback.","created_at":"2025-11-23T20:09:35Z"}]} 2 | {"id":"system_resource_protection_script-e5e.1","content_hash":"da475411702eb0e068e31d3b8c4573016d8f24938b08bae322ba73640c626b4f","title":"Scaffold Go/BubbleTea sysmon app and CI","description":"Init Go module; add cmd/sysmon skeleton; wire Bubble Tea main loop; add Go toolchain \u0026 cache to CI.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-23T19:39:32.153124861Z","updated_at":"2025-11-23T20:08:56.412413238Z","closed_at":"2025-11-23T20:08:56.412413238Z","source_repo":".","labels":["sysmon","tui"],"comments":[{"id":2,"issue_id":"system_resource_protection_script-e5e.1","author":"ubuntu","text":"Note constraint: deliver static binary via installer; users never build. Keep bash sysmon as fallback shim; same CLI/env flags. Toolchain lives in CI/toolbox only.","created_at":"2025-11-23T19:42:50Z"},{"id":4,"issue_id":"system_resource_protection_script-e5e.1","author":"ubuntu","text":"Completed: go.mod, cmd/sysmon BubbleTea stub, internal config/model/sampler/ui scaffolds, go CI job (setup-go + go test/build).","created_at":"2025-11-23T20:08:53Z"}]} 3 | {"id":"system_resource_protection_script-e5e.2","content_hash":"50620e954004f03af21ccbbf532e15a98892838a7a8c8cc0074a095451831a11","title":"Define sample data model, config, and history buffers","description":"Design typed Sample struct (cpu/mem/load/per-core/io/net/gpu/batt/cgroups/procs/timestamp); env/flag config; ring buffers for sparklines; shared between UI and JSON.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-23T19:39:37.222885142Z","updated_at":"2025-11-23T20:09:06.18485067Z","closed_at":"2025-11-23T20:09:06.18485067Z","source_repo":".","labels":["sysmon","tui"],"comments":[{"id":3,"issue_id":"system_resource_protection_script-e5e.2","author":"ubuntu","text":"Shared model/config must support both TUI and JSON export; installer must not force Go on users—binary download required; bash fallback retained.","created_at":"2025-11-23T19:42:53Z"},{"id":5,"issue_id":"system_resource_protection_script-e5e.2","author":"ubuntu","text":"Completed: defined typed Sample (cpu/mem/io/gpu/batt/process/cgroup/inotify/temps), config parser with env/flags, zero initializer; sampler stub emits intervalled samples.","created_at":"2025-11-23T20:09:00Z"}]} 4 | --------------------------------------------------------------------------------