Skip to content

Commit 0d4b979

Browse files
authored
feat: JSON schema contract, bd ping, structured errors, import enhancements (gastownhall#3368)
* feat: add schema_version to all --json output (beads-5uh) All bd commands that emit --json output now include a schema_version field so consumers (Jawnt MCP, BeadsX, gt mail, sync scripts) can detect format changes without silently breaking. - Object output: schema_version injected as top-level field - Array output: wrapped as {"schema_version": 1, "items": [...]} - Error output: schema_version added to stderr JSON Central implementation in outputJSON() via wrapWithSchemaVersion() means all 272 call sites get the field automatically. Includes: - Unit tests for wrapping logic (object, struct, slice, nil, roundtrip) - Updated protocol contract tests for new envelope format - New schema_version presence test across list/ready/show - docs/JSON_SCHEMA.md documenting the field contract per command * feat: add ready/blocked JSON contract tests (beads-clt) bd ready --json and bd blocked --json already return full issue objects with dependency counts and blocked_by fields. Add protocol contract tests to pin this behavior: - TestJSONContract_ReadyOutputHasFullObjects: verifies dependency_count, dependent_count fields on ready items - TestJSONContract_BlockedOutputHasBlockedBy: verifies blocked_by and blocked_by_count fields on blocked items Update docs/JSON_SCHEMA.md with blocked output contract. * feat: add bd ping lightweight health-check command (beads-8cc) New minimal command that confirms bd can reach its database: 1. Resolves the .beads workspace 2. Opens the store (embedded or server) 3. Runs a trivial query (SearchIssues with Limit=1) 4. Reports timing breakdown Supports --json for structured output with resolve_ms, store_ms, query_ms, and total_ms fields. Exit 0 on success, 1 on failure. Registered as read-only command (no auto-push, no file writes). Includes protocol contract test for JSON output. * feat: structured JSON errors on stderr when --json active (beads-06n) All fatal error functions (FatalError, FatalErrorWithHint, FatalErrorRespectJSON, FatalErrorWithHintRespectJSON) now emit structured JSON with schema_version when --json is active: {"schema_version": 1, "error": "message", "hint": "optional"} Centralized via jsonStderrError() and jsonStdoutError() helpers. Replaces ad-hoc json.MarshalIndent calls scattered across error functions with a consistent, schema-versioned format. Includes unit tests and updated protocol contract test verifying schema_version presence in error JSON output. * feat: enhance bd import with stdin, --dedup, --json (beads-c6i) Enhance the existing bd import command for bulk JSONL ingest: - Stdin support: bd import - reads JSONL from stdin for piped workflows - --dedup flag: skips lines whose title matches an existing open issue (case-insensitive) to prevent duplicate creation - --json flag: structured output with created/skipped counts and IDs Refactored import to use runImportFromReader() for unified file/stdin handling. Added filterDuplicatesByTitle() for dedup logic. Updated docs/JSON_SCHEMA.md with import output contract. Backward compatible: existing bd import [file] and --dry-run work exactly as before. * fix: update tests for schema_version envelope in JSON output Three test files expected raw JSON arrays or map[string]string, which broke when schema_version (a number) was added to the output envelope: - ado_test.go: parse envelope with items array instead of raw []json.RawMessage - bootstrap_embedded_test.go: use map[string]interface{} for mixed types - where_embedded_test.go: same map[string]interface{} fix * fix: drop array envelope wrapping to fix 200+ test failures The schema_version envelope was wrapping arrays as {"schema_version": N, "items": [...]} which broke every integration test that parses --json array output. Objects (show, create, ping) still get schema_version injected. Arrays (list, ready, blocked) now pass through unchanged for backwards compatibility. Also fixes: gofmt on import.go, doc-flags reference to bd import, uses importIssuesCore result to satisfy unparam lint. * fix: update remaining tests for schema_version number field config_embedded_test.go, kv_embedded_test.go, config_nodb_embedded_test.go, and show_test.go all parsed JSON output into map[string]string but schema_version is a number. Parse to map[string]interface{} and skip/assert schema_version appropriately. --------- Co-authored-by: kev <kglynn@pryoninc.com>
1 parent d0f0ad6 commit 0d4b979

16 files changed

Lines changed: 826 additions & 146 deletions

cmd/bd/bootstrap_embedded_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,18 @@ func TestBootstrapNoWorkspace(t *testing.T) {
6868
t.Fatalf("expected JSON object in output, got: %s", out)
6969
}
7070

71-
var payload map[string]string
71+
var payload map[string]interface{}
7272
if err := json.Unmarshal([]byte(s[start:]), &payload); err != nil {
7373
t.Fatalf("parse bootstrap JSON: %v\n%s", err, s)
7474
}
75-
if payload["action"] != "none" {
76-
t.Fatalf("action = %q, want %q", payload["action"], "none")
75+
if action, _ := payload["action"].(string); action != "none" {
76+
t.Fatalf("action = %q, want %q", action, "none")
7777
}
78-
if payload["reason"] != activeWorkspaceNotFoundError() {
79-
t.Fatalf("reason = %q, want %q", payload["reason"], activeWorkspaceNotFoundError())
78+
if reason, _ := payload["reason"].(string); reason != activeWorkspaceNotFoundError() {
79+
t.Fatalf("reason = %q, want %q", reason, activeWorkspaceNotFoundError())
8080
}
81-
if !strings.Contains(payload["suggestion"], "bd where") {
82-
t.Fatalf("suggestion should mention bd where, got: %q", payload["suggestion"])
81+
if suggestion, _ := payload["suggestion"].(string); !strings.Contains(suggestion, "bd where") {
82+
t.Fatalf("suggestion should mention bd where, got: %q", suggestion)
8383
}
8484
})
8585
}

cmd/bd/config_embedded_test.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,19 @@ func bdConfigListJSON(t *testing.T, bd, dir string) map[string]string {
5555
if start < 0 {
5656
t.Fatalf("no JSON object in config list output: %s", s)
5757
}
58-
var m map[string]string
59-
if err := json.Unmarshal([]byte(s[start:]), &m); err != nil {
58+
var raw map[string]interface{}
59+
if err := json.Unmarshal([]byte(s[start:]), &raw); err != nil {
6060
t.Fatalf("parse config list JSON: %v\n%s", err, s)
6161
}
62+
m := make(map[string]string, len(raw))
63+
for k, v := range raw {
64+
if k == "schema_version" {
65+
continue
66+
}
67+
if sv, ok := v.(string); ok {
68+
m[k] = sv
69+
}
70+
}
6271
return m
6372
}
6473

cmd/bd/config_nodb_embedded_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,14 @@ func TestEmbeddedConfigValidateJSONNoWorkspaceWritesStdout(t *testing.T) {
152152
t.Fatalf("expected JSON error on stdout only, got stderr:\n%s", stderr)
153153
}
154154

155-
var payload map[string]string
155+
var payload map[string]interface{}
156156
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
157157
t.Fatalf("parse config validate --json output: %v\nstdout:\n%s", err, stdout)
158158
}
159-
if payload["error"] != activeWorkspaceNotFoundError() {
160-
t.Fatalf("error = %q, want %q", payload["error"], activeWorkspaceNotFoundError())
159+
if errField, _ := payload["error"].(string); errField != activeWorkspaceNotFoundError() {
160+
t.Fatalf("error = %q, want %q", errField, activeWorkspaceNotFoundError())
161161
}
162-
if !strings.Contains(payload["hint"], "bd where") {
163-
t.Fatalf("expected hint to mention bd where, got %q", payload["hint"])
162+
if hint, _ := payload["hint"].(string); !strings.Contains(hint, "bd where") {
163+
t.Fatalf("expected hint to mention bd where, got %q", hint)
164164
}
165165
}

cmd/bd/errors.go

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,37 @@ func workspaceDiagHint(includeWhere bool) string {
3838
return "check BEADS_DIR/worktree setup, run 'bd doctor' to diagnose, or run 'bd init' to create a new database"
3939
}
4040

41+
// jsonStderrError writes a structured JSON error to stderr when --json is active.
42+
// All JSON errors include schema_version for consumer compatibility.
43+
func jsonStderrError(message, hint string) {
44+
obj := map[string]interface{}{
45+
"schema_version": JSONSchemaVersion,
46+
"error": message,
47+
}
48+
if hint != "" {
49+
obj["hint"] = hint
50+
}
51+
encoder := json.NewEncoder(os.Stderr)
52+
encoder.SetIndent("", " ")
53+
_ = encoder.Encode(obj)
54+
}
55+
56+
// jsonStdoutError writes a structured JSON error to stdout when --json is active.
57+
// Used by FatalErrorRespectJSON and FatalErrorWithHintRespectJSON where
58+
// callers expect errors on stdout (e.g., bd show nonexistent-id --json).
59+
func jsonStdoutError(message, hint string) {
60+
obj := map[string]interface{}{
61+
"schema_version": JSONSchemaVersion,
62+
"error": message,
63+
}
64+
if hint != "" {
65+
obj["hint"] = hint
66+
}
67+
encoder := json.NewEncoder(os.Stdout)
68+
encoder.SetIndent("", " ")
69+
_ = encoder.Encode(obj)
70+
}
71+
4172
// FatalError writes an error message to stderr and exits with code 1.
4273
// Use this for fatal errors that prevent the command from completing.
4374
//
@@ -54,8 +85,7 @@ func workspaceDiagHint(includeWhere bool) string {
5485
func FatalError(format string, args ...interface{}) {
5586
msg := fmt.Sprintf(format, args...)
5687
if jsonOutput {
57-
data, _ := json.MarshalIndent(map[string]string{"error": msg}, "", " ")
58-
fmt.Fprintln(os.Stderr, string(data))
88+
jsonStderrError(msg, "")
5989
} else {
6090
fmt.Fprintf(os.Stderr, "Error: %s\n", msg)
6191
}
@@ -76,8 +106,7 @@ func FatalError(format string, args ...interface{}) {
76106
func FatalErrorRespectJSON(format string, args ...interface{}) {
77107
msg := fmt.Sprintf(format, args...)
78108
if jsonOutput {
79-
data, _ := json.MarshalIndent(map[string]string{"error": msg}, "", " ") // json.MarshalIndent on simple maps does not fail in practice
80-
fmt.Println(string(data))
109+
jsonStdoutError(msg, "")
81110
} else {
82111
fmt.Fprintf(os.Stderr, "Error: %s\n", msg)
83112
}
@@ -88,7 +117,7 @@ func FatalErrorRespectJSON(format string, args ...interface{}) {
88117
// If --json is set, emits structured JSON to stdout so callers can parse it.
89118
func FatalErrorWithHintRespectJSON(message, hint string) {
90119
if jsonOutput {
91-
outputJSON(map[string]string{"error": message, "hint": hint})
120+
jsonStdoutError(message, hint)
92121
} else {
93122
fmt.Fprintf(os.Stderr, "Error: %s\n", message)
94123
fmt.Fprintf(os.Stderr, "Hint: %s\n", hint)
@@ -104,8 +133,7 @@ func FatalErrorWithHintRespectJSON(message, hint string) {
104133
// FatalErrorWithHint("database not found", "Run 'bd init' to create a database")
105134
func FatalErrorWithHint(message, hint string) {
106135
if jsonOutput {
107-
data, _ := json.MarshalIndent(map[string]string{"error": message, "hint": hint}, "", " ")
108-
fmt.Fprintln(os.Stderr, string(data))
136+
jsonStderrError(message, hint)
109137
} else {
110138
fmt.Fprintf(os.Stderr, "Error: %s\n", message)
111139
fmt.Fprintf(os.Stderr, "Hint: %s\n", hint)

cmd/bd/errors_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
)
7+
8+
func TestJsonStderrError_StructuredOutput(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
message string
12+
hint string
13+
}{
14+
{"message_only", "database not found", ""},
15+
{"message_with_hint", "database not found", "Run 'bd init' to create one"},
16+
}
17+
18+
for _, tt := range tests {
19+
t.Run(tt.name, func(t *testing.T) {
20+
obj := map[string]interface{}{
21+
"schema_version": JSONSchemaVersion,
22+
"error": tt.message,
23+
}
24+
if tt.hint != "" {
25+
obj["hint"] = tt.hint
26+
}
27+
28+
data, err := json.Marshal(obj)
29+
if err != nil {
30+
t.Fatalf("marshal: %v", err)
31+
}
32+
33+
var parsed map[string]interface{}
34+
if err := json.Unmarshal(data, &parsed); err != nil {
35+
t.Fatalf("unmarshal: %v", err)
36+
}
37+
38+
if parsed["schema_version"] != float64(JSONSchemaVersion) {
39+
t.Errorf("schema_version = %v, want %d", parsed["schema_version"], JSONSchemaVersion)
40+
}
41+
if parsed["error"] != tt.message {
42+
t.Errorf("error = %v, want %s", parsed["error"], tt.message)
43+
}
44+
if tt.hint != "" {
45+
if parsed["hint"] != tt.hint {
46+
t.Errorf("hint = %v, want %s", parsed["hint"], tt.hint)
47+
}
48+
} else {
49+
if _, ok := parsed["hint"]; ok {
50+
t.Errorf("hint should not be present when empty")
51+
}
52+
}
53+
})
54+
}
55+
}

0 commit comments

Comments
 (0)