Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ All notable changes to vouch are documented here. Format follows

## [Unreleased]

### Fixed
- `vouch pending` (and every bulk `list_*` path) no longer crashes when a
single artifact file is unreadable — a corrupt or mojibake yaml is skipped
with a warning instead of aborting the whole listing.
- all text-mode file i/o under `src/vouch/` now pins `encoding="utf-8"`, so a
non-utf-8 locale (e.g. latin-1) can no longer mangle non-ascii claim text
into raw control bytes that the yaml loader rejects, nor crash on write.

### Docs
- example KBs now carry their own screenshots: `examples/README.md` and the
`tiny/` + `decision-log/` READMEs embed terminal renders of `vouch status`,
Expand Down
4 changes: 2 additions & 2 deletions src/vouch/auto_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,10 +319,10 @@ def detect_or_bootstrap_guidance(ctx: RepoCtx, engine: Engine,
"---\nname: auto-pr-contributing\ndescription: synthesized contribution "
f"guide for {ctx.repo}, derived from merged PRs.\n---\n\n"
)
skill.write_text(front + guide)
skill.write_text(front + guide, encoding="utf-8")
codex_mirror = ctx.clone_dir / ".codex" / "auto-pr-contributing.md"
codex_mirror.parent.mkdir(parents=True, exist_ok=True)
codex_mirror.write_text(guide)
codex_mirror.write_text(guide, encoding="utf-8")
return guide


