tools/bugreport: --bundle/--chronology tauscht die Chronologie eines vorhandenen Bündels - Geheimnisse, Titelschutz und Pseudonyme wie beim Sammeln, kein wikitool-Aufruf, kein zweites Bündel; bug-report.md prüft die Chronologie vor dem Sammeln und nennt den Austausch als Entscheidungspunkt (#220); 10.1.0-beta.15
CI / verify (push) Successful in 8m17s
CI / pwsh (push) Successful in 2m53s
Release / converters (push) Successful in 26s
Release / release (push) Successful in 37s
Release / wiki-mirror (push) Skipped

Files changed:
- CHANGES.md
- VERSION
- docs/how-to/troubleshoot.md
- instructions/bug-report.md
- tools/README.md
- tools/bugreport.py
- tools/chemenu/tests/test_bugreport.py

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Sonnet 5.5 committed 2026-10-16 06:50:53 +02:00
1 parent b219adde91
commit b48a35b224
7 files changed
+371 -27

No files matched your search

+6
View File
@@ -148,6 +148,12 @@ exits 0 or 1 - never 42, since it opens no gate. It has a second mode: `--pseudo
identities it can read from the machine (stage 1), and `--bundle`/`--candidates` applies names a
model found in the result (stage 2), both word by word so that length, separators and depth
survive. The mapping, the review list and the candidate file stay beside the bundle directory.
A third mode, `--bundle`/`--chronology` (with `--root` if the checkout is not the one holding
`tools/`), swaps the chronology of a bundle that exists already: it runs no `wikitool` call and
writes no second bundle, changes `CHRONOLOGY.md` and the zip (and the review list of a
pseudonymised bundle), and puts the new text through the same secret scrub, title shield and
pseudonyms the collection would have. A bundle collected without a chronology gets one, and its
manifest changes in three fixed places.
`dist export` ships it with the rest of `tools/`;
its tests (`tests/test_bugreport.py`, in the origin repository) run it on a bare interpreter (`-I -S`) to keep that promise.
+148 -23
View File
@@ -10,6 +10,7 @@ Runs on the base Python with the standard library only, and imports nothing from
[--session ID | --no-trace] [--titles]
[--pseudonymise] [--root DIR] [--out DIR]
tools/bugreport --bundle DIR --candidates FILE
tools/bugreport --bundle DIR --chronology FILE [--root DIR]
`tools/bugreport` (and `tools/bugreport.ps1` under PowerShell) finds a Python 3.8
or newer and runs this file with it, skipping the Microsoft Store's aliases that
@@ -21,7 +22,9 @@ layers: the environment, the stack, what `wikitool` prints (if it starts), and
the agent's chronology. Secrets are always removed. Page titles are kept out of
everything this script generates unless `--titles` is given. With `--pseudonymise`
known identities are replaced by consistent, shape-preserving placeholders (stage 1);
`--bundle`/`--candidates` applies the names a model found beyond them (stage 2). The
`--bundle`/`--candidates` applies the names a model found beyond them (stage 2).
`--bundle`/`--chronology` swaps the chronology of a bundle that exists already, with the
same processing and without a second run: no `wikitool` call, no second bundle. The
mapping stays beside the bundle, never in it. Nothing is uploaded.
Exit 0 = bundle written, 1 = it could not be written. No exit 42: this script
@@ -1171,15 +1174,25 @@ def build_manifest(args, root, stamp, bundle, wikitool, trace, gaps) -> str:
return "\n".join(lines) + "\n"
def clean_text(text: str, red: Redactor, shield) -> str:
"""What every generated or supplied text goes through: secrets, then page titles."""
cleaned = red.scrub(text)
if shield is not None:
cleaned = shield.apply(cleaned)
return cleaned
def title_shield(content_paths):
return TitleShield(rel for rel in content_paths if rel.rsplit("/", 1)[-1] not in STACK_NAMES)
def final_pass(bundle_dir: Path, red: Redactor, shield, skip) -> None:
for path in sorted(bundle_dir.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(bundle_dir).as_posix()
text = read_text(path)
cleaned = red.scrub(text)
if shield is not None and rel not in skip:
cleaned = shield.apply(cleaned)
cleaned = clean_text(text, red, None if rel in skip else shield)
if cleaned != text:
write_text(path, cleaned)
@@ -1210,22 +1223,32 @@ def parse_args(argv):
"placeholders (stage 1); the mapping stays beside the bundle")
parser.add_argument("--root", help="checkout root (default: parent of tools/)")
parser.add_argument("--out", help="output directory (default: <root>/reports)")
parser.add_argument("--bundle", help="stage 2: an existing pseudonymised bundle directory")
parser.add_argument("--bundle", help="an existing bundle directory: stage 2 with --candidates, "
"a new chronology with --chronology")
parser.add_argument("--candidates", help="stage 2: file with one further name per line "
"('#' starts a comment)")
args = parser.parse_args(argv)
if bool(args.bundle) != bool(args.candidates):
parser.error("--bundle and --candidates belong together")
if args.bundle and (args.chronology or args.transcript or args.session or args.no_trace
or args.titles or args.pseudonymise or args.root or args.out):
parser.error("--bundle/--candidates excludes every collection option")
if args.candidates and not args.bundle:
parser.error("--candidates belongs with --bundle")
if args.bundle:
if args.candidates and args.chronology:
parser.error("--bundle takes --candidates or --chronology, not both")
if not (args.candidates or args.chronology):
parser.error("--bundle needs --candidates (stage 2) or --chronology (swap)")
if (args.transcript or args.session or args.no_trace or args.titles
or args.pseudonymise or args.out or (args.candidates and args.root)):
parser.error("--bundle excludes every collection option "
"(--root is allowed with --chronology only)")
return args
def main(argv=None) -> int:
args = parse_args(argv)
if args.bundle:
if args.bundle and args.candidates:
return run_stage2(Path(args.bundle), Path(args.candidates))
if args.bundle:
root = Path(args.root).resolve() if args.root else Path(__file__).resolve().parent.parent
return run_exchange(Path(args.bundle), Path(args.chronology), root)
root = Path(args.root).resolve() if args.root else Path(__file__).resolve().parent.parent
out = Path(args.out).resolve() if args.out else root / "reports"
@@ -1308,9 +1331,7 @@ def _collect(args, root: Path, out: Path, stamp: str, bundle_dir: Path) -> int:
shield = None
if not args.titles:
shield = TitleShield(
rel for rel in content_paths if rel.rsplit("/", 1)[-1] not in STACK_NAMES
)
shield = title_shield(content_paths)
final_pass(bundle_dir, red, shield, {"tree-paths.txt"})
pz = None
@@ -1354,25 +1375,30 @@ def parse_candidates(text: str):
return names
def load_pseudonymiser(mapping: Path):
"""The pseudonymiser stage 1 left beside a bundle, or the reason it cannot be had."""
if not mapping.is_file():
return None, ("no mapping beside the bundle (%s): stage 2 needs the salt stage 1 used, so "
"collect the report again with --pseudonymise" % mapping.name)
try:
return Pseudonymiser.from_json(json.loads(read_text(mapping))), None
except (ValueError, KeyError) as exc:
return None, "the mapping cannot be read: %s" % exc
def run_stage2(bundle_dir: Path, candidates: Path) -> int:
bundle_dir = bundle_dir.resolve()
mapping = _sibling(bundle_dir, ".pseudonyms.json")
problem = None
pz, problem = None, None
if not bundle_dir.is_dir():
problem = "bundle directory not found: %s" % bundle_dir
elif not candidates.is_file():
problem = "candidates file not found: %s" % candidates
elif not mapping.is_file():
problem = ("no mapping beside the bundle (%s): stage 2 needs the salt stage 1 used, so "
"collect the report again with --pseudonymise" % mapping.name)
else:
pz, problem = load_pseudonymiser(mapping)
if problem:
sys.stderr.write("bugreport: %s\n" % problem)
return 1
try:
pz = Pseudonymiser.from_json(json.loads(read_text(mapping)))
except (ValueError, KeyError) as exc:
sys.stderr.write("bugreport: the mapping cannot be read: %s\n" % exc)
return 1
files = text_files(bundle_dir)
corpus = "\n".join(read_text(path) for path in files)
@@ -1424,6 +1450,105 @@ def run_stage2(bundle_dir: Path, candidates: Path) -> int:
return 0
TITLES_LINE = re.compile(r"(?m)^- page titles \(`--titles`\): (included|kept out)$")
PSEUDONYMISATION_LINE = re.compile(r"(?m)^- pseudonymisation \(`--pseudonymise`\): (.*)$")
CHRONOLOGY_ROW = ("| `CHRONOLOGY.md` | 4 | the agent's chronology | %s |" % MAY_CONTAIN_CONTENT)
NO_CHRONOLOGY_GAP = "- no chronology was supplied"
def manifest_with_chronology(manifest: str) -> str:
"""The three fixed places a bundle collected without a chronology changes in."""
manifest = manifest.replace("- chronology: not supplied", "- chronology: supplied", 1)
lines = manifest.split("\n")
if CHRONOLOGY_ROW not in lines:
end = lines.index("| `MANIFEST.md` | - | this file | |")
at = end
for index in range(end - 1, -1, -1):
if not lines[index].startswith("| `"):
break
if lines[index].split("`")[1] > "CHRONOLOGY.md":
at = index
lines.insert(at, CHRONOLOGY_ROW)
sections = re.split(r"(?m)^(?=## )", "\n".join(lines))
kept = []
for section in sections:
if section.startswith("## Gaps\n"):
bullets = [ln for ln in section.splitlines()[1:] if ln.strip() and ln != NO_CHRONOLOGY_GAP]
if not bullets:
continue
section = "\n".join(["## Gaps", ""] + bullets) + "\n"
kept.append(section)
return "\n\n".join(part.strip("\n") for part in kept if part.strip()) + "\n"
def run_exchange(bundle_dir: Path, chronology: Path, root: Path) -> int:
"""Swap (or add) CHRONOLOGY.md of an existing bundle: no wikitool call, no second bundle."""
bundle_dir = bundle_dir.resolve()
manifest_path = bundle_dir / "MANIFEST.md"
problem, manifest, titles = None, "", None
if not bundle_dir.is_dir():
problem = "bundle directory not found: %s" % bundle_dir
elif not chronology.is_file():
problem = "chronology file not found: %s" % chronology
elif not manifest_path.is_file():
problem = "no MANIFEST.md in %s: not a bundle of this script" % bundle_dir
else:
manifest = read_text(manifest_path)
titles = TITLES_LINE.search(manifest)
if titles is None:
problem = "MANIFEST.md in %s has no page titles line: not a bundle of this script" % bundle_dir
pz, pseudonymisation = None, PSEUDONYMISATION_LINE.search(manifest)
if problem is None and pseudonymisation is not None:
pz, problem = load_pseudonymiser(_sibling(bundle_dir, ".pseudonyms.json"))
if problem:
sys.stderr.write("bugreport: %s\n" % problem)
return 1
# Everything is worked out in memory first; nothing is written before it all succeeded.
red = Redactor()
guarded(collect_environment, root, red)
guarded(collect_stack, root, red)
shield = None
if titles.group(1) == "kept out":
shield = title_shield(f for name in CONTENT_DIRS for f in _safe_scan(root, name)[1])
text = clean_text(read_text(chronology), red, shield)
if pz is not None:
text = pz.apply(text)
target = bundle_dir / "CHRONOLOGY.md"
added = not target.is_file()
new_manifest = manifest_with_chronology(manifest) if added else None
archive = bundle_dir.with_name(bundle_dir.name + ".zip")
review = _sibling(bundle_dir, ".review.txt")
try:
write_text(target, text)
if new_manifest is not None:
write_text(manifest_path, new_manifest)
if pz is not None:
write_text(review, build_review(bundle_dir, pz))
temporary = archive.with_name(archive.name + ".tmp")
make_zip(bundle_dir, temporary)
os.replace(str(temporary), str(archive))
except OSError as exc:
sys.stderr.write("bugreport: cannot write the bundle: %s\n" % exc)
return 1
print("Bundle: %s" % bundle_dir)
print("Archive: %s" % archive)
if pz is not None:
print("Review: %s" % review)
print("Read CHRONOLOGY.md and the review list again, then run stage 2: "
"tools/bugreport --bundle %s --candidates <file>" % bundle_dir)
print()
if pz is None:
print(PRIVACY_NOTICE)
elif "stage 1 and stage 2 applied" in pseudonymisation.group(1):
print(RESIDUAL_NOTICE)
else:
print(STAGE1_NOTICE)
return 0
def _safe_scan(root: Path, name: str):
try:
return scan_tree(root, name)
+168
View File
@@ -557,3 +557,171 @@ def test_bundle_and_candidates_exclude_the_collection_options(checkout, tmp_path
def test_without_the_flag_nothing_but_the_bundle_and_its_archive_is_written(checkout, tmp_path):
collect(checkout, tmp_path, "--no-trace")
assert sorted(p.suffix for p in (tmp_path / "out").iterdir()) == ["", ".zip"]
# ------------------------------------------------------------ swapping the chronology
def swap(bundle: Path, chronology: Path, *extra: str) -> int:
return bugreport.main(["--bundle", str(bundle), "--chronology", str(chronology), *extra])
def entries(out: Path) -> list:
return sorted(p.name for p in out.iterdir())
def archive_bytes(bundle: Path) -> bytes:
return bundle.with_name(bundle.name + ".zip").read_bytes()
def test_a_swapped_chronology_changes_nothing_else_and_makes_no_second_bundle(checkout, tmp_path):
old = tmp_path / "old.md"
old.write_text("1. `types describe <entity|concept|...>`\n")
bundle = collect(checkout, tmp_path, "--no-trace", "--chronology", str(old))
before, listing = snapshot(bundle), entries(tmp_path / "out")
new = tmp_path / "new.md"
new.write_text("1. `types describe entity` - exit 0\n")
assert swap(bundle, new, "--root", str(checkout)) == 0
after = snapshot(bundle)
assert after.pop("CHRONOLOGY.md") == b"1. `types describe entity` - exit 0\n"
assert before.pop("CHRONOLOGY.md") != b"1. `types describe entity` - exit 0\n"
assert after == before # wikitool/*.txt and MANIFEST.md included
assert entries(tmp_path / "out") == listing
assert "types describe entity` - exit 0" in zip_text(bundle)
assert "<entity|concept" not in zip_text(bundle)
def test_a_swapped_chronology_is_scrubbed_of_a_secret_from_a_config_file(checkout, tmp_path):
bundle = collect(checkout, tmp_path, "--no-trace")
new = tmp_path / "new.md"
new.write_text(f"1. Setup failed with {TOKEN} in the header\n")
assert swap(bundle, new, "--root", str(checkout)) == 0
text = (bundle / "CHRONOLOGY.md").read_text()
assert TOKEN not in text and "1. Setup failed" in text
assert TOKEN not in zip_text(bundle)
def test_a_swapped_chronology_keeps_titles_out_unless_the_bundle_was_collected_with_them(
checkout, tmp_path
):
new = tmp_path / "new.md"
new.write_text(f"see kb/Concepts/{TITLE}.md and [[{OTHER_TITLE}]]\n")
hidden = collect(checkout, tmp_path / "a", "--no-trace")
assert swap(hidden, new, "--root", str(checkout)) == 0
text = (hidden / "CHRONOLOGY.md").read_text()
assert TITLE not in text and OTHER_TITLE not in text and "[[<title" in text
shown = collect(checkout, tmp_path / "b", "--no-trace", "--titles")
assert swap(shown, new, "--root", str(checkout)) == 0
assert TITLE in (shown / "CHRONOLOGY.md").read_text()
def test_a_swapped_chronology_in_a_pseudonymised_bundle_uses_both_stages_and_the_same_mapping(
checkout, tmp_path, monkeypatch, capsys
):
first = tmp_path / "first.md"
first.write_text("Kunde Acme Corp zahlt.\n")
bundle = pseudonymised(checkout, tmp_path, monkeypatch, "--chronology", str(first))
candidates = tmp_path / "candidates.txt"
candidates.write_text("Acme Corp\n")
assert bugreport.main(["--bundle", str(bundle), "--candidates", str(candidates)]) == 0
stage_two_word = (bundle / "CHRONOLOGY.md").read_text().split()[1]
mapping, review = mapping_of(bundle).read_bytes(), bundle.with_name(bundle.name + ".review.txt")
review.write_text("stale\n")
manifest_before = (bundle / "MANIFEST.md").read_bytes()
new = tmp_path / "new.md"
new.write_text("Acme Corp hat Erika Mustermann angerufen\n")
capsys.readouterr()
assert swap(bundle, new, "--root", str(checkout)) == 0
text = (bundle / "CHRONOLOGY.md").read_text()
for original in ("Acme", "Erika", "Mustermann"):
assert original not in text and original not in zip_text(bundle)
assert text.startswith(stage_two_word + " ") # same placeholder as the stage 2 run gave
assert mapping_of(bundle).read_bytes() == mapping
assert (bundle / "MANIFEST.md").read_bytes() == manifest_before
assert review.read_text() != "stale\n"
printed = capsys.readouterr().out
assert "Review:" in printed and "stage 1 and stage 2 applied" in printed
def test_a_pseudonymised_bundle_without_its_mapping_is_left_as_it_was(
checkout, tmp_path, monkeypatch, capsys
):
bundle = pseudonymised(checkout, tmp_path, monkeypatch)
mapping_of(bundle).unlink()
before, archive = snapshot(bundle), archive_bytes(bundle)
new = tmp_path / "new.md"
new.write_text("Erika\n")
assert swap(bundle, new, "--root", str(checkout)) == 1
assert "collect the report again" in capsys.readouterr().err
assert snapshot(bundle) == before and archive_bytes(bundle) == archive
def test_a_swap_adds_a_missing_chronology_and_touches_the_manifest_in_three_places(
checkout, tmp_path
):
bundle = collect(checkout, tmp_path, "--no-trace")
assert not (bundle / "CHRONOLOGY.md").exists()
before = snapshot(bundle)
new = tmp_path / "new.md"
new.write_text("1. `doctor` - exit 1\n")
assert swap(bundle, new, "--root", str(checkout)) == 0
after = snapshot(bundle)
assert after.pop("CHRONOLOGY.md") == b"1. `doctor` - exit 1\n"
manifest_before, manifest_after = before.pop("MANIFEST.md"), after.pop("MANIFEST.md")
assert after == before
old_lines, new_lines = manifest_before.decode().splitlines(), manifest_after.decode().splitlines()
removed = [ln for ln in old_lines if ln not in new_lines]
added = [ln for ln in new_lines if ln not in old_lines]
assert sorted(removed) == ["## Gaps", "- chronology: not supplied", "- no chronology was supplied"]
assert sorted(added) == [
"- chronology: supplied",
"| `CHRONOLOGY.md` | 4 | the agent's chronology | may contain page content and titles |",
]
files = new_lines[new_lines.index("## Files"):]
assert files[4].startswith("| `CHRONOLOGY.md`") # sorted like build_manifest sorts
assert new_lines[-1] == "| `MANIFEST.md` | - | this file | |"
assert "`doctor` - exit 1" in zip_text(bundle)
def test_a_swap_keeps_the_other_gaps(checkout, tmp_path, monkeypatch):
monkeypatch.setattr(bugreport, "launcher_command", lambda root: None)
bundle = collect(checkout, tmp_path, "--no-trace")
new = tmp_path / "new.md"
new.write_text("text\n")
assert swap(bundle, new, "--root", str(checkout)) == 0
manifest = (bundle / "MANIFEST.md").read_text()
assert "## Gaps" in manifest and "no launcher found" in manifest
assert "no chronology was supplied" not in manifest
def test_a_swap_with_a_missing_file_or_no_manifest_changes_nothing(checkout, tmp_path, capsys):
bundle = collect(checkout, tmp_path, "--no-trace")
before, archive = snapshot(bundle), archive_bytes(bundle)
assert swap(bundle, tmp_path / "nowhere.md", "--root", str(checkout)) == 1
assert "chronology file not found" in capsys.readouterr().err
new = tmp_path / "new.md"
new.write_text("text\n")
(bundle / "MANIFEST.md").unlink()
before.pop("MANIFEST.md")
assert swap(bundle, new, "--root", str(checkout)) == 1
assert "MANIFEST.md" in capsys.readouterr().err
(bundle / "MANIFEST.md").write_text("# Bug report bundle\n")
assert swap(bundle, new, "--root", str(checkout)) == 1
assert "not a bundle of this script" in capsys.readouterr().err
assert not (bundle / "CHRONOLOGY.md").exists()
assert archive_bytes(bundle) == archive
def test_bundle_takes_exactly_one_of_candidates_and_chronology(checkout, tmp_path):
with pytest.raises(SystemExit):
bugreport.main(["--bundle", "b", "--candidates", "c", "--chronology", "x"])
with pytest.raises(SystemExit):
bugreport.main(["--bundle", "b"])
for extra in (["--no-trace"], ["--titles"], ["--pseudonymise"], ["--session", "s"],
["--transcript", "t"], ["--out", str(tmp_path)]):
with pytest.raises(SystemExit):
bugreport.main(["--bundle", "b", "--chronology", "x", *extra])
with pytest.raises(SystemExit):
bugreport.main(["--bundle", "b", "--candidates", "c", "--root", str(checkout)])