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>
788 lines
30 KiB
Python
788 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
"""Read, write and check the XCE bug records: one Markdown file per bug.
|
|
|
|
Each bug is `.wolf/bugs/<id>.md`: a front-matter block of short machine
|
|
fields (one `key: <JSON value>` per line, which is also valid YAML), then
|
|
`# <id>: <summary>` and the sections of the record standard
|
|
(.claude/skills/buglog/bug-record.md). `check` enforces that standard.
|
|
|
|
`.wolf/buglog.json` is an index generated from the records for OpenWolf's
|
|
hooks and `jq` queries; every write here regenerates it, never edit it by
|
|
hand. The retired hook-generated entries live in `.wolf/buglog-archive.json`.
|
|
|
|
Status values and when to use each: .claude/rules/buglog-status.md.
|
|
|
|
Commands:
|
|
new --summary S --component C --severity V [--binary B] [--tags a,b]
|
|
[--files f,g] [--related bug-1,bug-2]
|
|
Create a record from the template, Environment filled in.
|
|
check [ID ...] [--all] [--staged] Check records against the standard.
|
|
status ID STATUS [--note T] [--commit SHA]... [--duplicate-of ID] [--repro T]
|
|
Change status; refused if the record does not meet what the
|
|
new status requires.
|
|
note ID TEXT [--section S] Append "UPDATE (date): TEXT" to a section.
|
|
recur ID [--note T] Seen again: bump occurrences/last_seen;
|
|
cannot-reproduce -> investigating, fixed/wontfix -> open.
|
|
set ID key=<json> ... Set front-matter fields.
|
|
show ID Print one record.
|
|
list [--status S,S | --open] [--tag T] [--grep RE] [--since DATE]
|
|
triage Records with no (or an unknown) status.
|
|
env [BINARY] Print an Environment section for this machine.
|
|
index Regenerate .wolf/buglog.json.
|
|
next-id Print the id `new` would use.
|
|
|
|
Importable: load, load_all, set_meta, check, build_index, BuglogEditError.
|
|
"""
|
|
import argparse
|
|
import datetime
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
STATUSES = ["open", "partial", "investigating", "fixed",
|
|
"cannot-reproduce", "wontfix", "duplicate"]
|
|
OPEN_STATUSES = {"open", "partial", "investigating"}
|
|
COMPONENTS = ["controller", "scheduler", "worker", "lib", "harness",
|
|
"apiserver", "xcecli", "xceadmin", "autoscaler", "build",
|
|
"config", "tooling"]
|
|
SEVERITIES = ["data-loss", "crash", "hang", "wrong-result", "leak",
|
|
"security", "noise"]
|
|
SECTIONS = ["Description", "Environment", "Reproduction", "Root cause",
|
|
"Fix", "Verification", "References", "Legacy notes"]
|
|
META_ORDER = ["id", "status", "status_updated", "status_note", "component",
|
|
"severity", "timestamp", "last_seen", "occurrences", "files",
|
|
"tags", "related", "fix_commits", "duplicate_of",
|
|
"repro_attempts", "legacy", "gitea_issue",
|
|
"gitea_synced_status", "gitea_synced_hash"]
|
|
ID_RE = re.compile(r"bug-\d+(?:-[a-z0-9-]+)?")
|
|
# Text a migrated record carries where the old log had no such field.
|
|
NOT_RECORDED = "_Not recorded (migrated from buglog.json)._"
|
|
STATUS_WORDS = re.compile(r"\b(OPEN|FIXED|NOT FIXED|PRIORITY)\b")
|
|
|
|
ROOT = BUGS_DIR = INDEX_PATH = ARCHIVE_PATH = None
|
|
|
|
|
|
def configure(root):
|
|
"""Point the module at a repository root (tests use a temp dir)."""
|
|
global ROOT, BUGS_DIR, INDEX_PATH, ARCHIVE_PATH
|
|
ROOT = Path(root)
|
|
BUGS_DIR = ROOT / ".wolf" / "bugs"
|
|
INDEX_PATH = ROOT / ".wolf" / "buglog.json"
|
|
ARCHIVE_PATH = ROOT / ".wolf" / "buglog-archive.json"
|
|
|
|
|
|
configure(Path(__file__).resolve().parent.parent)
|
|
|
|
|
|
class BuglogEditError(Exception):
|
|
pass
|
|
|
|
|
|
def _today():
|
|
return datetime.date.today().isoformat()
|
|
|
|
|
|
def _now():
|
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def _split(value):
|
|
return [v.strip() for v in (value or "").split(",") if v.strip()]
|
|
|
|
|
|
def _run(cmd, **kw):
|
|
try:
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180, **kw)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return None
|
|
return r.stdout.strip() if r.returncode == 0 else None
|
|
|
|
|
|
# ── Record format ────────────────────────────────────────────────────────────
|
|
|
|
class Record:
|
|
def __init__(self, meta, summary, sections, path=None):
|
|
self.meta = meta
|
|
self.summary = summary
|
|
self.sections = sections
|
|
self.path = path
|
|
|
|
@property
|
|
def id(self):
|
|
return self.meta.get("id")
|
|
|
|
|
|
def render_meta(meta):
|
|
keys = [k for k in META_ORDER if k in meta] + \
|
|
sorted(k for k in meta if k not in META_ORDER)
|
|
lines = ["---"] + [f"{k}: {json.dumps(meta[k], ensure_ascii=False)}"
|
|
for k in keys] + ["---"]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def render(rec):
|
|
out = [render_meta(rec.meta), f"# {rec.id}: {rec.summary}", ""]
|
|
for name in SECTIONS:
|
|
if name in rec.sections:
|
|
out += [f"## {name}", rec.sections[name].strip("\n"), ""]
|
|
return "\n".join(out)
|
|
|
|
|
|
_HEADER_RE = re.compile(r"^## (" + "|".join(map(re.escape, SECTIONS)) + r")\s*$")
|
|
_ESCAPED_RE = re.compile(r"^\\(## (" + "|".join(map(re.escape, SECTIONS)) + r")\s*)$",
|
|
re.M)
|
|
|
|
|
|
def escape_text(text):
|
|
"""Stop a line of free text from being read as a section header."""
|
|
return re.sub(_HEADER_RE.pattern, r"\\\g<0>", text, flags=re.M)
|
|
|
|
|
|
def unescape_text(text):
|
|
return _ESCAPED_RE.sub(r"\1", text)
|
|
|
|
|
|
def parse(text, path=None):
|
|
lines = text.split("\n")
|
|
if not lines or lines[0] != "---":
|
|
raise BuglogEditError(f"{path}: no front matter")
|
|
meta = {}
|
|
i = 1
|
|
while i < len(lines) and lines[i] != "---":
|
|
key, sep, raw = lines[i].partition(": ")
|
|
if not sep:
|
|
raise BuglogEditError(f"{path}:{i + 1}: expected 'key: value'")
|
|
try:
|
|
meta[key] = json.loads(raw)
|
|
except json.JSONDecodeError as e:
|
|
raise BuglogEditError(f"{path}:{i + 1}: {key}: {e}") from None
|
|
i += 1
|
|
if i == len(lines):
|
|
raise BuglogEditError(f"{path}: front matter not closed")
|
|
|
|
summary, sections, current, buf, fence = None, {}, None, [], None
|
|
for ln in lines[i + 1:]:
|
|
m = re.match(r"^\s*(`{3,}|~{3,})", ln)
|
|
if m:
|
|
mark = m.group(1)
|
|
if fence is None:
|
|
fence = mark
|
|
elif mark[0] == fence[0] and len(mark) >= len(fence):
|
|
fence = None
|
|
if fence is None and not m:
|
|
h = _HEADER_RE.match(ln)
|
|
if h:
|
|
if current:
|
|
sections[current] = "\n".join(buf).strip("\n")
|
|
current, buf = h.group(1), []
|
|
continue
|
|
if summary is None and current is None and ln.startswith("# "):
|
|
title = ln[2:].strip()
|
|
prefix = f"{meta.get('id')}: "
|
|
summary = title[len(prefix):] if title.startswith(prefix) else title
|
|
continue
|
|
if current:
|
|
buf.append(ln)
|
|
if current:
|
|
sections[current] = "\n".join(buf).strip("\n")
|
|
return Record(meta, summary or "", sections, path)
|
|
|
|
|
|
def record_path(bug_id):
|
|
if not ID_RE.fullmatch(bug_id or ""):
|
|
raise BuglogEditError(f"{bug_id!r}: not a bug id")
|
|
return BUGS_DIR / f"{bug_id}.md"
|
|
|
|
|
|
def load(bug_id):
|
|
path = record_path(bug_id)
|
|
if not path.exists():
|
|
raise BuglogEditError(f"{bug_id}: no such record")
|
|
return parse(path.read_text(encoding="utf-8"), path)
|
|
|
|
|
|
def _id_key(bug_id):
|
|
m = re.match(r"bug-(\d+)(.*)", bug_id)
|
|
return (int(m.group(1)), m.group(2)) if m else (0, bug_id)
|
|
|
|
|
|
def load_all():
|
|
recs = [parse(p.read_text(encoding="utf-8"), p) for p in BUGS_DIR.glob("*.md")]
|
|
return sorted(recs, key=lambda r: _id_key(r.id or ""))
|
|
|
|
|
|
def _write_atomic(path, text):
|
|
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
tmp.write_text(text, encoding="utf-8")
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def save(rec, reindex=True):
|
|
"""Write a record; refuses text that would not parse back identically."""
|
|
text = render(rec)
|
|
again = parse(text, rec.path)
|
|
if again.meta != rec.meta or again.summary != rec.summary or \
|
|
again.sections != {k: v.strip("\n") for k, v in rec.sections.items()}:
|
|
raise BuglogEditError(f"{rec.id}: record would not round-trip")
|
|
path = record_path(rec.id)
|
|
_write_atomic(path, text)
|
|
if reindex:
|
|
build_index()
|
|
|
|
|
|
def set_meta(bug_id, fields, reindex=True):
|
|
"""Set front-matter fields only; the body bytes are left untouched."""
|
|
path = record_path(bug_id)
|
|
text = path.read_text(encoding="utf-8")
|
|
rec = parse(text, path)
|
|
rec.meta.update(fields)
|
|
head_end = text.index("\n---\n", 3) + len("\n---\n")
|
|
new = render_meta(rec.meta) + text[head_end:]
|
|
if parse(new, path).meta != rec.meta:
|
|
raise BuglogEditError(f"{bug_id}: front matter would not round-trip")
|
|
_write_atomic(path, new)
|
|
if reindex:
|
|
build_index()
|
|
|
|
|
|
# Kept for scripts/gitea-sync.py and older callers.
|
|
def set_fields(_path_ignored, bug_id, fields):
|
|
set_meta(bug_id, fields)
|
|
|
|
|
|
# ── Index ────────────────────────────────────────────────────────────────────
|
|
|
|
def index_entry(rec):
|
|
s, m = rec.sections, rec.meta
|
|
unesc = lambda k: unescape_text(s.get(k, "")) # noqa: E731
|
|
fix = unesc("Fix")
|
|
if s.get("Verification") and s["Verification"] != NOT_RECORDED:
|
|
fix += "\n\nVerification:\n" + unesc("Verification")
|
|
entry = {
|
|
"id": rec.id,
|
|
"status": m.get("status"),
|
|
"status_updated": m.get("status_updated"),
|
|
"status_note": m.get("status_note", ""),
|
|
"component": m.get("component"),
|
|
"severity": m.get("severity"),
|
|
"timestamp": m.get("timestamp"),
|
|
"last_seen": m.get("last_seen", m.get("timestamp")),
|
|
"occurrences": m.get("occurrences", 1),
|
|
"error_message": (rec.summary + "\n\n" + unesc("Description")).strip(),
|
|
"file": ", ".join(m.get("files") or []),
|
|
"root_cause": unesc("Root cause"),
|
|
"fix": fix.strip(),
|
|
"tags": m.get("tags") or [],
|
|
"related_bugs": m.get("related") or [],
|
|
"fix_commits": m.get("fix_commits") or [],
|
|
"record": str(record_path(rec.id).relative_to(ROOT)),
|
|
}
|
|
for k in ("duplicate_of", "repro_attempts", "gitea_issue", "gitea_synced_status"):
|
|
if k in m:
|
|
entry[k] = m[k]
|
|
return entry
|
|
|
|
|
|
def build_index():
|
|
data = {
|
|
"version": 1,
|
|
"generated": "by scripts/buglog.py from .wolf/bugs/*.md -- do not edit",
|
|
"bugs": [index_entry(r) for r in load_all()],
|
|
}
|
|
_write_atomic(INDEX_PATH, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
|
|
|
|
def next_id():
|
|
ids = [p.stem for p in BUGS_DIR.glob("*.md")]
|
|
if ARCHIVE_PATH.exists():
|
|
ids += [b.get("id", "") for b in
|
|
json.loads(ARCHIVE_PATH.read_text(encoding="utf-8")).get("bugs", [])]
|
|
nums = [int(m.group(1)) for i in ids if (m := re.match(r"bug-(\d+)", i))]
|
|
return f"bug-{max(nums, default=0) + 1:03d}"
|
|
|
|
|
|
# ── Checks against the record standard ──────────────────────────────────────
|
|
|
|
def _empty(value):
|
|
v = (value or "").strip()
|
|
return not v or v.upper().startswith("TODO") or v == NOT_RECORDED
|
|
|
|
|
|
def _item(section, label):
|
|
"""Value of a `- Label: value` list item, or None."""
|
|
m = re.search(r"^\s*[-*] " + re.escape(label) + r":[ \t]*(.*)$", section or "", re.M)
|
|
return m.group(1).strip() if m else None
|
|
|
|
|
|
def _bold(section, label):
|
|
"""Text after `**Label:**` up to the next bold label."""
|
|
m = re.search(r"\*\*" + re.escape(label) + r":\*\*(.*?)(?=^\*\*[\w ]+:\*\*|\Z)",
|
|
section or "", re.S | re.M)
|
|
return m.group(1).strip() if m else None
|
|
|
|
|
|
def bad_commits(shas):
|
|
bad = []
|
|
for sha in shas:
|
|
if not re.fullmatch(r"[0-9a-f]{7,40}", str(sha)) or \
|
|
_run(["git", "-C", str(ROOT), "cat-file", "-e", f"{sha}^{{commit}}"]) is None:
|
|
bad.append(sha)
|
|
return bad
|
|
|
|
|
|
def check(rec, target_status=None):
|
|
"""Problems with `rec` under the record standard, as a list of strings.
|
|
|
|
`target_status` checks the record as if it were moved to that status:
|
|
that is how `status` refuses an incomplete change. Legacy (migrated)
|
|
records are exempt from the reporting sections, but a status change to
|
|
fixed/partial still needs the fix-time sections.
|
|
"""
|
|
m, s, P = rec.meta, rec.sections, []
|
|
status = target_status or m.get("status")
|
|
legacy = bool(m.get("legacy"))
|
|
strict_fix = not legacy or target_status is not None
|
|
|
|
if not ID_RE.fullmatch(str(m.get("id", ""))):
|
|
P.append(f"id {m.get('id')!r} is not a bug id")
|
|
elif rec.path and Path(rec.path).stem != m["id"]:
|
|
P.append(f"id {m['id']} does not match file name {Path(rec.path).name}")
|
|
if status not in STATUSES:
|
|
P.append(f"status {status!r} is not one of {', '.join(STATUSES)}")
|
|
if status not in ("fixed", None) and _empty(m.get("status_note")):
|
|
P.append(f"status {status} needs a status_note (what remains / why / what was seen)")
|
|
if status == "duplicate" and not m.get("duplicate_of"):
|
|
P.append("status duplicate needs duplicate_of")
|
|
if status == "cannot-reproduce" and not m.get("repro_attempts"):
|
|
P.append("status cannot-reproduce needs repro_attempts (runs tried, date)")
|
|
|
|
if not legacy:
|
|
if m.get("component") not in COMPONENTS:
|
|
P.append(f"component {m.get('component')!r} is not one of {', '.join(COMPONENTS)}")
|
|
if m.get("severity") not in SEVERITIES:
|
|
P.append(f"severity {m.get('severity')!r} is not one of {', '.join(SEVERITIES)}")
|
|
if not re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?Z", str(m.get("timestamp", ""))):
|
|
P.append("timestamp must be ISO-8601 UTC, e.g. 2026-09-27T14:05:00Z")
|
|
if _empty(rec.summary):
|
|
P.append("Summary (the # title) is empty")
|
|
elif len(rec.summary) > 100:
|
|
P.append(f"Summary is {len(rec.summary)} chars, max 100")
|
|
elif STATUS_WORDS.search(rec.summary):
|
|
P.append("Summary must not carry status words (OPEN/FIXED/PRIORITY)")
|
|
|
|
d = s.get("Description")
|
|
for label in ("Observed", "Expected", "Impact", "Found by"):
|
|
if _empty(_bold(d, label)):
|
|
P.append(f"Description: **{label}:** is missing or empty")
|
|
|
|
e = s.get("Environment")
|
|
for label, rx, why in (
|
|
("OS", r"\d+\.\d+", "distribution/version and kernel version"),
|
|
("libc", r"\d+\.\d+", "a version, from the failing binary's interpreter"),
|
|
("Toolchain", r"\d+\.\d+", "compiler and build tool versions"),
|
|
("Build", r"\b(asan|debug|release)\b", "the build profile: asan, debug or release"),
|
|
("Code", r"\b[0-9a-f]{7,40}\b", "the commit the binary was built from"),
|
|
("Deployment", r"\S", "topology, worker kind, hosts")):
|
|
v = _item(e, label)
|
|
if _empty(v):
|
|
P.append(f"Environment: - {label}: is missing or empty ({why})")
|
|
elif not re.search(rx, v):
|
|
P.append(f"Environment: - {label}: needs {why}")
|
|
|
|
r = s.get("Reproduction") or ""
|
|
steps = re.search(r"^\s*\d+\.\s+(?!TODO)\S", r, re.M) or "```" in r
|
|
if not steps:
|
|
P.append("Reproduction: needs numbered steps (1. <command>) or a code block")
|
|
freq = _item(r, "Frequency")
|
|
if _empty(freq):
|
|
P.append("Reproduction: - Frequency: is missing (deterministic, or N/M runs with the date)")
|
|
elif not re.search(r"deterministic|\d+\s*/\s*\d+", freq, re.I):
|
|
P.append("Reproduction: - Frequency: must be 'deterministic' or 'N/M runs, <date>'")
|
|
if _empty(_item(r, "Evidence")):
|
|
P.append("Reproduction: - Evidence: is missing (a durable path, or 'not kept')")
|
|
|
|
if strict_fix and status in ("fixed", "partial"):
|
|
for name in ("Root cause", "Fix"):
|
|
if _empty(s.get(name)):
|
|
P.append(f"{name}: required for status {status}")
|
|
if strict_fix and status == "fixed":
|
|
v = s.get("Verification")
|
|
if _empty(v):
|
|
P.append("Verification: required for status fixed")
|
|
else:
|
|
for label in ("Test", "Suites"):
|
|
if _empty(_item(v, label)):
|
|
P.append(f"Verification: - {label}: is missing or empty")
|
|
commits = m.get("fix_commits") or []
|
|
if not commits:
|
|
P.append("fix_commits: status fixed needs the fix commit(s) (status ... --commit <sha>)")
|
|
for sha in bad_commits(commits):
|
|
P.append(f"fix_commits: {sha} is not a commit in this repository")
|
|
return P
|
|
|
|
|
|
# ── Environment collection ───────────────────────────────────────────────────
|
|
|
|
def _os_line():
|
|
kernel = f"{platform.system()} {platform.release()} {platform.machine()}"
|
|
distro = None
|
|
for f in ("/etc/os-release", "/usr/lib/os-release"):
|
|
if os.path.exists(f):
|
|
m = re.search(r'^PRETTY_NAME="?([^"\n]*)', Path(f).read_text(), re.M)
|
|
distro = m.group(1) if m else None
|
|
break
|
|
parts = [distro or "no /etc/os-release"]
|
|
if os.path.exists("/.dockerenv"):
|
|
parts.append("Docker container")
|
|
try:
|
|
if "NixOS" in Path("/proc/version").read_text():
|
|
parts.append("NixOS kernel")
|
|
except OSError:
|
|
pass
|
|
return "; ".join(parts + [f"kernel {kernel}"])
|
|
|
|
|
|
def _libc_line(binary):
|
|
if binary:
|
|
out = _run(["readelf", "-l", str(binary)]) or ""
|
|
m = re.search(r"interpreter: ([^\]]+)\]", out)
|
|
if m:
|
|
v = re.search(r"glibc-(\d+\.\d+[\w.-]*)", m.group(1))
|
|
name = f"glibc {v.group(1)}" if v else "unknown libc"
|
|
return f"{name} (interpreter {m.group(1)})"
|
|
out = _run(["ldd", "--version"]) or ""
|
|
v = re.search(r"(\d+\.\d+)", out.split("\n")[0]) if out else None
|
|
host = f"glibc {v.group(1)}" if v else "unknown"
|
|
return f"TODO: pass the failing binary (`buglog.py env <binary>`); host ldd reports {host}"
|
|
|
|
|
|
def _toolchain_line():
|
|
probe = "gcc --version | head -1; meson --version"
|
|
if os.environ.get("IN_NIX_SHELL") or not shutil.which("nix"):
|
|
out = _run(["bash", "-c", probe])
|
|
else:
|
|
out = _run(["nix", "develop", str(ROOT / "src"), "--command", "bash", "-c", probe],
|
|
cwd=str(ROOT))
|
|
if not out:
|
|
return "TODO: gcc and meson versions from the shell that built the binary"
|
|
gcc, _, meson = out.partition("\n")
|
|
return f"{gcc.strip()}, meson {meson.strip()}"
|
|
|
|
|
|
def _build_line(binary):
|
|
profile = None
|
|
if binary:
|
|
for d, p in (("xce.build", "asan"), ("build.dbg", "debug"), ("build.release", "release")):
|
|
if d in str(binary):
|
|
profile = f"{p} ({d})"
|
|
line = profile or "TODO: asan (xce.build) | debug (build.dbg) | release (build.release)"
|
|
if os.environ.get("ASAN_OPTIONS"):
|
|
line += f", ASAN_OPTIONS={os.environ['ASAN_OPTIONS']}"
|
|
return line
|
|
|
|
|
|
def _code_line():
|
|
sha = _run(["git", "-C", str(ROOT), "rev-parse", "--short=8", "HEAD"])
|
|
if not sha:
|
|
return "TODO: branch @ commit"
|
|
branch = _run(["git", "-C", str(ROOT), "rev-parse", "--abbrev-ref", "HEAD"]) or "?"
|
|
dirty = _run(["git", "-C", str(ROOT), "status", "--porcelain", "--untracked-files=no"])
|
|
return f"{branch} @ {sha}" + ("-dirty" if dirty else "")
|
|
|
|
|
|
def collect_env(binary=None):
|
|
return "\n".join([
|
|
f"- OS: {_os_line()}",
|
|
f"- libc: {_libc_line(binary)}",
|
|
f"- Toolchain: {_toolchain_line()}",
|
|
f"- Build: {_build_line(binary)}",
|
|
f"- Code: {_code_line()}",
|
|
"- Deployment: TODO: topology (single / 3-node HA / multihost), worker kind, hosts",
|
|
])
|
|
|
|
|
|
TEMPLATE = {
|
|
"Description": "**Observed:**\nTODO: the exact error, log line or sanitizer text, in a code block\n\n"
|
|
"**Expected:** TODO\n**Impact:** TODO\n"
|
|
"**Found by:** TODO: user report | soak | ASAN | audit | review | test | spec task",
|
|
"Reproduction": "1. TODO: commands someone else can paste\n"
|
|
"- Frequency: TODO: deterministic | N/M runs, <date>\n"
|
|
"- Evidence: TODO: durable path to logs/cores/replays, or 'not kept'",
|
|
"Root cause": "TODO: required for fixed/partial",
|
|
"Fix": "TODO: required for fixed/partial",
|
|
"Verification": "- Test: TODO: name + file; RED before the fix, GREEN after\n"
|
|
"- Suites: TODO: suite results and build profile\n"
|
|
"- Live: TODO: soak/deploy run, or n/a and why",
|
|
"References": "- Related: none",
|
|
}
|
|
|
|
|
|
# ── Commands ─────────────────────────────────────────────────────────────────
|
|
|
|
def _line(r):
|
|
note = r.meta.get("status_note") or r.summary
|
|
return f"{r.id:<10} {r.meta.get('status') or '-':<16} {note[:110]}"
|
|
|
|
|
|
def _fail(problems, what):
|
|
sys.exit(f"{what}:\n" + "\n".join(f" - {p}" for p in problems)
|
|
+ "\nStandard: .claude/skills/buglog/bug-record.md")
|
|
|
|
|
|
def cmd_new(a):
|
|
if a.component not in COMPONENTS:
|
|
sys.exit(f"--component must be one of {', '.join(COMPONENTS)}")
|
|
if a.severity not in SEVERITIES:
|
|
sys.exit(f"--severity must be one of {', '.join(SEVERITIES)}")
|
|
bid, now = next_id(), _now()
|
|
meta = {"id": bid, "status": "open", "status_updated": _today(),
|
|
"status_note": "newly logged", "component": a.component,
|
|
"severity": a.severity, "timestamp": now, "last_seen": now,
|
|
"occurrences": 1, "files": _split(a.files), "tags": _split(a.tags),
|
|
"related": _split(a.related)}
|
|
sections = dict(TEMPLATE)
|
|
sections["Environment"] = collect_env(a.binary)
|
|
rec = Record(meta, a.summary, {k: sections[k] for k in SECTIONS if k in sections})
|
|
save(rec)
|
|
print(record_path(bid).relative_to(ROOT))
|
|
probs = check(load(bid))
|
|
if probs:
|
|
print(f"{len(probs)} item(s) still to fill in; run `buglog.py check {bid}` when done.")
|
|
|
|
|
|
def _staged(pathspec, diff_filter="ACMD"):
|
|
out = _run(["git", "-C", str(ROOT), "diff", "--cached", "--name-only",
|
|
f"--diff-filter={diff_filter}", "--", pathspec]) or ""
|
|
return [p for p in out.split("\n") if p]
|
|
|
|
|
|
def _staged_records():
|
|
return [Path(p).stem for p in _staged(".wolf/bugs/", "ACM") if p.endswith(".md")]
|
|
|
|
|
|
_HEAD_INDEX = None
|
|
|
|
|
|
def _head_status(bug_id):
|
|
"""(found, status) of the bug at HEAD: its record, else its entry in
|
|
HEAD's buglog.json (how the migration commit is recognised)."""
|
|
global _HEAD_INDEX
|
|
text = _run(["git", "-C", str(ROOT), "show", f"HEAD:.wolf/bugs/{bug_id}.md"])
|
|
if text:
|
|
try:
|
|
return True, parse(text).meta.get("status")
|
|
except BuglogEditError:
|
|
return True, None
|
|
if _HEAD_INDEX is None:
|
|
raw = _run(["git", "-C", str(ROOT), "show", "HEAD:.wolf/buglog.json"])
|
|
try:
|
|
_HEAD_INDEX = {b.get("id"): b.get("status")
|
|
for b in json.loads(raw).get("bugs", [])} if raw else {}
|
|
except (json.JSONDecodeError, AttributeError):
|
|
_HEAD_INDEX = {}
|
|
if bug_id in _HEAD_INDEX:
|
|
return True, _HEAD_INDEX[bug_id]
|
|
return False, None
|
|
|
|
|
|
def cmd_check(a):
|
|
global _HEAD_INDEX
|
|
_HEAD_INDEX = None # read HEAD afresh on every check
|
|
ids = a.ids or []
|
|
if a.staged:
|
|
ids = _staged_records()
|
|
elif a.all or not ids:
|
|
ids = [r.id for r in load_all()]
|
|
failed = 0
|
|
for bid in ids:
|
|
rec = load(bid)
|
|
target, extra = None, []
|
|
if a.staged and rec.meta.get("legacy"):
|
|
found, head = _head_status(bid)
|
|
if not found:
|
|
extra.append("a new record cannot be legacy: true (only migrated records are)")
|
|
elif head != rec.meta.get("status"):
|
|
# a legacy record whose status changed in this commit must
|
|
# meet what the new status requires
|
|
target = rec.meta.get("status")
|
|
probs = extra + check(rec, target_status=target)
|
|
if probs:
|
|
failed += 1
|
|
print(f"{bid}:")
|
|
for p in probs:
|
|
print(f" - {p}")
|
|
if a.staged and (_staged(".wolf/bugs/") or _staged(".wolf/buglog.json")):
|
|
expect = json.dumps({"version": 1,
|
|
"generated": "by scripts/buglog.py from .wolf/bugs/*.md -- do not edit",
|
|
"bugs": [index_entry(r) for r in load_all()]},
|
|
indent=2, ensure_ascii=False) + "\n"
|
|
if not INDEX_PATH.exists() or INDEX_PATH.read_text(encoding="utf-8") != expect:
|
|
failed += 1
|
|
print(".wolf/buglog.json is stale: run `python3 scripts/buglog.py index` and stage it")
|
|
if failed:
|
|
sys.exit(f"{failed} record(s) do not meet .claude/skills/buglog/bug-record.md")
|
|
if not a.staged:
|
|
print(f"{len(ids)} record(s) OK")
|
|
|
|
|
|
def cmd_status(a):
|
|
if a.status not in STATUSES:
|
|
sys.exit(f"unknown status {a.status!r}; one of {', '.join(STATUSES)}")
|
|
rec = load(a.id)
|
|
rec.meta.update({"status": a.status, "status_updated": _today(),
|
|
"status_note": a.note or ""})
|
|
if a.commit:
|
|
rec.meta["fix_commits"] = list(dict.fromkeys((rec.meta.get("fix_commits") or [])
|
|
+ a.commit))
|
|
if a.duplicate_of:
|
|
rec.meta["duplicate_of"] = a.duplicate_of
|
|
if a.repro:
|
|
rec.meta["repro_attempts"] = a.repro
|
|
probs = check(rec, target_status=a.status)
|
|
if probs:
|
|
_fail(probs, f"{a.id}: not changed to {a.status}")
|
|
set_meta(a.id, {k: rec.meta[k] for k in rec.meta})
|
|
print(_line(load(a.id)))
|
|
|
|
|
|
def cmd_note(a):
|
|
if a.section not in SECTIONS:
|
|
sys.exit(f"--section must be one of {', '.join(SECTIONS)}")
|
|
rec = load(a.id)
|
|
old = rec.sections.get(a.section, "")
|
|
old = "" if _empty(old) else old
|
|
para = f"UPDATE ({_today()}): {escape_text(a.text)}"
|
|
rec.sections[a.section] = f"{old}\n\n{para}" if old else para
|
|
save(rec)
|
|
|
|
|
|
def cmd_recur(a):
|
|
rec = load(a.id)
|
|
fields = {"occurrences": int(rec.meta.get("occurrences") or 1) + 1, "last_seen": _now()}
|
|
reopen = {"cannot-reproduce": "investigating", "fixed": "open", "wontfix": "open"}
|
|
if rec.meta.get("status") in reopen:
|
|
fields.update(status=reopen[rec.meta["status"]], status_updated=_today())
|
|
fields["status_note"] = a.note or f"recurred {_today()} (was {rec.meta['status']})"
|
|
elif a.note:
|
|
fields["status_note"] = a.note
|
|
set_meta(a.id, fields)
|
|
print(_line(load(a.id)))
|
|
|
|
|
|
def cmd_set(a):
|
|
fields = {}
|
|
for kv in a.pairs:
|
|
key, sep, raw = kv.partition("=")
|
|
if not sep or not key:
|
|
sys.exit(f"bad argument {kv!r}: expected key=value")
|
|
try:
|
|
fields[key] = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
fields[key] = raw
|
|
set_meta(a.id, fields)
|
|
|
|
|
|
def cmd_list(a):
|
|
want = OPEN_STATUSES if a.open else set(_split(a.status)) or None
|
|
rx = re.compile(a.grep, re.I) if a.grep else None
|
|
for r in load_all():
|
|
m = r.meta
|
|
if want is not None and m.get("status") not in want:
|
|
continue
|
|
if a.tag and a.tag not in (m.get("tags") or []):
|
|
continue
|
|
if a.since and max(m.get("last_seen") or "", m.get("status_updated") or "") < a.since:
|
|
continue
|
|
if rx and not rx.search(render(r)):
|
|
continue
|
|
print(_line(r))
|
|
|
|
|
|
def main(argv=None):
|
|
p = argparse.ArgumentParser(description=__doc__.split("\n")[0],
|
|
epilog="Status values: " + ", ".join(STATUSES))
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
s = sub.add_parser("new")
|
|
s.add_argument("--summary", required=True)
|
|
s.add_argument("--component", required=True, help=", ".join(COMPONENTS))
|
|
s.add_argument("--severity", required=True, help=", ".join(SEVERITIES))
|
|
s.add_argument("--binary", help="the failing binary, for its libc and build profile")
|
|
s.add_argument("--tags")
|
|
s.add_argument("--files")
|
|
s.add_argument("--related")
|
|
s.set_defaults(fn=cmd_new)
|
|
|
|
s = sub.add_parser("check")
|
|
s.add_argument("ids", nargs="*")
|
|
s.add_argument("--all", action="store_true")
|
|
s.add_argument("--staged", action="store_true",
|
|
help="records staged for commit (the pre-commit hook)")
|
|
s.set_defaults(fn=cmd_check)
|
|
|
|
s = sub.add_parser("status")
|
|
s.add_argument("id")
|
|
s.add_argument("status")
|
|
s.add_argument("--note")
|
|
s.add_argument("--commit", action="append", default=[],
|
|
help="fix commit (repeatable); required for fixed")
|
|
s.add_argument("--duplicate-of")
|
|
s.add_argument("--repro")
|
|
s.set_defaults(fn=cmd_status)
|
|
|
|
s = sub.add_parser("note")
|
|
s.add_argument("id")
|
|
s.add_argument("text")
|
|
s.add_argument("--section", default="Description")
|
|
s.set_defaults(fn=cmd_note)
|
|
|
|
s = sub.add_parser("recur")
|
|
s.add_argument("id")
|
|
s.add_argument("--note")
|
|
s.set_defaults(fn=cmd_recur)
|
|
|
|
s = sub.add_parser("set")
|
|
s.add_argument("id")
|
|
s.add_argument("pairs", nargs="+")
|
|
s.set_defaults(fn=cmd_set)
|
|
|
|
s = sub.add_parser("show")
|
|
s.add_argument("id")
|
|
s.set_defaults(fn=lambda a: print(render(load(a.id)), end=""))
|
|
|
|
s = sub.add_parser("list")
|
|
s.add_argument("--status")
|
|
s.add_argument("--open", action="store_true")
|
|
s.add_argument("--tag")
|
|
s.add_argument("--grep")
|
|
s.add_argument("--since")
|
|
s.set_defaults(fn=cmd_list)
|
|
|
|
s = sub.add_parser("triage")
|
|
s.set_defaults(fn=lambda a: [print(_line(r)) for r in load_all()
|
|
if r.meta.get("status") not in STATUSES])
|
|
|
|
s = sub.add_parser("env")
|
|
s.add_argument("binary", nargs="?")
|
|
s.set_defaults(fn=lambda a: print(collect_env(a.binary)))
|
|
|
|
s = sub.add_parser("index")
|
|
s.set_defaults(fn=lambda a: build_index())
|
|
|
|
s = sub.add_parser("next-id")
|
|
s.set_defaults(fn=lambda a: print(next_id()))
|
|
|
|
a = p.parse_args(argv)
|
|
try:
|
|
a.fn(a)
|
|
except BuglogEditError as e:
|
|
sys.exit(str(e))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|