├── .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("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 |
--------------------------------------------------------------------------------