#![allow(dead_code)]
use std::path::Path;
use serde::Serialize;
use super::exit_codes::DoctorExitCode;
#[derive(Debug, Clone, Serialize)]
pub struct ExitCodeEntry {
pub code: i32,
pub name: &'static str,
pub description: &'static str,
}
#[derive(Debug, Clone, Serialize)]
pub struct DoctorCapabilities {
pub schema: &'static str,
pub contract_version: &'static str,
pub doctor_version: &'static str,
pub exit_codes: Vec<ExitCodeEntry>,
pub write_scopes: Vec<String>,
pub env_vars: Vec<&'static str>,
pub fixers: Vec<FixerEntry>,
pub detectors: Vec<DetectorEntry>,
pub ops_supported: Vec<OpEntry>,
pub finding_id_map: Vec<FindingIdEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FindingIdEntry {
pub check_name: &'static str,
pub finding_id: &'static str,
}
#[derive(Debug, Clone, Serialize)]
pub struct OpEntry {
pub name: &'static str,
pub summary: &'static str,
pub params: Vec<&'static str>,
pub fully_routed: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct FixerEntry {
pub id: String,
pub subsystem: String,
pub auto_fixable: bool,
pub mutates: bool,
pub addressed_findings: Vec<String>,
pub filter_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DetectorEntry {
pub id: String,
pub subsystem: String,
pub severity_default: String,
pub fast_path: bool,
}
impl DoctorCapabilities {
#[must_use]
pub fn build() -> Self {
let exit_codes = DoctorExitCode::all()
.iter()
.map(|code| ExitCodeEntry {
code: code.as_i32(),
name: code.as_str(),
description: code.description(),
})
.collect();
Self {
schema: "br.doctor.capabilities",
contract_version: "1",
doctor_version: env!("CARGO_PKG_VERSION"),
exit_codes,
write_scopes: vec![".beads/".into(), ".doctor/".into()],
env_vars: vec![
super::run_dir::ENV_RUNS_DIR,
"BR_NO_AUTOFLUSH",
"BD_NO_AUTOFLUSH",
"RUST_LOG",
"BD_DB",
"BEADS_JSONL",
],
fixers: build_fixer_registry(),
detectors: build_detector_registry(),
ops_supported: vec![
OpEntry {
name: "write_file",
summary: "Atomic create-or-overwrite via tmpfile + rename(2).",
params: vec!["content", "mode"],
fully_routed: true,
},
OpEntry {
name: "append_file",
summary: "Append-only write; creates the file if missing.",
params: vec!["content"],
fully_routed: true,
},
OpEntry {
name: "rename",
summary: "Move a path within write_scopes (no Op::Delete by design).",
params: vec!["to"],
fully_routed: true,
},
OpEntry {
name: "chmod",
summary: "Set the mode bits of a path.",
params: vec!["mode"],
fully_routed: true,
},
OpEntry {
name: "symlink_atomic",
summary: "Replace the symlink at path with one pointing at target.",
params: vec!["target"],
fully_routed: true,
},
OpEntry {
name: "db_exec",
summary: "Run a parameterized SQL statement inside BEGIN IMMEDIATE; \
snapshots affected rows to backups/db/ before COMMIT.",
params: vec!["sql", "args", "affected_tables", "affected_predicate"],
fully_routed: true,
},
OpEntry {
name: "db_migrate",
summary: "Versioned schema migration. Verifies PRAGMA user_version \
matches `from`, snapshots the DB file verbatim to \
backups/db/beads.db.pre-migrate, then drives \
schema::run_migrations_atomic inside BEGIN IMMEDIATE / COMMIT \
(rolls back + restores from snapshot on failure).",
params: vec!["from", "to"],
fully_routed: true,
},
],
finding_id_map: build_finding_id_map(),
}
}
pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
#[must_use]
pub fn build_for_repo(_repo_root: &Path) -> Self {
Self::build()
}
}
type DetectorRow = (&'static str, &'static str, &'static str, bool);
const DETECTOR_ROWS: &[DetectorRow] = &[
("beads_dir", "configs", "error", true),
("metadata", "configs", "error", true),
("gitignore.beads_inner", "configs", "warn", true),
("gitignore.root", "configs", "warn", true),
("routes_jsonl", "routes_external", "warn", true),
("routes.targets", "routes_external", "warn", true),
("rust_log", "observability", "warn", true),
("permissions.beads_dir", "permissions", "warn", true),
("config.yaml", "configs", "warn", true),
("config.unknown_keys", "configs", "warn", true),
("metadata.json", "configs", "warn", true),
("binary_version", "external_artifacts", "warn", true),
("write_lock", "concurrency_primitives", "warn", true),
("jsonl.parse", "state_files", "error", true),
("jsonl.merge_artifacts", "state_files", "warn", true),
("base_jsonl", "state_files", "warn", true),
(
"base_jsonl.missing_post_flush",
"state_files",
"warn",
false,
),
("dirty_bitmap", "caches_indexes", "warn", false),
("doctor.runs_dir", "observability", "warn", true),
("doctor.runs_creatable", "permissions", "warn", true),
("permissions.recovery_dir", "permissions", "warn", true),
("permissions.write_lock", "permissions", "warn", true),
("permissions.root_gitignore", "permissions", "warn", true),
("permissions.db_sidecars", "permissions", "warn", true),
("jsonl.duplicate_ids", "state_files", "warn", false),
("comments.orphans", "caches_indexes", "warn", false),
("labels.orphans", "caches_indexes", "warn", false),
("dependencies.orphans", "caches_indexes", "warn", false),
(
"permissions.config_yaml_secrets",
"permissions",
"warn",
true,
),
("br_path_dupes", "external_artifacts", "warn", true),
("gitignore.beads_inner_present", "configs", "warn", true),
(
"permissions.jsonl_world_writable",
"permissions",
"warn",
true,
),
("tmp_files_orphan", "state_files", "warn", true),
("jsonl_size", "state_files", "warn", true),
("br_history.size", "state_files", "warn", true),
("jsonl_eof_newline", "state_files", "warn", true),
("jsonl_crlf", "state_files", "warn", true),
("jsonl_bom", "state_files", "warn", true),
("db_bloat", "caches_indexes", "warn", true),
("wal_size", "state_files", "warn", true),
("startup_cache.health", "configs", "warn", true),
("sync_jsonl_path", "state_files", "warn", true),
("sync_conflict_markers", "state_files", "error", true),
("db.exists", "state_files", "error", true),
("db.open", "state_files", "error", true),
("db.sidecars", "state_files", "error", true),
(
"db.read_only_open_observational",
"state_files",
"warn",
true,
),
("db.recovery_artifacts", "state_files", "info", true),
("db.recovery_artifacts.aged", "state_files", "warn", true),
("db.foreign_recovery_debris", "state_files", "info", true),
("db.export_hash_cache", "caches_indexes", "warn", true),
("schema.tables", "schemas", "error", true),
("schema.columns", "schemas", "error", true),
("schema.inspect", "schemas", "error", true),
("db.null_defaults", "schemas", "warn", true),
("sqlite.integrity_check", "state_files", "error", true),
("db.recoverable_anomalies", "caches_indexes", "warn", false),
("counts.db_vs_jsonl", "state_files", "warn", false),
("sync.metadata", "state_files", "warn", false),
("sqlite3.integrity_check", "state_files", "error", false),
("db.write_probe", "state_files", "warn", false),
(
"audit.suspect_close_reasons",
"agent_coordination",
"warn",
true,
),
];
fn build_detector_registry() -> Vec<DetectorEntry> {
DETECTOR_ROWS
.iter()
.map(|(id, subsystem, sev, fast)| DetectorEntry {
id: (*id).to_string(),
subsystem: (*subsystem).to_string(),
severity_default: (*sev).to_string(),
fast_path: *fast,
})
.collect()
}
type FixerRow = (
&'static str,
&'static str,
bool,
bool,
&'static [&'static str],
&'static [&'static str],
);
const EARLY_CHOKEPOINT_FIXER_ROWS: &[FixerRow] = &[
(
"doctor.gitignore_repair",
"configs",
true,
true,
&["gitignore.beads_inner", "gitignore.root"],
&["fm-configs-gitignore-leaking-beads"],
),
(
"doctor.merge_artifact_quarantine",
"state_files",
true,
true,
&["jsonl.merge_artifacts"],
&["fm-state_files-merge-artifact-stuck"],
),
(
"doctor.startup_cache_quarantine",
"configs",
true,
true,
&["startup_cache.health"],
&["fm-configs-startup-cache-poisoned"],
),
(
"doctor.recovery_artifacts_aged_quarantine",
"state_files",
true,
true,
&["db.recovery_artifacts.aged"],
&["fm-state_files-recovery-artifacts-orphaned"],
),
(
"doctor.export_hash_cache_repair",
"caches_indexes",
true,
true,
&["db.export_hash_cache"],
&["fm-caches_indexes-export-hash-cache-divergence"],
),
(
"doctor.base_jsonl_symlink_quarantine",
"state_files",
true,
true,
&["base_jsonl"],
&["fm-state_files-base-jsonl-missing-or-stale"],
),
(
"doctor.base_jsonl_regen",
"state_files",
true,
true,
&["base_jsonl"],
&["fm-state_files-base-jsonl-missing-or-stale"],
),
(
"doctor.orphan_tmp_quarantine",
"state_files",
true,
true,
&["tmp_files_orphan"],
&["fm-state_files-orphan-tmp-files"],
),
(
"doctor.jsonl_trailing_newline_append",
"state_files",
true,
true,
&["jsonl_eof_newline"],
&["fm-state_files-jsonl-missing-trailing-newline"],
),
(
"doctor.jsonl_bom_strip",
"state_files",
true,
true,
&["jsonl_bom"],
&["fm-state_files-jsonl-utf8-bom-prefix"],
),
(
"doctor.jsonl_crlf_to_lf",
"state_files",
true,
true,
&["jsonl_crlf"],
&["fm-state_files-jsonl-crlf-line-endings"],
),
(
"doctor.jsonl_world_writable_chmod",
"permissions",
true,
true,
&["permissions.jsonl_world_writable"],
&["fm-permissions-jsonl-world-writable"],
),
(
"doctor.config_yaml_secret_chmod",
"permissions",
true,
true,
&["permissions.config_yaml_secrets"],
&["fm-permissions-config-yaml-mode-leaks-secrets"],
),
(
"doctor.db_sidecar_mode_chmod",
"permissions",
true,
true,
&["permissions.db_sidecars"],
&["fm-permissions-db-sidecar-mode-too-open"],
),
(
"doctor.inner_gitignore_append",
"configs",
true,
true,
&["gitignore.beads_inner_present"],
&["fm-configs-gitignore-leaking-beads"],
),
(
"doctor.dirty_bitmap_orphan_prune",
"caches_indexes",
true,
true,
&["dirty_bitmap"],
&["fm-caches_indexes-dirty-bitmap-divergence"],
),
(
"doctor.comments_orphan_prune",
"caches_indexes",
true,
true,
&["comments.orphans"],
&["fm-caches_indexes-comments-orphans"],
),
(
"doctor.labels_orphan_prune",
"caches_indexes",
true,
true,
&["labels.orphans"],
&["fm-caches_indexes-labels-orphans"],
),
(
"doctor.dependencies_orphan_prune",
"caches_indexes",
true,
true,
&["dependencies.orphans"],
&["fm-caches_indexes-dependencies-orphans"],
),
(
"doctor.wal_checkpoint_truncate",
"state_files",
true,
true,
&["wal_size"],
&["fm-state_files-wal-oversized"],
),
(
"doctor.null_defaults_backfill",
"schemas",
true,
true,
&["db.null_defaults"],
&["fm-schemas-missing-required-column"],
),
];
const fn early_chokepoint_fixer_rows() -> &'static [FixerRow] {
EARLY_CHOKEPOINT_FIXER_ROWS
}
fn legacy_fixer_rows() -> &'static [FixerRow] {
&[
(
"doctor.repair_recoverable_db_state",
"caches_indexes",
true,
false,
&["db.recoverable_anomalies", "db.sidecars"],
&[
"fm-caches_indexes-blocked-cache-stale",
"fm-state_files-wal-shm-sidecar-orphan",
],
),
(
"doctor.repair_partial_indexes",
"caches_indexes",
true,
false,
&["sqlite.integrity_check", "sqlite3.integrity_check"],
&["fm-caches_indexes-partial-index-stale"],
),
(
"doctor.repair_via_vacuum",
"state_files",
true,
false,
&["sqlite.integrity_check", "sqlite3.integrity_check"],
&["fm-state_files-sqlite-page-malformed"],
),
(
"doctor.db_bloat_vacuum",
"caches_indexes",
false,
false,
&["db_bloat"],
&["fm-caches_indexes-db-bloat-vs-jsonl"],
),
]
}
fn rebuild_fixer_rows() -> &'static [FixerRow] {
&[
(
"doctor.repair_database_from_jsonl",
"state_files",
true,
true,
&[
"db.open",
"counts.db_vs_jsonl",
"schema.tables",
"schema.columns",
],
&[
"fm-state_files-jsonl-row-count-mismatch",
"fm-state_files-empty-or-truncated-database",
"fm-state_files-sqlite-page-malformed",
"fm-schemas-missing-required-table",
"fm-schemas-missing-required-column",
"fm-caches_indexes-blocked-cache-stale",
],
),
(
"doctor.repair_database_sidecars",
"state_files",
true,
true,
&["db.sidecars"],
&["fm-state_files-wal-shm-sidecar-orphan"],
),
]
}
fn refuse_gate_fixer_rows() -> &'static [FixerRow] {
&[
(
"refuse_gates.schema_version_downgrade",
"schemas",
false,
false,
&["schema.columns"],
&[],
),
(
"refuse_gates.recovery_fingerprint_integrity",
"state_files",
false,
false,
&["db.recovery_artifacts"],
&[],
),
]
}
fn fixer_entry_from_row(row: &FixerRow) -> FixerEntry {
let (id, subsystem, auto_fixable, mutates, addressed, filter_ids) = *row;
FixerEntry {
id: id.to_string(),
subsystem: subsystem.to_string(),
auto_fixable,
mutates,
addressed_findings: addressed.iter().map(|s| (*s).to_string()).collect(),
filter_ids: filter_ids.iter().map(|s| (*s).to_string()).collect(),
}
}
fn build_fixer_registry() -> Vec<FixerEntry> {
early_chokepoint_fixer_rows()
.iter()
.chain(legacy_fixer_rows())
.chain(rebuild_fixer_rows())
.chain(refuse_gate_fixer_rows())
.map(fixer_entry_from_row)
.collect()
}
fn build_finding_id_map() -> Vec<FindingIdEntry> {
super::super::doctor::CHECK_NAME_TO_FINDING_ID
.iter()
.map(|(check_name, finding_id)| FindingIdEntry {
check_name,
finding_id,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capabilities_builds_with_expected_shape() {
let caps = DoctorCapabilities::build();
assert_eq!(caps.schema, "br.doctor.capabilities");
assert_eq!(caps.contract_version, "1");
assert_eq!(caps.doctor_version, env!("CARGO_PKG_VERSION"));
assert!(!caps.exit_codes.is_empty());
let mut codes: Vec<i32> = caps.exit_codes.iter().map(|e| e.code).collect();
codes.sort_unstable();
assert!(codes.contains(&0));
assert!(codes.contains(&4));
assert!(codes.contains(&74));
assert!(caps.write_scopes.contains(&".beads/".to_string()));
assert!(caps.write_scopes.contains(&".doctor/".to_string()));
assert!(caps.env_vars.contains(&super::super::run_dir::ENV_RUNS_DIR));
}
#[test]
fn capabilities_is_stable_json() {
let caps = DoctorCapabilities::build();
let json = caps.to_pretty_json().expect("json");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
assert_eq!(parsed["schema"], "br.doctor.capabilities");
assert_eq!(parsed["contract_version"], "1");
assert!(parsed["exit_codes"].is_array());
assert!(parsed["fixers"].is_array());
assert!(parsed["detectors"].is_array());
let ops = parsed["ops_supported"]
.as_array()
.expect("ops_supported array");
assert!(
ops.iter()
.any(|o| o["name"] == "db_exec" && o["fully_routed"] == true),
"db_exec must be advertised as fully routed"
);
assert!(
ops.iter()
.any(|o| o["name"] == "db_migrate" && o["fully_routed"] == true),
"db_migrate must be advertised as fully routed (beads_rust-folg)"
);
for op in ops {
assert!(op["params"].is_array(), "op.params must be an array: {op}");
}
}
#[test]
#[allow(clippy::too_many_lines)]
fn capabilities_detectors_and_fixers_are_populated() {
let caps = DoctorCapabilities::build();
assert!(
!caps.detectors.is_empty(),
"detector registry must enumerate the check_* family"
);
assert!(
!caps.fixers.is_empty(),
"fixer registry must enumerate the repair_* family"
);
let detector_ids: Vec<&str> = caps.detectors.iter().map(|d| d.id.as_str()).collect();
let mut sorted_detector_ids = detector_ids.clone();
sorted_detector_ids.sort_unstable();
sorted_detector_ids.dedup();
assert_eq!(
sorted_detector_ids.len(),
detector_ids.len(),
"detector registry must not contain duplicate ids"
);
for required in &[
"gitignore.beads_inner",
"jsonl.parse",
"db.open",
"sqlite.integrity_check",
"schema.tables",
] {
assert!(
detector_ids.contains(required),
"detector registry missing {required}"
);
}
for obsolete in &["merge_artifacts", "sqlite.cli_integrity"] {
assert!(
!detector_ids.contains(obsolete),
"detector registry must not advertise obsolete check id {obsolete}"
);
}
let detector = |id: &str| {
caps.detectors
.iter()
.find(|d| d.id == id)
.unwrap_or_else(|| panic!("missing detector {id}"))
};
assert_eq!(detector("jsonl.merge_artifacts").severity_default, "warn");
assert_eq!(detector("schema.inspect").severity_default, "error");
let fixer_ids: Vec<&str> = caps.fixers.iter().map(|f| f.id.as_str()).collect();
assert_eq!(
fixer_ids,
vec![
"doctor.gitignore_repair",
"doctor.merge_artifact_quarantine",
"doctor.startup_cache_quarantine",
"doctor.recovery_artifacts_aged_quarantine",
"doctor.export_hash_cache_repair",
"doctor.base_jsonl_symlink_quarantine",
"doctor.base_jsonl_regen",
"doctor.orphan_tmp_quarantine",
"doctor.jsonl_trailing_newline_append",
"doctor.jsonl_bom_strip",
"doctor.jsonl_crlf_to_lf",
"doctor.jsonl_world_writable_chmod",
"doctor.config_yaml_secret_chmod",
"doctor.db_sidecar_mode_chmod",
"doctor.inner_gitignore_append",
"doctor.dirty_bitmap_orphan_prune",
"doctor.comments_orphan_prune",
"doctor.labels_orphan_prune",
"doctor.dependencies_orphan_prune",
"doctor.wal_checkpoint_truncate",
"doctor.null_defaults_backfill",
"doctor.repair_recoverable_db_state",
"doctor.repair_partial_indexes",
"doctor.repair_via_vacuum",
"doctor.db_bloat_vacuum",
"doctor.repair_database_from_jsonl",
"doctor.repair_database_sidecars",
"refuse_gates.schema_version_downgrade",
"refuse_gates.recovery_fingerprint_integrity",
],
"fixer registry order is part of the stable capabilities JSON"
);
for required in &[
"doctor.gitignore_repair",
"doctor.merge_artifact_quarantine",
"doctor.repair_via_vacuum",
"doctor.repair_database_from_jsonl",
"refuse_gates.schema_version_downgrade",
] {
assert!(
fixer_ids.contains(required),
"fixer registry missing {required}"
);
}
for d in &caps.detectors {
assert!(
!d.subsystem.is_empty(),
"detector {} missing subsystem",
d.id
);
}
assert!(
caps.fixers.iter().any(|f| f.mutates),
"at least one fixer must route through mutate()"
);
}
}