blob: 343ee2e0109f6c0325d0df166730041134380700 [file]
#!/usr/bin/env python3
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
"""Structural report over the beads graph.
ADVISORY ONLY. This never blocks anything. Run as a gate it would fire constantly
on legitimate work -- a one-line fix has no record bead and no decisions, and that
is correct.
Two scopes:
bin/beads-report.py --root tp-abc At merge, before pinning. Reads just that
subtree via `bd children`. Bounded and fast.
bin/beads-report.py At release, before purging. Reads the whole
database via `bd export`. Its most valuable
output is "here is every piece of rationale
about to be deleted" -- review it, pin what
should survive, then purge.
Checks divide into two kinds, and the distinction matters:
OBJECTIVE dangling edge targets; rationale on closed unpinned beads.
Defects and facts. No judgment involved.
HEURISTIC decisions with no rejected alternative recorded; roots with
several tasks and no decisions; pinned roots with no commit
record. These cannot distinguish "no decisions were made" from
"decisions were not captured", so they are questions for a
human, never verdicts.
Neither kind can judge whether design text is real reasoning or fluent filler.
Structure is checkable; substance is not.
"""
import argparse
import collections
import json
import re
import subprocess
import sys
# A commit record is titled and referenced owner/repo@<sha> -- PRIME.md section 6.
COMMIT_REF = re.compile(r"@[0-9a-f]{7,40}$")
def bd(*args):
"""Run a bd command with --json and return the parsed result."""
proc = subprocess.run(["bd", *args, "--json"], capture_output=True, text=True)
if proc.returncode != 0:
sys.exit(f"bd {' '.join(args)} failed: {proc.stderr.strip()}")
if not proc.stdout.strip():
return []
return json.loads(proc.stdout)
def as_list(payload):
"""bd sometimes returns a bare object where a list is expected."""
if isinstance(payload, dict):
return payload.get("issues", [payload])
return payload
def load_export():
"""Whole database, one record per line. Carries design and comments."""
proc = subprocess.run(["bd", "export"], capture_output=True, text=True)
if proc.returncode != 0:
sys.exit(f"bd export failed: {proc.stderr.strip()}")
beads = {}
for line in proc.stdout.splitlines():
line = line.strip()
if not line:
continue
record = json.loads(line)
if record.get("_type") == "issue":
beads[record["id"]] = record
return beads
def load_subtree(root):
"""One root's subtree, gathered by descending one level at a time.
`bd children` recurses in its text output but NOT under `--json`, which
returns direct children only -- so a single call silently truncates the
subtree to depth 1. Verified against a four-level tree at bd 1.1.2.
The records returned carry no `design` field, only comment_count, so
rationale-at-risk detection is weaker in this scope than at release scope."""
beads = {}
for record in as_list(bd("show", root)):
beads.setdefault(record["id"], record)
frontier = [root]
seen = {root}
while frontier:
node = frontier.pop()
for child in as_list(bd("children", node)):
cid = child["id"]
beads.setdefault(cid, child)
if cid not in seen:
seen.add(cid)
frontier.append(cid)
return beads
def edges_of(beads):
return [
(bid, dep["depends_on_id"], dep["type"])
for bid, bead in beads.items()
for dep in bead.get("dependencies", [])
]
def report(title, rows):
print(f"\n{title}")
print(" (none)" if not rows else "\n".join(f" {r}" for r in rows))
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--root", help="limit to one root's subtree (merge scope)")
args = parser.parse_args()
scoped = bool(args.root)
beads = load_subtree(args.root) if scoped else load_export()
edges = edges_of(beads)
neighbours = collections.defaultdict(set)
for src, dst, _ in edges:
neighbours[src].add(dst)
neighbours[dst].add(src)
# --- OBJECTIVE ---------------------------------------------------------
# Dangling targets are only meaningful against the whole graph; in a subtree
# a "missing" target is usually just outside the scope.
if not scoped:
report("Dangling dependency targets", [
f"{s} -[{t}]-> {d} (target missing)"
for s, d, t in edges if d not in beads
])
at_risk = []
for bead in beads.values():
if bead.get("status") != "closed":
continue # pinned and open beads are not purge-eligible
carried = []
if bead.get("comment_count") or bead.get("comments"):
n = bead.get("comment_count") or len(bead.get("comments") or [])
carried.append(f"{n} comment(s)")
if (bead.get("design") or "").strip():
carried.append("design text")
if carried:
at_risk.append(f"{bead['id']:18} {', '.join(carried):22} {bead['title'][:44]}")
report("Rationale on closed beads (lost at next purge -- pin to keep)", at_risk)
if scoped:
print(" note: subtree scope cannot see `design` text; comment counts only")
# --- HEURISTIC ---------------------------------------------------------
# A rejected alternative's neighbour is the decision that beat it, which is not
# itself rejected -- so only the chosen side of a pair is worth checking here.
lonely = [
f"{b['id']:18} {b['title'][:50]}"
for b in beads.values()
if b.get("issue_type") == "decision"
and (b.get("metadata") or {}).get("rejected") is not True
and not any((beads[n].get("metadata") or {}).get("rejected") is True
for n in neighbours[b["id"]] if n in beads)
]
report("Decisions with no rejected alternative recorded (suspect)", lonely)
# `bd children --json` carries a `parent` field; `bd export` does not -- there
# the hierarchy lives only in parent-child edges, which point child -> parent.
parent_of = {b: bead["parent"] for b, bead in beads.items() if bead.get("parent")}
for src, dst, typ in edges:
if typ == "parent-child":
parent_of.setdefault(src, dst)
children = collections.defaultdict(list)
for bid, parent in parent_of.items():
children[parent].append(beads[bid])
roots = [args.root] if scoped else [
b for b in beads if b not in parent_of and children.get(b)
]
thin = []
for root in roots:
if root not in beads:
continue
kids = children.get(root, [])
kinds = collections.Counter(k.get("issue_type") for k in kids)
linked = [beads[n] for n in neighbours[root] if n in beads]
flags = []
if not any(x.get("issue_type") == "record" for x in kids + linked):
flags.append("no record")
if kinds.get("task", 0) >= 3 and not kinds.get("decision"):
flags.append(f"{kinds['task']} tasks, 0 decisions")
# Only a pinned root has merged, so only a pinned root can be missing the
# commit record that `bin/beads-commits.py` writes at merge. A root pinned
# before the convention existed trips this legitimately.
if beads[root].get("status") == "pinned" and not any(
x.get("issue_type") == "record"
and COMMIT_REF.search(x.get("external_ref") or x.get("title") or "")
for x in kids + linked):
flags.append("no commits")
if flags:
thin.append(f"{root:18} {', '.join(flags):26} {beads[root]['title'][:34]}")
report("Roots that look thin (a question, not a verdict)", thin)
scope = f"subtree of {args.root}" if scoped else "whole database"
print(f"\n{len(beads)} beads, {len(edges)} edges, {len(roots)} root(s) [{scope}]")
if __name__ == "__main__":
main()