Expand Down
2 changes: 1 addition & 1 deletion src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2797,7 +2797,7 @@ def review_ui(
auth_note = " (Bearer auth on)" if token else ""
if open_browser and is_loopback:
# Lazy-import webbrowser; some CI envs (headless) don't have a default
# browser configured and webbrowser.open() returns False rather than
# browser configured and webbrowser.open(encoding="utf-8") returns False rather than
# raising — that's fine, the URL is also printed to stdout. When auth
# is on, hand the browser the token once via ?token= so it can stash it.
import threading
Expand Down
2 changes: 1 addition & 1 deletion src/vouch/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def _configured_backend(store: KBStore) -> str:
falls back to "auto".
"""
try:
loaded = yaml.safe_load(store.config_path.read_text())
loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError):
return "auto"
if not isinstance(loaded, dict):
Expand Down
4 changes: 2 additions & 2 deletions src/vouch/embeddings/scorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def evaluate(
raise ValueError(f"unknown metric(s): {sorted(unknown)}; known: {sorted(known)}")
totals = {m: 0.0 for m in metrics}
n = 0
with queries_file.open() as f:
with queries_file.open(encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
Expand All @@ -78,4 +78,4 @@ def evaluate(


def write_report(out: dict[str, float], path: Path) -> None:
path.write_text(json.dumps(out, indent=2))
path.write_text(json.dumps(out, indent=2), encoding="utf-8")
2 changes: 1 addition & 1 deletion src/vouch/embeddings/similarity.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
def similarity_threshold(store: KBStore) -> float:
"""Resolve `review.similarity_threshold` from config, else dedup default."""
try:
loaded = yaml.safe_load(store.config_path.read_text())
loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
review = loaded.get("review")
if isinstance(review, dict) and review.get("similarity_threshold") is not None:
Expand Down
2 changes: 1 addition & 1 deletion src/vouch/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def _load_claims_for_lint(store: KBStore) -> tuple[list[Claim], list[Finding]]:
for p in sorted(cdir.glob("*.yaml")):
cid = p.stem
try:
valid.append(Claim.model_validate(_yaml_load(p.read_text())))
valid.append(Claim.model_validate(_yaml_load(p.read_text(encoding="utf-8"))))
except ValidationError as e:
tail = str(e).splitlines()[-1].strip() if str(e) else "validation failed"
findings.append(
Expand Down
2 changes: 1 addition & 1 deletion src/vouch/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def load_serve_config(path: Path) -> ServeConfig:
if not path.exists():
return ServeConfig()
try:
raw = yaml.safe_load(path.read_text()) or {}
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as e:
raise ServeConfigError(f"could not parse {path}: {e}") from e
if not isinstance(raw, dict):
Expand Down
2 changes: 1 addition & 1 deletion src/vouch/index_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def _snippet_for(kb_dir: Path, kind: str, eid: str) -> str:
path = kb_dir / kind / f"{eid}.md"
if not path.exists():
return eid
text = path.read_text()
text = path.read_text(encoding="utf-8")
return text[:200].replace("\n", " ")


Expand Down
2 changes: 1 addition & 1 deletion src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ def _h_search(p: dict) -> dict:

def _load_cfg(store: KBStore) -> dict:
try:
loaded = yaml.safe_load((store.kb_dir / "config.yaml").read_text())
loaded = yaml.safe_load((store.kb_dir / "config.yaml").read_text(encoding="utf-8"))
except Exception:
return {}
return loaded if isinstance(loaded, dict) else {}
Expand Down
9 changes: 5 additions & 4 deletions src/vouch/migrations/_legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class MigrationResult:
def read_config(store: KBStore) -> dict[str, Any]:
if not store.config_path.exists():
return {}
loaded = yaml.safe_load(store.config_path.read_text())
loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8"))
if loaded is None:
return {}
if not isinstance(loaded, dict):
Expand All @@ -64,7 +64,8 @@ def read_config(store: KBStore) -> dict[str, Any]:


def write_config(store: KBStore, config: dict[str, Any]) -> None:
store.config_path.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True))
store.config_path.write_text(
yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8")


def detect_version(store: KBStore) -> int:
Expand Down Expand Up @@ -162,14 +163,14 @@ def _migration_0_to_1(store: KBStore, dry_run: bool) -> list[str]:
required_ignores = ("proposed/", "state.db", "state.db-*")
existing_ignores: list[str] = []
if gitignore_path.exists():
existing_ignores = gitignore_path.read_text().splitlines()
existing_ignores = gitignore_path.read_text(encoding="utf-8").splitlines()
missing_ignores = [line for line in required_ignores if line not in existing_ignores]
if missing_ignores:
changes.append("ensure .gitignore excludes proposed/ and state.db")
if not dry_run:
gitignore_path.parent.mkdir(parents=True, exist_ok=True)
lines = [*existing_ignores, *missing_ignores]
gitignore_path.write_text("\n".join(lines).rstrip() + "\n")
gitignore_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")

return changes

Expand Down
2 changes: 1 addition & 1 deletion src/vouch/migrations/journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def latest_journal(store: KBStore) -> Path | None:
def read_journal(path: Path) -> tuple[dict[str, object], list[JournalEntry]]:
header: dict[str, object] = {}
entries: list[JournalEntry] = []
for line in path.read_text().splitlines():
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
rec = json.loads(line)
Expand Down
2 changes: 1 addition & 1 deletion src/vouch/migrations/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def _validate_transforms(transforms: Any, where: str) -> list[dict[str, Any]]:

def parse_manifest(path: Path) -> Manifest:
try:
data = yaml.safe_load(path.read_text())
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError as e:
raise ManifestError(f"{path.name}: invalid yaml: {e}") from e
if not isinstance(data, dict):
Expand Down
8 changes: 4 additions & 4 deletions src/vouch/migrations/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def _changed_files(store: KBStore, manifest: Manifest) -> list[tuple[Path, str,
"""Files whose content the manifest would actually change: (path, old, new)."""
out: list[tuple[Path, str, str]] = []
for path in artifact_files(store.kb_dir, manifest.artifact):
old = path.read_text()
old = path.read_text(encoding="utf-8")
new = transform_text(old, manifest.artifact, manifest.transforms)
if new != old:
out.append((path, old, new))
Expand Down Expand Up @@ -311,21 +311,21 @@ def verify(store: KBStore) -> dict[str, Any]:
for path in artifact_files(store.kb_dir, kind):
checked += 1
try:
model.model_validate(_yaml_load(path.read_text()))
model.model_validate(_yaml_load(path.read_text(encoding="utf-8")))
except Exception as e:
errors.append({"path": str(path.relative_to(store.kb_dir)), "error": str(e)})
for path in artifact_files(store.kb_dir, "pages"):
checked += 1
try:
match = _FRONTMATTER_RE.match(path.read_text())
match = _FRONTMATTER_RE.match(path.read_text(encoding="utf-8"))
front = _yaml_load(match.group(1)) if match else {}
Page.model_validate({**(front or {}), "body": match.group(2) if match else ""})
except Exception as e:
errors.append({"path": str(path.relative_to(store.kb_dir)), "error": str(e)})
for meta in sorted((store.kb_dir / "sources").glob("*/meta.yaml")):
checked += 1
try:
Source.model_validate(_yaml_load(meta.read_text()))
Source.model_validate(_yaml_load(meta.read_text(encoding="utf-8")))
except Exception as e:
errors.append({"path": str(meta.relative_to(store.kb_dir)), "error": str(e)})
return {
Expand Down
2 changes: 1 addition & 1 deletion src/vouch/migrations/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def read_schema_version(store: KBStore) -> str:
p = schema_version_path(store)
if not p.exists():
return BASELINE_SCHEMA_VERSION
raw = p.read_text().strip()
raw = p.read_text(encoding="utf-8").strip()
if not raw:
return BASELINE_SCHEMA_VERSION
semver.parse(raw) # validate; raises ValueError on garbage
Expand Down
2 changes: 1 addition & 1 deletion src/vouch/openclaw/context_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def resolve_kb_root(*, workspace_dir: Path | None, kb_path: str | None) -> Path:

def load_cfg(store: KBStore) -> dict[str, Any]:
try:
loaded = yaml.safe_load(store.config_path.read_text())
loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8"))
except Exception:
return {}
return loaded if isinstance(loaded, dict) else {}
Expand Down
2 changes: 1 addition & 1 deletion src/vouch/page_kinds.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ def _read_page_kinds(store: KBStore) -> dict[str, Any]:
if not path.exists():
return {}
try:
loaded = yaml.safe_load(path.read_text())
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError:
return {}
if not isinstance(loaded, dict):
Expand Down
4 changes: 2 additions & 2 deletions src/vouch/pr_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ def _record_from_json(d: dict[str, Any]) -> PRRecord:
def load_cache(path: Path) -> dict[str, PRRecord]:
if not path.exists():
return {}
raw = json.loads(path.read_text())
raw = json.loads(path.read_text(encoding="utf-8"))
return {str(d["number"]): _record_from_json(d) for d in raw.get("prs", [])}


Expand All @@ -475,7 +475,7 @@ def save_cache(path: Path, repo: RepoRef, records: dict[str, PRRecord]) -> None:
"prs": [_record_to_json(r) for r in prs_sorted],
}
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, indent=2, sort_keys=False))
tmp.write_text(json.dumps(payload, indent=2, sort_keys=False), encoding="utf-8")
tmp.replace(path)


Expand Down
4 changes: 2 additions & 2 deletions src/vouch/proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ def _approval_block_reason(
if approved_by == proposal.proposed_by:
cfg: dict[str, Any] = {}
try:
loaded = yaml.safe_load((store.kb_dir / "config.yaml").read_text())
loaded = yaml.safe_load((store.kb_dir / "config.yaml").read_text(encoding="utf-8"))
if isinstance(loaded, dict):
cfg = loaded
except Exception:
Expand Down Expand Up @@ -577,7 +577,7 @@ def expire_pending_after_days(store: KBStore, *, override: int | None = None) ->
if override is not None:
return override
try:
loaded = yaml.safe_load(store.config_path.read_text())
loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8"))
except Exception:
return _DEFAULT_EXPIRE_PENDING_DAYS
if not isinstance(loaded, dict):
Expand Down
2 changes: 1 addition & 1 deletion src/vouch/scoping.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def viewer_from(

if config_path is not None and (resolved_project is None or resolved_agent is None):
try:
loaded = yaml.safe_load(config_path.read_text())
loaded = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError):
loaded = None
if isinstance(loaded, dict):
Expand Down
2 changes: 1 addition & 1 deletion src/vouch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def _to_dicts(h: list[tuple[str, str, str, float]], used: str) -> dict[str, Any]

def _load_cfg(store: KBStore) -> dict[str, Any]:
try:
loaded = yaml.safe_load((store.kb_dir / "config.yaml").read_text())
loaded = yaml.safe_load((store.kb_dir / "config.yaml").read_text(encoding="utf-8"))
except Exception:
return {}
return loaded if isinstance(loaded, dict) else {}
Expand Down
2 changes: 1 addition & 1 deletion src/vouch/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def _list_decided(store: KBStore) -> list[Proposal]:
return []
out: list[Proposal] = []
for path in sorted(ddir.glob("*.yaml")):
out.append(Proposal.model_validate(_yaml_load(path.read_text())))
out.append(Proposal.model_validate(_yaml_load(path.read_text(encoding="utf-8"))))
return out


Expand Down
Loading
Loading