The buglog was one 27K-line JSON file whose fields covered only symptom,
root cause and fix, as free text: 2% of entries named the code version a
bug was found on, 12% the build profile, 36% a way to reproduce, and 58% of
the file was OpenWolf hook auto-detections ("Missing guard clause",
"Function not marked async" on .md files).
- .claude/skills/buglog/bug-record.md: the record standard. Eight
sections (Summary as the title, Description, Environment, Reproduction;
Root cause, Fix, Verification, References once fixed), each with the
content it must contain -- e.g. Environment needs the OS, the libc the
failing binary loads (from its ELF interpreter: glibc 2.42 in the nix
shell vs 2.40 on the host), toolchain, build profile and commit.
- .wolf/bugs/<id>.md: 500 records migrated from buglog.json; every field
verified to round-trip. They carry legacy: true and an explicit "not
recorded" where the old log had no such field; component derived from
file paths, severity from keywords (else unknown), fix_commits from
hashes in status_note/fix that git resolves (91 of 463 fixed).
- .wolf/buglog-archive.json: the 710 hook auto-detections, verbatim.
- .wolf/buglog.json is now an index generated from the records, keeping
the fields OpenWolf's hooks and `openwolf bug search` read.
- scripts/buglog.py rewritten: new (template + Environment collected from
the machine and --binary), check, status (refuses a status the record
doesn't support; fixed needs Root cause, Fix, Verification and
--commit hashes git resolves), note, recur, set, show, list, triage,
env, index. 29 unittest cases in scripts/tests/test_buglog.py.
- scripts/git-hooks/pre-commit runs `check --staged`: incomplete staged
records, a legacy record hand-changed to fixed without the fix-time
sections, a new record claiming legacy: true, or a stale index refuse
the commit.
- scripts/gitea-sync.py reads the records: issue body = the record's
sections plus linked fix commits (origin remote, GITEA_COMMIT_REPO to
override), and a body hash so an issue's text is refreshed when the
record changes.
- bug-1866 rewritten to the full standard as the worked example.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
340 lines
13 KiB
Python
340 lines
13 KiB
Python
"""Tests for scripts/buglog.py (one Markdown record per bug).
|
|
|
|
Run: python3 -m unittest discover -s scripts/tests -v
|
|
"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
import buglog # noqa: E402
|
|
|
|
|
|
FULL_ENV = """\
|
|
- OS: Ubuntu 24.04 LTS; kernel Linux 6.18.33 x86_64
|
|
- libc: glibc 2.42 (from the binary's interpreter)
|
|
- Toolchain: gcc 15.2.0, meson 1.9.1
|
|
- Build: asan (xce.build)
|
|
- Code: scheduler-framework @ {sha}
|
|
- Deployment: single host, xcecd_virtual"""
|
|
|
|
FULL_BODY = """\
|
|
# {id}: xcecd_virtual leaks worker strings on every finished job
|
|
|
|
## Description
|
|
**Observed:**
|
|
```
|
|
Direct leak of 12 byte(s) in 12 object(s)
|
|
```
|
|
**Expected:** no leak at exit
|
|
**Impact:** grows with job count
|
|
**Found by:** LeakSanitizer after a job burst
|
|
|
|
## Environment
|
|
{env}
|
|
|
|
## Reproduction
|
|
1. `/xce-build worker`
|
|
2. run `finished_job_retention_test`
|
|
- Frequency: deterministic
|
|
- Evidence: the unit test
|
|
|
|
## Root cause
|
|
{root}
|
|
|
|
## Fix
|
|
{fix}
|
|
|
|
## Verification
|
|
{verif}
|
|
|
|
## References
|
|
- Related: none
|
|
"""
|
|
|
|
|
|
def git(root, *args):
|
|
return subprocess.run(["git", "-C", str(root), *args], check=True,
|
|
capture_output=True, text=True).stdout.strip()
|
|
|
|
|
|
class BuglogTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.tmp.name)
|
|
(self.root / ".wolf" / "bugs").mkdir(parents=True)
|
|
git(self.root, "init", "-q")
|
|
git(self.root, "-c", "user.email=t@t", "-c", "user.name=t",
|
|
"commit", "-q", "--allow-empty", "-m", "init")
|
|
self.sha = git(self.root, "rev-parse", "--short=8", "HEAD")
|
|
buglog.configure(self.root)
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def write_record(self, bug_id="bug-100", status="open", legacy=False,
|
|
root="TODO", fix="TODO", verif="TODO", commits=None,
|
|
env=None, extra_meta=None):
|
|
meta = {"id": bug_id, "status": status, "status_updated": "2026-09-27",
|
|
"status_note": "" if status == "fixed" else "why",
|
|
"component": "worker", "severity": "leak",
|
|
"timestamp": "2026-09-25T15:42:02Z"}
|
|
if legacy:
|
|
meta["legacy"] = True
|
|
if commits is not None:
|
|
meta["fix_commits"] = commits
|
|
meta.update(extra_meta or {})
|
|
body = FULL_BODY.format(id=bug_id, env=env if env is not None
|
|
else FULL_ENV.format(sha=self.sha),
|
|
root=root, fix=fix, verif=verif)
|
|
text = buglog.render_meta(meta) + "\n" + body
|
|
path = self.root / ".wolf" / "bugs" / f"{bug_id}.md"
|
|
path.write_text(text, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
class ParseRender(BuglogTestCase):
|
|
def test_round_trip_keeps_every_field(self):
|
|
path = self.write_record(extra_meta={"tags": ["worker", "asan"],
|
|
"gitea_issue": 285})
|
|
rec = buglog.load("bug-100")
|
|
self.assertEqual(rec.meta["tags"], ["worker", "asan"])
|
|
self.assertEqual(rec.meta["gitea_issue"], 285)
|
|
self.assertEqual(rec.summary,
|
|
"xcecd_virtual leaks worker strings on every finished job")
|
|
self.assertIn("Direct leak", rec.sections["Description"])
|
|
self.assertEqual(buglog.render(rec), path.read_text(encoding="utf-8"))
|
|
|
|
def test_section_header_inside_code_fence_is_not_a_section(self):
|
|
self.write_record(root="See:\n```\n## Fix\nnot a header\n```")
|
|
rec = buglog.load("bug-100")
|
|
self.assertIn("not a header", rec.sections["Root cause"])
|
|
self.assertEqual(rec.sections["Fix"], "TODO")
|
|
|
|
def test_escaped_section_header_round_trips(self):
|
|
text = "a\n## Fix\nb"
|
|
rec = buglog.Record({"id": "bug-7", "status": "open"}, "s",
|
|
{"Description": buglog.escape_text(text)})
|
|
again = buglog.parse(buglog.render(rec))
|
|
self.assertEqual(buglog.unescape_text(again.sections["Description"]), text)
|
|
self.assertNotIn("Fix", again.sections)
|
|
|
|
def test_set_meta_leaves_body_bytes_unchanged(self):
|
|
path = self.write_record()
|
|
body_before = path.read_text(encoding="utf-8").split("\n---\n", 1)[1]
|
|
buglog.set_meta("bug-100", {"gitea_issue": 9, "status_note": "x: y"})
|
|
text = path.read_text(encoding="utf-8")
|
|
self.assertEqual(text.split("\n---\n", 1)[1], body_before)
|
|
self.assertEqual(buglog.load("bug-100").meta["status_note"], "x: y")
|
|
|
|
|
|
class Check(BuglogTestCase):
|
|
def problems(self, bug_id="bug-100", target=None):
|
|
return buglog.check(buglog.load(bug_id), target_status=target)
|
|
|
|
def test_complete_open_record_passes(self):
|
|
self.write_record()
|
|
self.assertEqual(self.problems(), [])
|
|
|
|
def test_environment_needs_every_item(self):
|
|
env = FULL_ENV.format(sha=self.sha).replace(
|
|
"- libc: glibc 2.42 (from the binary's interpreter)\n", "")
|
|
self.write_record(env=env)
|
|
self.assertTrue(any("libc" in p for p in self.problems()))
|
|
|
|
def test_libc_needs_a_version(self):
|
|
env = FULL_ENV.format(sha=self.sha).replace("glibc 2.42", "glibc")
|
|
self.write_record(env=env)
|
|
self.assertTrue(any("libc" in p for p in self.problems()))
|
|
|
|
def test_code_needs_a_commit(self):
|
|
env = FULL_ENV.format(sha=self.sha).replace(self.sha, "latest")
|
|
self.write_record(env=env)
|
|
self.assertTrue(any("Code" in p for p in self.problems()))
|
|
|
|
def test_todo_counts_as_missing(self):
|
|
env = FULL_ENV.format(sha=self.sha).replace(
|
|
"single host, xcecd_virtual", "TODO: topology")
|
|
self.write_record(env=env)
|
|
self.assertTrue(any("Deployment" in p for p in self.problems()))
|
|
|
|
def test_fixed_needs_root_cause_fix_verification_and_commit(self):
|
|
self.write_record(status="fixed")
|
|
probs = " | ".join(self.problems())
|
|
for word in ("Root cause", "Fix", "Verification", "fix_commits"):
|
|
self.assertIn(word, probs)
|
|
|
|
def test_fixed_with_everything_passes(self):
|
|
self.write_record(status="fixed", root="remove_worker skips five strings",
|
|
fix="free them", commits=[self.sha],
|
|
verif="- Test: test_x: RED before, GREEN after\n"
|
|
"- Suites: worker 12/12, asan")
|
|
self.assertEqual(self.problems(), [])
|
|
|
|
def test_unknown_commit_is_rejected(self):
|
|
self.write_record(status="fixed", root="r", fix="f", commits=["deadbeef"],
|
|
verif="- Test: t: RED before, GREEN after\n- Suites: s")
|
|
self.assertTrue(any("deadbeef" in p for p in self.problems()))
|
|
|
|
def test_open_status_needs_note(self):
|
|
self.write_record(extra_meta={"status_note": ""})
|
|
self.assertTrue(any("status_note" in p for p in self.problems()))
|
|
|
|
def test_legacy_record_is_exempt_until_it_changes_status(self):
|
|
self.write_record(legacy=True, env=buglog.NOT_RECORDED)
|
|
self.assertEqual(self.problems(), [])
|
|
# marking it fixed now demands the fix-time sections
|
|
probs = " | ".join(self.problems(target="fixed"))
|
|
self.assertIn("Verification", probs)
|
|
self.assertIn("fix_commits", probs)
|
|
|
|
def test_migrated_placeholder_is_not_verification(self):
|
|
self.write_record(legacy=True, root="r", fix="f", commits=[self.sha],
|
|
verif=buglog.NOT_RECORDED)
|
|
self.assertTrue(any("Verification" in p
|
|
for p in self.problems(target="fixed")))
|
|
|
|
|
|
class Commands(BuglogTestCase):
|
|
def run_cli(self, *argv):
|
|
try:
|
|
buglog.main(list(argv))
|
|
return 0
|
|
except SystemExit as e:
|
|
return e.code
|
|
|
|
def test_status_refuses_incomplete_fixed_and_leaves_file_alone(self):
|
|
path = self.write_record()
|
|
before = path.read_text(encoding="utf-8")
|
|
rc = self.run_cli("status", "bug-100", "fixed", "--commit", self.sha)
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertEqual(path.read_text(encoding="utf-8"), before)
|
|
|
|
def test_status_fixed_records_commits(self):
|
|
self.write_record(root="r", fix="f",
|
|
verif="- Test: t: RED before, GREEN after\n- Suites: s")
|
|
rc = self.run_cli("status", "bug-100", "fixed", "--commit", self.sha)
|
|
self.assertIn(rc, (0, None))
|
|
meta = buglog.load("bug-100").meta
|
|
self.assertEqual(meta["status"], "fixed")
|
|
self.assertEqual(meta["fix_commits"], [self.sha])
|
|
|
|
def test_new_creates_record_with_environment_filled(self):
|
|
self.run_cli("new", "--summary", "scheduler hangs on empty queue",
|
|
"--component", "scheduler", "--severity", "hang")
|
|
rec = buglog.load("bug-001")
|
|
self.assertEqual(rec.meta["status"], "open")
|
|
env = rec.sections["Environment"]
|
|
self.assertRegex(env, r"- OS: .*Linux")
|
|
self.assertRegex(env, r"- Code: .*" + self.sha)
|
|
# the author still has to write the description
|
|
self.assertTrue(buglog.check(rec))
|
|
|
|
def test_next_id_counts_archive(self):
|
|
self.write_record(bug_id="bug-100")
|
|
(self.root / ".wolf" / "buglog-archive.json").write_text(
|
|
json.dumps({"bugs": [{"id": "bug-900"}]}), encoding="utf-8")
|
|
self.assertEqual(buglog.next_id(), "bug-901")
|
|
|
|
def test_index_keeps_openwolf_fields(self):
|
|
self.write_record(extra_meta={"files": ["src/worker/a.c", "src/worker/b.c"],
|
|
"tags": ["worker"]}, root="cause text")
|
|
buglog.build_index()
|
|
idx = json.loads((self.root / ".wolf" / "buglog.json").read_text())
|
|
b = idx["bugs"][0]
|
|
self.assertEqual(b["id"], "bug-100")
|
|
self.assertTrue(b["error_message"].startswith("xcecd_virtual leaks"))
|
|
self.assertIn("Direct leak", b["error_message"])
|
|
self.assertEqual(b["root_cause"], "cause text")
|
|
self.assertEqual(b["file"], "src/worker/a.c, src/worker/b.c")
|
|
self.assertEqual(b["tags"], ["worker"])
|
|
self.assertEqual(b["status"], "open")
|
|
|
|
def test_write_commands_refresh_the_index(self):
|
|
self.write_record()
|
|
self.run_cli("note", "bug-100", "seen again under load")
|
|
idx = json.loads((self.root / ".wolf" / "buglog.json").read_text())
|
|
self.assertIn("seen again under load", idx["bugs"][0]["error_message"])
|
|
|
|
|
|
class Staged(BuglogTestCase):
|
|
"""`check --staged`, which the pre-commit hook runs."""
|
|
|
|
def commit(self, *paths):
|
|
git(self.root, "add", *paths)
|
|
git(self.root, "-c", "user.email=t@t", "-c", "user.name=t",
|
|
"commit", "-q", "-m", "x")
|
|
|
|
def check_staged(self):
|
|
try:
|
|
buglog.main(["check", "--staged"])
|
|
return 0
|
|
except SystemExit as e:
|
|
return e.code or 0
|
|
|
|
def test_complete_record_with_fresh_index_passes(self):
|
|
self.write_record()
|
|
buglog.build_index()
|
|
git(self.root, "add", ".wolf")
|
|
self.assertEqual(self.check_staged(), 0)
|
|
|
|
def test_incomplete_record_is_refused(self):
|
|
self.write_record(env="- OS: TODO")
|
|
buglog.build_index()
|
|
git(self.root, "add", ".wolf")
|
|
self.assertNotEqual(self.check_staged(), 0)
|
|
|
|
def test_stale_index_is_refused(self):
|
|
self.write_record()
|
|
git(self.root, "add", ".wolf")
|
|
self.assertNotEqual(self.check_staged(), 0)
|
|
|
|
def test_hand_edited_index_alone_is_refused(self):
|
|
self.write_record()
|
|
buglog.build_index()
|
|
self.commit(".wolf")
|
|
idx = self.root / ".wolf" / "buglog.json"
|
|
idx.write_text(idx.read_text().replace("leaks", "LEAKS"))
|
|
git(self.root, "add", ".wolf/buglog.json")
|
|
self.assertNotEqual(self.check_staged(), 0)
|
|
|
|
def test_legacy_record_marked_fixed_by_hand_needs_fix_sections(self):
|
|
self.write_record(legacy=True, env=buglog.NOT_RECORDED)
|
|
buglog.build_index()
|
|
self.commit(".wolf")
|
|
buglog.set_meta("bug-100", {"status": "fixed", "status_note": ""})
|
|
git(self.root, "add", ".wolf")
|
|
self.assertNotEqual(self.check_staged(), 0)
|
|
|
|
def test_migrated_record_matching_the_old_index_passes(self):
|
|
# the migration commit: the record is new, but HEAD's buglog.json
|
|
# already had this bug with the same status
|
|
(self.root / ".wolf" / "buglog.json").write_text(
|
|
json.dumps({"bugs": [{"id": "bug-100", "status": "fixed"}]}))
|
|
self.commit(".wolf")
|
|
self.write_record(legacy=True, status="fixed", env=buglog.NOT_RECORDED)
|
|
buglog.build_index()
|
|
git(self.root, "add", ".wolf")
|
|
self.assertEqual(self.check_staged(), 0)
|
|
|
|
def test_new_record_cannot_claim_legacy(self):
|
|
self.write_record(legacy=True, status="fixed", env=buglog.NOT_RECORDED)
|
|
buglog.build_index()
|
|
git(self.root, "add", ".wolf")
|
|
self.assertNotEqual(self.check_staged(), 0)
|
|
|
|
def test_untouched_legacy_record_passes(self):
|
|
self.write_record(legacy=True, env=buglog.NOT_RECORDED)
|
|
buglog.build_index()
|
|
self.commit(".wolf")
|
|
buglog.set_meta("bug-100", {"gitea_issue": 5})
|
|
git(self.root, "add", ".wolf")
|
|
self.assertEqual(self.check_staged(), 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|