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

+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)