feat: bug-report collector tools/bugreport.py and instructions/bug-report.md (#157)
CI / verify (push) Successful in 2m11s
Release / release (push) Successful in 38s

Files changed:
- .gitea/workflows/ci.yml
- CHANGES.md
- INSTALL.md
- VERSION
- instructions/bug-report.md
- instructions/gates.md
- instructions/setup-instance.md
- instructions/upgrade-instance.md
- reports/CONTRACT.md
- tools/README.md
- tools/bugreport.py
- tools/chemenu/tests/test_bugreport.py
This commit is contained in:
torben committed 2026-09-30 17:29:39 +02:00
1 parent 40413f966d
commit f9c047bd2e
12 files changed
+1497 -3

No files matched your search

+15
View File
@@ -279,3 +279,18 @@ jobs:
echo "reports/telemetry/ exists in a fresh distributed instance - telemetry should default off"
exit 1
fi
- name: The bug-report collector works in the distribution
# tools/bugreport.py is the one part of the stack that must run when
# nothing else does, so it is exercised as shipped: from the export,
# on the runner's plain python3, in the instance the step above set up.
# The unit tests cover what it collects; this covers that it arrives.
run: |
set -eu
cd "$DIST_DIR"
python3 tools/bugreport.py --no-trace
bundle=$(ls -d reports/bugreport-*/ | head -n 1)
test -f "$bundle/MANIFEST.md"
test -f "$bundle/environment.json"
grep -q '`wikitool` started' "$bundle/MANIFEST.md"
ls reports/bugreport-*.zip
+30 -1
View File
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
---
## 8.0.0-beta.4 - 2026-09-30 - Demo corpus follows the decided project pages: three states, seed with their items (#156)
## 8.0.0-beta.5 - 2026-09-30 - Bug-report collector: tools/bugreport.py and instructions/bug-report.md
**Author:** Torben Nehmer
@@ -80,6 +80,7 @@ concern - readable here, never shipped as something to parse.
- Budget gate and loop-breaker refusals exit without a traceback
- Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (#162)
- Live tracker suite: WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image
- Bug-report collector: tools/bugreport.py and instructions/bug-report.md
**Low impact**
- version bump no longer points at version release in its output
@@ -111,6 +112,34 @@ concern - readable here, never shipped as something to parse.
- Demo corpus follows the decided project pages: three states, seed with their items (#156)
<!-- /wikitool:bumps -->
### Bug-report collector: tools/bugreport.py and instructions/bug-report.md (Gitea #157)
A failure on one machine used to reach the maintainer as a description. `tools/bugreport.py` now
collects the first round of answers into one bundle, and it does so when `wikitool` itself does
not start: it uses the standard library only, imports nothing from `chemenu`, and keeps to
Python 3.8 syntax.
- **Four layers.** The environment (OS, every Python and shell found, harness, `PATH`, git
configuration, venv, line endings, on Windows also long paths, execution policy and
mark-of-the-web), the stack (`VERSION`, the `.wikitool-*.json` files, git status and log, the
shape of `kb/` and `raw/` with path lengths and names that break on Windows), verbatim output of
`version show`, `doctor`, `budget status`, `instructions verify` and `docs verify` plus the
caller's session trace, and the agent's chronology with any transcripts. The result is
`reports/bugreport-<UTC stamp>/` and a zip beside it.
- **Secrets are always removed; page titles are kept out of everything the script generates**
unless `--titles` is given. Environment variable names are all recorded, values only for a
fixed list. The trace, the chronology and the transcripts are marked in the manifest as
possibly containing page content and titles. Nothing is uploaded.
- **Its own session id.** The two counting calls run under `WIKITOOL_SESSION_ID=bugreport-<stamp>`,
so collecting a report neither spends nor is refused by the caller's budget. `gates.md` §
"Taking a new session id" names this as the second permitted case, for that script alone.
- **`instructions/bug-report.md`** (`manual: true`) is the agent's side: what to tell the user,
a fact-only chronology template, and the rule to stop before sending anything. `setup-instance.md`
and `upgrade-instance.md` offer it at a failure that has no obvious cause.
- `reports/CONTRACT.md` names the bundle as a third kind of output, `tools/README.md` explains why the
script sits beside the package, `INSTALL.md` has a troubleshooting entry, and CI runs the script
from the exported distribution.
### Demo corpus follows the decided project pages: three states, seed with their items (Gitea #156)
The first build of the live tracker suite shipped two demo project pages whose names came from a
+7
View File
@@ -473,6 +473,13 @@ tools/wikitool instructions verify
als beim Mass-Update-Gate gibt es hier **keinen Token und keine Flagge** - stimmt das Ziel
wirklich, trägt der Mensch dessen URL selbst in die Datei ein. Ein Agent, der die Datei
anfasst, um an der Verweigerung vorbeizukommen, öffnet ein Gate aus eigenem Antrieb.
- **Setup oder Update schlägt fehl und ich will es melden** - den Agenten
[instructions/bug-report.md](instructions/bug-report.md) ausführen lassen, oder direkt
`python3 tools/bugreport.py`. Das Skript braucht nur Python 3.8 oder neuer, läuft auch ohne
funktionierendes `wikitool` und schreibt ein Bündel nach `reports/bugreport-<Zeitstempel>/`
samt Zip. Geheimnisse werden entfernt, Seitentitel bleiben draußen (`--titles` nimmt sie mit);
das Bündel enthält trotzdem Maschinen-, Benutzer- und Pfadnamen. Es wird nirgends hochgeladen -
den Kanal wählst du selbst.
- **Ich will am Tool-Stack selbst weiterarbeiten (nicht nur Wiki-Inhalt betreiben)** - eine neue
Instanz hat dafür keinen Weg: `dist export` lässt `instructions/dev/` (Stack-Entwicklung,
inkl. der vendorten `commonplace/`-Wissensbasis) bewusst und dauerhaft weg, ohne
+1 -1
View File
@@ -1 +1 @@
8.0.0-beta.4
8.0.0-beta.5
+135
View File
@@ -0,0 +1,135 @@
---
type: types/instruction.md
name: bug-report
description: How to collect a bug-report bundle when the stack misbehaves on this machine - run tools/bugreport.py, write a fact-only chronology, tell the user what the bundle contains, and stop short of sending it anywhere.
manual: true
---
# Collect a bug report
When setup, an upgrade or a command fails on one machine and works on another, the person who has to
fix it sees nothing of what happened here. This procedure produces one bundle that answers the first
round of their questions - which machine, which Python, which shell, which harness, what the stack
looked like, what `wikitool` printed - so that the report is not a guessing game.
**Run this only when asked, by name, or when the user agrees to it after a failure.** It is
`manual: true` on purpose: the bundle contains private data, and whether to produce one is the
user's decision, not the agent's. Nothing links to this file from `AGENTS.md` or a skill, apart from
the pointers at the failure decision points of [setup-instance.md](setup-instance.md) and
[upgrade-instance.md](upgrade-instance.md), which only offer it.
<!-- wikitool:toc -->
## Contents
- [What the bundle holds](#what-the-bundle-holds)
- [When to run](#when-to-run)
- [Steps](#steps)
- [Chronology template](#chronology-template)
- [Decision points](#decision-points)
- [Scope](#scope)
<!-- /wikitool:toc -->
## What the bundle holds
`tools/bugreport.py` is a standalone script. It uses the standard library only and imports nothing
from the stack, so it still runs when `wikitool` does not start - no venv, a broken package, a Python
that is too old for the stack. It writes `reports/bugreport-<UTC stamp>/` and a zip beside it, in
four layers:
| Layer | Files | Holds |
|-------|-------|-------|
| 1 Environment | `environment.json` | OS, every Python and shell found, harness, `PATH`, environment variable names (values only for a fixed list), git configuration, venv, line endings, on Windows also long paths, execution policy and mark-of-the-web |
| 2 Stack | `stack.json`, `tree-structure.json` | `VERSION`, the `.wikitool-*.json` files with secrets removed, git status and the last commits, and the shape of `kb/` and `raw/` (counts, depths, path lengths, names that break on Windows) |
| 3 wikitool | `wikitool/*.txt`, `trace.jsonl` | Verbatim output of `version show`, `doctor`, `budget status`, `instructions verify` and `docs verify`, and the caller's session trace. If `version show` fails, `wikitool` counts as not started and nothing else runs |
| 4 Chronology | `CHRONOLOGY.md`, `transcripts/` | What the agent did and saw, and harness transcripts if the user asked for them |
`MANIFEST.md` lists every file and marks the ones that may contain page content and titles: the trace,
the chronology and the transcripts.
Two rules hold for everything the script generates itself. **Secrets are always removed**: values of
keys that look like a token, password, secret, key or auth entry, credentials in URLs, and every value
of that kind found while collecting is also replaced wherever else it turns up. **Page titles are kept
out** unless `--titles` is given: paths under `kb/` and `raw/` are replaced by their shape
(depth, length, whether they hold a space or a non-ASCII character) and `[[wikilinks]]` by the same
flags. A title that stands as bare prose is not found - which is why the trace, the chronology and the
transcripts are marked instead.
## When to run
- Setup or an upgrade failed and the user wants to report it.
- A command fails in a way that looks tied to the machine, and the user asks for a report.
- The user asks for a bug report by name.
## Steps
1. **Tell the user what is about to happen**, in the instance's KB language: a bundle will be written
under `reports/`, it is not pseudonymised, it holds machine, user and path names and the git
remotes, and it is not sent anywhere. Ask whether the session trace, page titles and transcripts
may go in. The defaults are: trace in, titles out, no transcripts.
2. **Write the chronology** to a file under `reports/` (which is gitignored) from the
[template](#chronology-template) below. Facts only - no diagnosis. If the session's own history is
too long to reconstruct, say what is missing instead of filling the gap.
3. **Run the collector**, with whichever Python 3.8 or later the machine has:
```bash
python3 tools/bugreport.py --chronology reports/chronology.md
```
Add `--no-trace` if the user declined the trace, `--titles` if titles may stay,
`--transcript <file>` (repeatable) for a transcript the user pointed at, `--session <id>` to take a
trace other than the caller's. `python3` may be `python` or `py -3` on Windows. If no Python starts
at all, that is the report: give the user the exact error text, verbatim.
4. **Do not imitate the collector's session id.** It runs its counting `wikitool` calls under
`WIKITOOL_SESSION_ID=bugreport-<stamp>` itself; do not export that variable, or any `bugreport-*`
one, in the session. The exception it gets in [gates.md](gates.md) § "Taking a new session id"
belongs to the script alone.
5. **Report the result.** Quote the bundle path, the archive path and the privacy notice the script
prints, name the gaps in `MANIFEST.md`, and tell the user to read the bundle before sharing it.
Then stop: the channel - a tracker issue, an email, a chat - is the user's choice, and the agent
never uploads the bundle.
## Chronology template
```markdown
# Chronology
## Goal
One sentence: what the user asked for, e.g. "Set up a new instance from a fresh clone."
## Environment as the agent saw it
Harness, shell, OS, anything the user said about the machine that the collector cannot know.
## Steps
1. `<exact command>` - exit code, and the first line of the error or output that mattered, verbatim.
2. ...
## Expected and observed
- Expected: what the instruction said would happen.
- Observed: what happened instead, verbatim where it is short.
## Changes made by hand
Every file edited or created outside a `wikitool` command, and every setting changed, in order.
## Not known
What the agent could not find out, or did not check.
```
Facts only: no page content, no guessed cause, no advice.
## Decision points
- **The user declines the trace?** Run with `--no-trace`. The manifest records the exclusion.
- **The user wants the report to name pages?** Run with `--titles`; the bundle then also carries
`tree-paths.txt`, which lists every path under `kb/` and `raw/`.
- **The collector exits 1?** It could not write the bundle (a missing input file, a full disk). Read
the message, fix the cause, retry once, then report the exact error.
- **A `wikitool` output in the bundle looks wrong or refuses to run?** Do not re-run it to see more.
The bundle records what happened; that is the report.
## Scope
Covers producing the bundle. It does not cover reading a bundle someone else sent, triaging the
report, or filing it - all of that is the maintainer's side and the user's choice of channel.
+8
View File
@@ -215,6 +215,14 @@ never in response to a gate refusal.** The plan is the human approval the gate w
have to ask for; a refusal means that approval has not been given yet. If you are tempted to
re-export the variable after an `ERROR` line, that is the gate working.
**One tool takes a new id itself, for a fixed set of read commands.** `tools/bugreport.py`
([bug-report.md](bug-report.md)) runs `instructions verify` and `docs verify` under
`WIKITOOL_SESSION_ID=bugreport-<stamp>`, so that collecting a report neither spends the
caller's budget nor is refused by it at exactly the moment something has gone wrong. That is
permitted because the script sets the id for those two commands only, never in the caller's
shell, and the set is a constant in the script. It is not a precedent for an agent: an agent that
exports a `bugreport-*` id, or any other, outside the two cases above is opening the gate.
Background: the [[Iteration and Cost Limits]] concept page in `kb/`.
## Scope
+4
View File
@@ -305,6 +305,10 @@ and ready for its first ingest.
afterwards are `wiki-ingest`, `wiki-query`, `wiki-manage`, `wiki-lint`, `wiki-status` and
`gtd-weekly-review` available.
**A step fails and the cause is not obvious?** Do not improvise around it (invariant 7). Offer the
user a bug report - [bug-report.md](bug-report.md) - and run it only if they agree; the collector
works even when `wikitool` does not start.
## Scope
Applies only to an empty distribution produced by `dist export`. For an existing clone of this
+3
View File
@@ -247,6 +247,9 @@ fresh clone ([bootstrap.md](bootstrap.md)), and not for the clone-with-upstream
intermediate state is legitimate and `migrate status` resumes it; what is not legitimate is
recording a migration with `migrate done` that was not carried out - the version then describes
a shape the corpus is not in.
- **A step fails and the cause is not obvious?** Stop rather than improvise, and offer the user a
bug report - [bug-report.md](bug-report.md). It runs even when `wikitool` does not start, and
only when the user agrees.
- **`doctor` reports `kb-version` behind `VERSION` after everything is done?** Correct when the
release's chain was empty or carried only `offered` entries: an offer changes a file the
instance owns, not the shape of its content, so the content version stays where it was.
+18 -1
View File
@@ -1,12 +1,14 @@
# reports/ - Generated Output
Derived output that must stay out of git. Two kinds live here:
Derived output that must stay out of git. Three kinds live here:
- **Lint reports**, written by `tools/wikitool lint --markdown "reports/Lint Report
<YYYY-MM-DD>.md"`.
- **Traces**, under `reports/telemetry/<session>/trace.jsonl` - the append-only record of
what a session did, written by `wikitool` itself and by the harness hooks. See
[../EVALS.md](../EVALS.md) for the event contract and what is redacted.
- **Bug-report bundles**, under `reports/bugreport-<UTC stamp>/` and a zip beside each, written by
`tools/bugreport.py` on request - see [../instructions/bug-report.md](../instructions/bug-report.md).
**Everything in this directory except this file is gitignored.** A lint report is a derived
copy of recomputable truth: its structural sections can be regenerated from the tree at any
@@ -39,12 +41,27 @@ installation-form default (on for a dev checkout, off for a distributed instance
keeps lengths and digests instead of text. See [../EVALS.md](../EVALS.md) § "Whether it runs at
all" for the full precedence and both quantity caps below.
## Bug-report bundles
A bundle is a snapshot of one machine at one moment, made so that someone else can read what
happened here. It is not recomputable and not durable, and it **contains private data**: machine,
user and path names, `PATH` entries, git remotes and commit subjects, and - when included - the
session trace, the chronology and transcripts, which may hold page content and titles. Secrets are
removed by the collector; the rest is the reader's to check before a bundle leaves the machine.
Nothing uploads it: the channel is the user's choice.
The collector's own `wikitool` calls run under the session id `bugreport-<stamp>`, so a
`reports/telemetry/bugreport-*` directory is that run's trace and belongs to no session of yours.
## Retention
**Lint reports: none.** Old ones are local scratch; delete them freely. There is nothing to
retire with `wikitool rm`, because no report is ever a wiki page - `lint-report` is a
contract-only type-spec with no `base_dir:` and cannot be instantiated under `kb/`.
**Bug-report bundles: none.** Delete them freely once they have been read or sent; nothing refers
to one afterwards.
**Traces: two enforced caps, applied by the writer itself, never by a separate cleanup pass.**
A byte cap per session trace (default 5 MiB, `WIKI_TRACE_MAX_SESSION_BYTES`) and a retention
limit on the number of session directories under `reports/telemetry/` (default 250,
+7
View File
@@ -60,6 +60,13 @@ tools/
tests/ pytest suite
```
**A script beside the package.** `bugreport.py` is not part of `chemenu` and imports nothing
from it: it is the bug-report collector ([../instructions/bug-report.md](../instructions/bug-report.md)),
and the case it exists for is a checkout where `wikitool` does not start. It therefore uses the
standard library only, keeps to Python 3.8 syntax so that an old interpreter can still run it, and
exits 0 or 1 - never 42, since it opens no gate. `dist export` ships it with the rest of `tools/`;
its tests (`tests/test_bugreport.py`) run it on a bare interpreter (`-I -S`) to keep that promise.
**Two consumers, one core.** The CLI is not the only caller any more. The cores
(`search/service.py`, `lint_core.py`, `types_core.py`, `catalog.py`) hold what
decides an answer and import no `typer` and no `rich`; the modules under `commands/` turn
+942
View File
@@ -0,0 +1,942 @@
#!/usr/bin/env python3
"""Collect a bug-report bundle from this checkout.
Runs on the base Python with the standard library only, and imports nothing from
`chemenu`: the case it exists for is a checkout where `wikitool` does not start
- no venv, a broken package, a Python that is too old. Syntax stays at Python
3.8 so that even an old interpreter can still produce a report.
python tools/bugreport.py [--chronology FILE] [--transcript FILE]...
[--session ID | --no-trace] [--titles]
[--root DIR] [--out DIR]
The bundle is `<out>/bugreport-<UTC stamp>/` plus a zip beside it, in four
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. Nothing is uploaded.
Exit 0 = bundle written, 1 = it could not be written. No exit 42: this script
opens no gate. See instructions/bug-report.md.
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import unicodedata
import zipfile
from pathlib import Path
TIMEOUT_SECONDS = 300
PROBE_TIMEOUT_SECONDS = 20
REMOVED = "<removed>"
NOT_COLLECTED = "<not collected>"
SESSION_ENV = "WIKITOOL_SESSION_ID"
HARNESS_SESSION_ENV = "CLAUDE_CODE_SESSION_ID"
SECRET_NAME = re.compile(r"token|password|secret|key|auth", re.IGNORECASE)
HARNESS_MARKERS = (
(HARNESS_SESSION_ENV, "claude-code"),
)
ENV_ALLOW_EXACT = frozenset(
{
"PATH", "PATHEXT", "SHELL", "TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION",
"COLORTERM", "LANG", "LANGUAGE", "VIRTUAL_ENV", "MSYSTEM", "COMSPEC",
"PSMODULEPATH",
}
| {name for name, _ in HARNESS_MARKERS}
)
ENV_ALLOW_PREFIX = ("LC_", "PYTHON", "WIKI_", "WIKITOOL_", "CHEMENU_")
# Names under kb/ that the stack generates or owns: not titles, and unreadable
# in a bundle if they are masked.
STACK_NAMES = frozenset(
{
"CONTRACT.md", "CONVENTIONS.md", "CONVENTIONS.md.template", "COLLECTION.md",
"COLLECTION.md.template", "INDEX.md", "index.md", "log.md", "provenance.md",
".gitkeep",
}
)
CONTENT_DIRS = ("kb", "raw")
CONTENT_COMMIT_DIRS = ("kb", "raw", "work")
MAY_CONTAIN_CONTENT = "may contain page content and titles"
PRIVACY_NOTICE = (
"This bundle is not pseudonymised. It contains private data: machine, user and path "
"names, PATH entries, git remotes and commit subjects and - if included - the session "
"trace, the chronology and transcripts, which may contain page content and titles. "
"Secrets are removed, but read the bundle before you share it. Choose the channel "
"yourself; neither this tool nor the agent uploads anything."
)
WINDOWS_RESERVED = re.compile(r"^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$", re.IGNORECASE)
WINDOWS_FORBIDDEN = re.compile(r'[<>:"|?*\x00-\x1f]')
WIKILINK = re.compile(r"\[\[([^\]\n]+)\]\]")
URL_USERINFO = re.compile(r"(?P<scheme>[A-Za-z][A-Za-z0-9+.\-]*)://(?P<userinfo>[^/\s@\"'<>]+)@")
AUTH_HEADER = re.compile(r"(?im)(authorization\s*[:=]\s*)(?:(?:bearer|basic|token)\s+)?\S+")
BEARER = re.compile(r"(?i)\b(bearer)\s+[A-Za-z0-9._~+/=\-]{8,}")
# --------------------------------------------------------------------------- secrets
class Redactor:
"""Every secret value found while collecting, replaced again in a final pass."""
def __init__(self) -> None:
self.values = set()
def add(self, value) -> None:
if isinstance(value, str) and len(value) >= 4:
self.values.add(value)
def add_any(self, node) -> None:
if isinstance(node, dict):
for item in node.values():
self.add_any(item)
elif isinstance(node, (list, tuple)):
for item in node:
self.add_any(item)
else:
self.add(node)
def scrub_urls(self, text: str) -> str:
def repl(match):
scheme, userinfo = match.group("scheme"), match.group("userinfo")
if scheme.lower() in ("http", "https"):
self.add(userinfo)
for part in userinfo.split(":"):
self.add(part)
return scheme + "://"
if ":" in userinfo:
user, password = userinfo.split(":", 1)
self.add(password)
return scheme + "://" + user + "@"
return match.group(0)
return URL_USERINFO.sub(repl, text)
def scrub(self, text: str) -> str:
text = self.scrub_urls(text)
text = AUTH_HEADER.sub(lambda m: m.group(1) + REMOVED, text)
text = BEARER.sub(lambda m: m.group(1) + " " + REMOVED, text)
forms = set()
for value in self.values:
forms.add(value)
escaped = json.dumps(value, ensure_ascii=False)[1:-1]
if escaped != value:
forms.add(escaped)
for form in sorted(forms, key=len, reverse=True):
text = text.replace(form, REMOVED)
return text
def clean_json(self, node):
if isinstance(node, dict):
out = {}
for key, value in node.items():
if SECRET_NAME.search(str(key)):
self.add_any(value)
out[key] = REMOVED
else:
out[key] = self.clean_json(value)
return out
if isinstance(node, list):
return [self.clean_json(item) for item in node]
if isinstance(node, str):
return self.scrub_urls(node)
return node
# ------------------------------------------------------------------------ title shield
def _shape(rel: str) -> str:
parts = rel.split("/")
name = parts[-1]
stem, dot, suffix = name.rpartition(".")
flags = ["depth=%d" % len(parts), "len=%d" % len(rel)]
if " " in rel:
flags.append("space")
if any(ord(ch) > 127 for ch in rel):
flags.append("non-ascii")
return "%s/<%s>%s" % (parts[0], " ".join(flags), (dot + suffix) if dot and stem else "")
class TitleShield:
"""Replaces the relative path of a file under kb/ or raw/ with its shape.
A title that stands as bare prose is not found, on purpose: replacing titles
word by word would hit a title like "Git" everywhere in the bundle.
"""
def __init__(self, rel_paths) -> None:
self.forms = {}
for rel in rel_paths:
shape = _shape(rel)
for form in (rel, rel.replace("/", "\\"), rel.replace("/", "\\\\")):
self.forms[form] = shape
self.lengths = sorted({len(form) for form in self.forms}, reverse=True)
self.start = re.compile(r"(?:%s)(?:/|\\)" % "|".join(CONTENT_DIRS))
def apply(self, text: str) -> str:
if self.forms:
out, last = [], 0
for match in self.start.finditer(text):
i = match.start()
if i < last:
continue
for length in self.lengths:
shape = self.forms.get(text[i:i + length])
if shape:
out.append(text[last:i])
out.append(shape)
last = i + length
break
out.append(text[last:])
text = "".join(out)
return WIKILINK.sub(lambda m: "[[<title %s>]]" % _title_flags(m.group(1)), text)
def _title_flags(title: str) -> str:
flags = ["len=%d" % len(title)]
if " " in title:
flags.append("space")
if any(ord(ch) > 127 for ch in title):
flags.append("non-ascii")
return " ".join(flags)
# ---------------------------------------------------------------------------- helpers
class Result:
def __init__(self, returncode, stdout, stderr, note=None) -> None:
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
self.note = note
@property
def ok(self) -> bool:
return self.returncode == 0
def first_line(self) -> str:
for text in (self.stderr, self.stdout, self.note or ""):
for line in text.splitlines():
if line.strip():
return line.strip()
return ""
def _decode(data) -> str:
if data is None:
return ""
if isinstance(data, str):
return data
return data.decode("utf-8", errors="replace")
def run(cmd, cwd, env=None, timeout=PROBE_TIMEOUT_SECONDS) -> Result:
try:
proc = subprocess.run(
cmd, cwd=str(cwd), env=env, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
return Result(None, _decode(exc.stdout), _decode(exc.stderr),
"timed out after %d s" % timeout)
except OSError as exc:
return Result(None, "", "", "could not start: %s" % exc)
return Result(proc.returncode, _decode(proc.stdout), _decode(proc.stderr))
def read_text(path: Path) -> str:
with open(str(path), "r", encoding="utf-8", errors="replace", newline="") as handle:
return handle.read()
def write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(str(path), "w", encoding="utf-8", newline="\n") as handle:
handle.write(text)
def dump_json(data) -> str:
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def guarded(fn, *args):
"""A broken layer must not take the whole report down."""
try:
return fn(*args)
except Exception as exc: # noqa: BLE001 - the report is the point
return {"collector_error": "%s: %s" % (type(exc).__name__, exc)}
def session_slug(value: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "__", value).strip("_") or "unknown"
# ------------------------------------------------------------------- layer 1: environment
def _version_line(cmd, cwd) -> str:
result = run(cmd, cwd)
return result.first_line() if result.ok or result.first_line() else (result.note or "")
def collect_environment(root: Path, red: Redactor) -> dict:
data = {
"os": {
"platform": platform.platform(),
"system": platform.system(),
"release": platform.release(),
"version": platform.version(),
"machine": platform.machine(),
},
"python": _collect_python(root),
"shells": _collect_shells(root),
"harness": _collect_harness(),
"path_entries": [
{"entry": entry, "exists": os.path.isdir(entry)}
for entry in os.environ.get("PATH", "").split(os.pathsep) if entry
],
"env": _collect_env(red),
"tools_config": _collect_tools_config(root, red),
"git": _collect_git_config(root, red),
"venv": _collect_venv(root, red),
"line_endings": _collect_line_endings(root),
"gitattributes": _read_optional(root / ".gitattributes", red),
"windows": _collect_windows(root),
}
return data
def _collect_python(root: Path) -> dict:
running = {
"executable": sys.executable,
"version": sys.version.replace("\n", " "),
"version_info": list(sys.version_info[:3]),
}
candidates = []
for name, extra in (("python3", []), ("python", []), ("py", ["-3"])):
path = shutil.which(name)
entry = {"name": " ".join([name] + extra), "path": path}
if path:
entry["version"] = _version_line([path] + extra + ["--version"], root)
entry["windows_store_alias"] = "windowsapps" in path.lower()
candidates.append(entry)
return {"running": running, "candidates": candidates}
def _collect_shells(root: Path) -> dict:
shells = {"SHELL": os.environ.get("SHELL"), "COMSPEC": os.environ.get("COMSPEC")}
for name in ("bash", "zsh", "fish"):
path = shutil.which(name)
shells[name] = {"path": path,
"version": _version_line([path, "--version"], root) if path else None}
for name in ("pwsh", "powershell"):
path = shutil.which(name)
shells[name] = {
"path": path,
"version": _version_line(
[path, "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"], root
) if path else None,
}
return shells
def _collect_harness() -> dict:
detected = [
{"marker": name, "harness": harness}
for name, harness in HARNESS_MARKERS if os.environ.get(name)
]
if os.environ.get("TERM_PROGRAM") == "vscode":
detected.append({"marker": "TERM_PROGRAM=vscode", "harness": "vscode terminal"})
return {"detected": detected or "unrecognised"}
def _env_allowed(name: str) -> bool:
upper = name.upper()
return upper in ENV_ALLOW_EXACT or upper.startswith(ENV_ALLOW_PREFIX)
def _collect_env(red: Redactor) -> dict:
env = {}
for name in sorted(os.environ):
value = os.environ[name]
if SECRET_NAME.search(name):
red.add(value)
env[name] = REMOVED if _env_allowed(name) else NOT_COLLECTED
elif _env_allowed(name):
env[name] = value
else:
env[name] = NOT_COLLECTED
return env
def _collect_tools_config(root: Path, red: Redactor) -> dict:
path = root / ".wikitool-tools.json"
if not path.is_file():
return {"status": "absent"}
try:
parsed = json.loads(read_text(path))
except ValueError as exc:
return {"status": "unparseable", "error": str(exc)}
checks = []
def walk(node, where):
if isinstance(node, dict):
for key, value in node.items():
walk(value, where + [str(key)])
elif isinstance(node, list):
for index, value in enumerate(node):
walk(value, where + [str(index)])
elif isinstance(node, str) and os.path.isabs(node):
checks.append({"at": ".".join(where), "path": node, "exists": os.path.exists(node)})
walk(parsed, [])
return {"status": "present", "content": red.clean_json(parsed), "path_checks": checks}
def _collect_git_config(root: Path, red: Redactor) -> dict:
version = run(["git", "--version"], root)
if not version.ok:
return {"available": False, "error": version.note or version.first_line()}
config = {}
for key in ("core.autocrlf", "core.longpaths", "core.filemode"):
result = run(["git", "config", "--get", key], root)
config[key] = result.stdout.strip() if result.ok else "(unset)"
name = run(["git", "config", "--get", "user.name"], root)
config["user.name is set"] = bool(name.ok and name.stdout.strip())
remotes = run(["git", "remote", "-v"], root)
return {
"available": True,
"version": version.stdout.strip(),
"config": config,
"remotes": red.scrub_urls(remotes.stdout).splitlines() if remotes.ok
else ["(not a repository, or git failed: %s)" % remotes.first_line()],
}
def _collect_venv(root: Path, red: Redactor) -> dict:
venv = root / "tools" / ".venv"
if not venv.is_dir():
return {"present": False}
layout = ("bin" if (venv / "bin").is_dir() else None) or (
"Scripts" if (venv / "Scripts").is_dir() else None)
cfg = venv / "pyvenv.cfg"
return {
"present": True,
"layout": layout or "unknown",
"python_found": any(
(venv / sub / exe).exists()
for sub, exe in (("bin", "python"), ("Scripts", "python.exe"))
),
"pyvenv.cfg": read_text(cfg) if cfg.is_file() else None,
}
def _collect_line_endings(root: Path) -> dict:
tools = root / "tools"
files = [tools / "wikitool"] + (sorted(tools.glob("*.ps1")) if tools.is_dir() else [])
result = {}
for path in files:
if not path.is_file():
continue
raw = path.read_bytes()
crlf = raw.count(b"\r\n")
result[path.name] = {"crlf": crlf, "lf_only": raw.count(b"\n") - crlf}
return result
def _read_optional(path: Path, red: Redactor):
return red.scrub_urls(read_text(path)) if path.is_file() else None
def _collect_windows(root: Path) -> dict:
if sys.platform != "win32":
return {"applicable": False}
data = {"applicable": True}
try:
import winreg # noqa: PLC0415 - only exists on Windows
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\FileSystem"
) as key:
data["LongPathsEnabled"] = winreg.QueryValueEx(key, "LongPathsEnabled")[0]
except Exception as exc: # noqa: BLE001
data["LongPathsEnabled"] = "unreadable: %s" % exc
pwsh = shutil.which("pwsh")
if pwsh:
policy = run([pwsh, "-NoProfile", "-Command", "Get-ExecutionPolicy -List | Out-String"], root)
data["execution_policy"] = policy.stdout.strip() or policy.first_line()
else:
data["execution_policy"] = "pwsh not found"
data["powershell_5_present"] = bool(shutil.which("powershell"))
marked = []
tools = root / "tools"
if tools.is_dir():
for path in sorted(p for p in tools.iterdir() if p.is_file()):
try:
with open(str(path) + ":Zone.Identifier", "r", encoding="utf-8",
errors="replace") as stream:
marked.append({"file": path.name, "zone": stream.read().strip()})
except OSError:
pass
data["mark_of_the_web"] = marked
return data
# ------------------------------------------------------------------------ layer 2: stack
def scan_tree(root: Path, name: str):
"""Structure of kb/ or raw/, and the relative paths it holds. No content."""
top = root / name
if not top.is_dir():
return {"present": False}, []
files, dirs = [], 0
depth_histogram, longest_rel, longest_abs = {}, 0, 0
counts = {
"abs_path_over_240": 0, "abs_path_over_260": 0, "names_with_space": 0,
"names_non_ascii": 0, "names_windows_forbidden_chars": 0,
"names_trailing_dot_or_space": 0, "names_reserved_device": 0,
"names_not_nfc": 0, "case_collisions": 0,
}
def judge(entry: str) -> None:
if " " in entry:
counts["names_with_space"] += 1
if any(ord(ch) > 127 for ch in entry):
counts["names_non_ascii"] += 1
if WINDOWS_FORBIDDEN.search(entry):
counts["names_windows_forbidden_chars"] += 1
if entry.endswith((" ", ".")):
counts["names_trailing_dot_or_space"] += 1
if WINDOWS_RESERVED.match(entry):
counts["names_reserved_device"] += 1
if unicodedata.normalize("NFC", entry) != entry:
counts["names_not_nfc"] += 1
for current, subdirs, names in os.walk(str(top)):
entries = sorted(subdirs) + sorted(names)
seen = {}
for entry in entries:
seen.setdefault(entry.casefold(), []).append(entry)
judge(entry)
counts["case_collisions"] += sum(1 for group in seen.values() if len(group) > 1)
dirs += len(subdirs)
for entry in names:
absolute = os.path.join(current, entry)
rel = os.path.relpath(absolute, str(root)).replace(os.sep, "/")
files.append(rel)
depth = rel.count("/") + 1
depth_histogram[str(depth)] = depth_histogram.get(str(depth), 0) + 1
longest_rel = max(longest_rel, len(rel))
longest_abs = max(longest_abs, len(os.path.abspath(absolute)))
if len(os.path.abspath(absolute)) > 240:
counts["abs_path_over_240"] += 1
if len(os.path.abspath(absolute)) > 260:
counts["abs_path_over_260"] += 1
summary = {
"present": True, "files": len(files), "directories": dirs,
"depth_histogram": depth_histogram, "longest_relative_path": longest_rel,
"longest_absolute_path": longest_abs,
}
summary.update(counts)
return summary, files
def collect_stack(root: Path, red: Redactor) -> dict:
version = root / "VERSION"
configs = {}
for path in sorted(root.glob(".wikitool-*.json")):
if path.name == ".wikitool-tools.json":
continue
try:
configs[path.name] = red.clean_json(json.loads(read_text(path)))
except ValueError:
configs[path.name] = {"unparseable": True, "bytes": path.stat().st_size}
return {
"VERSION": read_text(version).strip() if version.is_file() else "absent",
"config_files": configs,
"git": _collect_git_state(root),
}
def _collect_git_state(root: Path) -> dict:
quiet = ["git", "-c", "core.quotepath=off"]
inside = run(quiet + ["rev-parse", "--is-inside-work-tree"], root)
if not inside.ok:
return {"repository": False, "error": inside.first_line()}
branch = run(quiet + ["rev-parse", "--abbrev-ref", "HEAD"], root)
status = run(quiet + ["status", "--porcelain", "-z"], root)
log = run(quiet + ["log", "-n", "20", "--name-only", "--format=%x1e%h%x1f%s"], root)
return {
"repository": True,
"branch": branch.stdout.strip() if branch.ok else branch.first_line(),
"status": _parse_status(status.stdout) if status.ok else [status.first_line()],
"log": _parse_log(log.stdout) if log.ok else [log.first_line()],
}
def _parse_status(raw: str) -> list:
entries = raw.split("\0")
lines, i = [], 0
while i < len(entries):
entry = entries[i]
i += 1
if len(entry) < 4:
continue
code, path = entry[:2], entry[3:]
if code[0] in "RC" and i < len(entries):
lines.append("%s %s (from %s)" % (code, path, entries[i]))
i += 1
else:
lines.append("%s %s" % (code, path))
return lines
def _parse_log(raw: str) -> list:
lines = []
for block in raw.split("\x1e"):
if not block.strip():
continue
head, _, rest = block.partition("\n")
sha, _, subject = head.partition("\x1f")
files = [line for line in rest.splitlines() if line.strip()]
if files and all(f.startswith(tuple(d + "/" for d in CONTENT_COMMIT_DIRS)) for f in files):
parts = []
for directory in CONTENT_COMMIT_DIRS:
count = sum(1 for f in files if f.startswith(directory + "/"))
if count:
parts.append("%s %d" % (directory, count))
subject = "<content commit: %s files>" % ", ".join(parts)
lines.append("%s %s" % (sha.strip(), subject.strip()))
return lines
# ---------------------------------------------------------------------- layer 3: wikitool
def launcher_command(root: Path):
tools = root / "tools"
if sys.platform == "win32":
ps1, sh = tools / "wikitool.ps1", tools / "wikitool"
pwsh = shutil.which("pwsh")
if ps1.is_file() and pwsh:
return [pwsh, "-NoProfile", "-File", str(ps1)]
bash = shutil.which("bash")
if sh.is_file() and bash:
return [bash, str(sh)]
return None
launcher = tools / "wikitool"
return [str(launcher)] if launcher.is_file() else None
def resolve_trace_session(explicit):
if explicit:
return explicit, "--session"
for name in (SESSION_ENV, HARNESS_SESSION_ENV):
if os.environ.get(name):
return os.environ[name], name
return None, None
def trace_root(root: Path) -> Path:
override = os.environ.get("WIKI_TRACE_DIR")
return Path(override) if override else root / "reports" / "telemetry"
def collect_trace(root: Path, explicit):
"""The caller's session trace, or a listing of what exists instead."""
session, source = resolve_trace_session(explicit)
base = trace_root(root)
if session:
path = base / session_slug(session) / "trace.jsonl"
if path.is_file():
return {"found": True, "session": session, "source": source,
"text": read_text(path)}
listing = []
if base.is_dir():
for child in sorted(base.iterdir()):
if child.is_dir() and not child.name.startswith("bugreport-"):
trace = child / "trace.jsonl"
listing.append({"session_directory": child.name,
"bytes": trace.stat().st_size if trace.is_file() else 0})
return {"found": False, "session": session, "source": source, "listing": listing}
WIKITOOL_CALLS = (
("version show", ("version", "show"), "inherited"),
("doctor", ("doctor",), "inherited"),
("budget status", ("budget", "status"), "inherited"),
("instructions verify", ("instructions", "verify"), "own"),
("docs verify", ("docs", "verify"), "own"),
)
def run_wikitool(root: Path, launcher, own_session: str) -> dict:
"""Five fixed read commands. `version show` is the startup probe: if it fails,
`wikitool` counts as not started and nothing else is run."""
outputs = []
started = False
reason = None
for label, args, session in WIKITOOL_CALLS:
env = dict(os.environ)
if session == "own":
env[SESSION_ENV] = own_session
result = run(launcher + list(args), root, env=env, timeout=TIMEOUT_SECONDS)
prefix = "%s=%s " % (SESSION_ENV, own_session) if session == "own" else ""
outputs.append({
"label": label,
"file": "wikitool/%s.txt" % label.replace(" ", "-"),
"session": ("%s (set by the collector)" % own_session) if session == "own"
else "inherited from the caller",
"text": (
"command: %s%s\nsession: %s\nexit: %s\n%s\n--- stdout ---\n%s\n--- stderr ---\n%s\n"
% (prefix, " ".join(launcher + list(args)),
"own" if session == "own" else "inherited",
result.returncode if result.returncode is not None else result.note,
("note: " + result.note) if result.note and result.returncode is not None
else "", result.stdout, result.stderr)
),
})
if label == "version show":
if not result.ok:
reason = "version show %s: %s" % (
("exit %s" % result.returncode) if result.returncode is not None
else result.note, result.first_line())
break
started = True
return {"started": started, "reason": reason, "outputs": outputs}
# ------------------------------------------------------------------------------ bundle
class Bundle:
def __init__(self, directory: Path) -> None:
self.directory = directory
self.files = []
def add(self, rel: str, text: str, layer: str, description: str, note: str = "") -> None:
write_text(self.directory / rel, text)
self.files.append((rel, layer, description, note))
def stamp_now() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def unique_stamp(out: Path) -> str:
stamp = stamp_now()
candidate, n = stamp, 1
while (out / ("bugreport-" + candidate)).exists() or (out / ("bugreport-%s.zip" % candidate)).exists():
n += 1
candidate = "%s-%d" % (stamp, n)
return candidate
def build_manifest(args, root, stamp, bundle, wikitool, trace, gaps) -> str:
lines = ["# Bug report bundle", "",
"Collected: %s UTC " % datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
"Bundle: `bugreport-%s`" % stamp, "", "## Privacy", "", PRIVACY_NOTICE, "",
"## Options", "",
"- chronology: %s" % ("supplied" if args.chronology else "not supplied"),
"- transcripts: %d" % len(args.transcript),
"- page titles (`--titles`): %s" % ("included" if args.titles else "kept out"),
"- session trace: %s" % (
"excluded (`--no-trace`)" if args.no_trace else
("included" if trace and trace.get("found") else "not found")),
"", "## wikitool", ""]
if wikitool is None:
lines.append("`wikitool` did not start: no launcher found under `tools/`.")
elif wikitool["started"]:
lines.append("`wikitool` started. Five read commands ran; outputs are verbatim.")
else:
lines.append("`wikitool` did not start (%s). The four other commands were not run."
% wikitool["reason"])
if wikitool:
lines += ["", "| Command | Session id |", "|---|---|"]
for out in wikitool["outputs"]:
lines.append("| `%s` | %s |" % (out["label"], out["session"]))
caller, source = resolve_trace_session(args.session)
lines += ["", "Caller session id: %s" % (
"%s (from %s)" % (caller, source) if caller else
"not set; `wikitool` falls back to its parent process id"), ""]
if trace and not trace.get("found") and not args.no_trace:
lines += ["No trace found for the caller's session%s. Existing session directories "
"(name and size only):" % (
" `%s`" % trace["session"] if trace.get("session") else ""), ""]
for item in trace.get("listing", [])[:100]:
lines.append("- `%s` (%d bytes)" % (item["session_directory"], item["bytes"]))
if not trace.get("listing"):
lines.append("- none")
lines.append("")
lines += ["## Files", "", "| File | Layer | Contents | Note |", "|---|---|---|---|"]
for rel, layer, description, note in sorted(bundle.files, key=lambda f: f[0]):
lines.append("| `%s` | %s | %s | %s |" % (rel, layer, description, note))
lines.append("| `MANIFEST.md` | - | this file | |")
if gaps:
lines += ["", "## Gaps", ""] + ["- %s" % gap for gap in gaps]
return "\n".join(lines) + "\n"
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)
if cleaned != text:
write_text(path, cleaned)
def make_zip(bundle_dir: Path, archive: Path) -> None:
with zipfile.ZipFile(str(archive), "w", zipfile.ZIP_DEFLATED) as zf:
for path in sorted(bundle_dir.rglob("*")):
if path.is_file():
zf.write(str(path), bundle_dir.name + "/" + path.relative_to(bundle_dir).as_posix())
def parse_args(argv):
parser = argparse.ArgumentParser(
prog="bugreport.py",
description="Collect a bug-report bundle. Removes secrets, keeps page titles out "
"unless --titles is given, uploads nothing.",
)
parser.add_argument("--chronology", help="the agent's fact-only chronology (Markdown)")
parser.add_argument("--transcript", action="append", default=[],
help="a harness transcript to include (repeatable; only on request)")
group = parser.add_mutually_exclusive_group()
group.add_argument("--session", help="session id whose trace to include")
group.add_argument("--no-trace", action="store_true", help="leave the session trace out")
parser.add_argument("--titles", action="store_true",
help="keep page titles and paths (writes tree-paths.txt)")
parser.add_argument("--root", help="checkout root (default: parent of tools/)")
parser.add_argument("--out", help="output directory (default: <root>/reports)")
return parser.parse_args(argv)
def main(argv=None) -> int:
args = parse_args(argv)
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"
inputs = [("chronology", args.chronology)] + [("transcript", t) for t in args.transcript]
for label, value in inputs:
if value and not Path(value).is_file():
sys.stderr.write("bugreport: %s file not found: %s\n" % (label, value))
return 1
try:
out.mkdir(parents=True, exist_ok=True)
stamp = unique_stamp(out)
bundle_dir = out / ("bugreport-" + stamp)
bundle_dir.mkdir()
return _collect(args, root, out, stamp, bundle_dir)
except OSError as exc:
sys.stderr.write("bugreport: cannot write the bundle: %s\n" % exc)
return 1
def _collect(args, root: Path, out: Path, stamp: str, bundle_dir: Path) -> int:
red = Redactor()
bundle = Bundle(bundle_dir)
gaps = []
trace = None
if not args.no_trace:
trace = guarded(collect_trace, root, args.session)
if trace.get("found"):
bundle.add("trace.jsonl", trace["text"], "3", "session trace of the caller",
MAY_CONTAIN_CONTENT)
bundle.add("environment.json", dump_json(guarded(collect_environment, root, red)), "1",
"OS, Python, shells, harness, PATH, environment variables (names; values for a "
"fixed list), git configuration, venv, line endings, Windows details")
stack = guarded(collect_stack, root, red)
bundle.add("stack.json", dump_json(stack), "2",
"VERSION, `.wikitool-*.json` (secrets removed), git status and log")
content_paths = []
tree = {}
for name in CONTENT_DIRS:
summary, files = _safe_scan(root, name)
tree[name] = summary
content_paths.extend(files)
bundle.add("tree-structure.json", dump_json(tree), "2",
"kb/ and raw/: counts, depth, path lengths, name problems - no content")
if args.titles:
bundle.add("tree-paths.txt", "\n".join(sorted(content_paths)) + "\n", "2",
"all relative paths under kb/ and raw/", "contains page titles")
wikitool = None
launcher = launcher_command(root)
if launcher:
wikitool = run_wikitool(root, launcher, "bugreport-" + stamp)
for item in wikitool["outputs"]:
bundle.add(item["file"], item["text"], "3", "verbatim output of `%s`" % item["label"])
else:
gaps.append("no launcher found under tools/, so no wikitool command ran")
if args.chronology:
bundle.add("CHRONOLOGY.md", read_text(Path(args.chronology)), "4",
"the agent's chronology", MAY_CONTAIN_CONTENT)
else:
gaps.append("no chronology was supplied")
used = set()
for source in args.transcript:
name = Path(source).name
target, n = name, 1
while target in used:
n += 1
target = "%d-%s" % (n, name)
used.add(target)
bundle.add("transcripts/" + target, read_text(Path(source)), "4",
"harness transcript", MAY_CONTAIN_CONTENT)
write_text(bundle_dir / "MANIFEST.md",
build_manifest(args, root, stamp, bundle, wikitool, trace, gaps))
shield = None
if not args.titles:
shield = TitleShield(
rel for rel in content_paths if rel.rsplit("/", 1)[-1] not in STACK_NAMES
)
final_pass(bundle_dir, red, shield, {"tree-paths.txt"})
archive = out / ("bugreport-%s.zip" % stamp)
make_zip(bundle_dir, archive)
print("Bundle: %s" % bundle_dir)
print("Archive: %s" % archive)
print()
print(PRIVACY_NOTICE)
return 0
def _safe_scan(root: Path, name: str):
try:
return scan_tree(root, name)
except Exception as exc: # noqa: BLE001
return {"collector_error": "%s: %s" % (type(exc).__name__, exc)}, []
if __name__ == "__main__":
sys.exit(main())
+327
View File
@@ -0,0 +1,327 @@
"""`tools/bugreport.py`, the bug-report collector.
The collector is a standalone script - standard library only, no `chemenu` import -
so these tests import it the way `test_trace_ingest.py` imports its script, and run
it against a small fake checkout with a fake `tools/wikitool` launcher.
"""
import ast
import json
import os
import subprocess
import sys
import zipfile
from pathlib import Path
import pytest
import bugreport
SCRIPT = Path(bugreport.__file__)
TOKEN = "s3cr3t-tok3n-value-9F2"
PASSWORD = "hunter2-hunter2"
TITLE = "Vertraulicher Titel Alpha"
OTHER_TITLE = "Zweite Seite Beta"
LAUNCHER = """#!{python}
import os, sys
args = sys.argv[1:]
print("args:", " ".join(args))
print("session:", os.environ.get("WIKITOOL_SESSION_ID", "<unset>"))
print("token-in-env:", os.environ.get("MY_API_TOKEN", "<unset>"))
print("see [[{title}]] and kb/Concepts/{title}.md and kb/Concepts/CONTRACT.md")
if args[:2] == ["version", "show"] and os.environ.get("FAKE_NOT_STARTING"):
sys.stderr.write("ModuleNotFoundError: No module named 'typer'\\n")
sys.exit(1)
if os.environ.get("FAKE_SLEEP") and args[0] == "doctor":
import time
time.sleep(30)
"""
def git(root: Path, *args: str) -> None:
subprocess.run(
["git", "-c", "user.name=Fixture", "-c", "user.email=f@example.invalid", *args],
cwd=root, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
@pytest.fixture
def checkout(tmp_path: Path, monkeypatch) -> Path:
root = tmp_path / "checkout"
(root / "kb" / "Concepts").mkdir(parents=True)
(root / "raw" / "articles").mkdir(parents=True)
(root / "tools").mkdir()
(root / "kb" / "Concepts" / f"{TITLE}.md").write_text("body of the page\n")
(root / "kb" / "Concepts" / f"{OTHER_TITLE}.md").write_text("more\n")
(root / "kb" / "Concepts" / "CONTRACT.md").write_text("stack file\n")
(root / "raw" / "articles" / "Quelle Gamma.md").write_text("source\n")
(root / "VERSION").write_text("1.2.3\n")
(root / ".wikitool-upload.json").write_text(
json.dumps({"schema": 1, "api_token": TOKEN, "enabled": True})
)
(root / ".wikitool-tools.json").write_text(
json.dumps({"schema": 1, "tools": {"python": {"path": "/definitely/not/here/python"}}})
)
launcher = root / "tools" / "wikitool"
launcher.write_text(LAUNCHER.format(python=sys.executable, title=TITLE))
launcher.chmod(0o755)
git(root, "init", "-q", "-b", "main")
git(root, "remote", "add", "origin", f"https://oauth:{PASSWORD}@git.example.invalid/x/y.git")
git(root, "add", "-A")
git(root, "commit", "-q", "-m", "seed")
(root / "kb" / "Concepts" / f"{TITLE}.md").write_text("changed\n")
monkeypatch.delenv("WIKI_TRACE_DIR", raising=False)
monkeypatch.setenv("MY_API_TOKEN", TOKEN)
monkeypatch.setenv("PATH", os.environ["PATH"])
return root
def collect(root: Path, tmp_path: Path, *extra: str) -> Path:
out = tmp_path / "out"
code = bugreport.main(["--root", str(root), "--out", str(out), *extra])
assert code == 0
bundles = [p for p in out.iterdir() if p.is_dir()]
assert len(bundles) == 1
return bundles[0]
def all_text(bundle: Path) -> str:
return "\n".join(
p.read_text(encoding="utf-8", errors="replace") for p in bundle.rglob("*") if p.is_file()
)
def test_the_script_is_standard_library_only_and_python38_syntax():
source = SCRIPT.read_text(encoding="utf-8")
tree = ast.parse(source, feature_version=(3, 8))
imported = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imported.add(node.module.split(".")[0])
assert "chemenu" not in imported
assert imported <= set(sys.stdlib_module_names) if hasattr(sys, "stdlib_module_names") else True
def test_the_script_starts_on_a_bare_interpreter():
proc = subprocess.run(
[sys.executable, "-I", "-S", str(SCRIPT), "--help"], capture_output=True, text=True
)
assert proc.returncode == 0
assert "--no-trace" in proc.stdout
def test_a_bundle_and_an_archive_are_written_with_a_manifest(checkout, tmp_path):
bundle = collect(checkout, tmp_path, "--no-trace")
for name in ("MANIFEST.md", "environment.json", "stack.json", "tree-structure.json"):
assert (bundle / name).is_file(), name
assert (bundle / "wikitool" / "doctor.txt").is_file()
manifest = (bundle / "MANIFEST.md").read_text()
assert "not pseudonymised" in manifest
assert "no chronology was supplied" in manifest
archive = bundle.with_name(bundle.name + ".zip")
with zipfile.ZipFile(archive) as zf:
names = zf.namelist()
assert f"{bundle.name}/MANIFEST.md" in names
def test_secrets_are_removed_everywhere_including_the_archive(checkout, tmp_path):
bundle = collect(checkout, tmp_path, "--no-trace")
text = all_text(bundle)
assert TOKEN not in text
assert PASSWORD not in text
stack = json.loads((bundle / "stack.json").read_text())
assert stack["config_files"][".wikitool-upload.json"]["api_token"] == "<removed>"
archive = bundle.with_name(bundle.name + ".zip")
with zipfile.ZipFile(archive) as zf:
for name in zf.namelist():
data = zf.read(name).decode("utf-8", errors="replace")
assert TOKEN not in data and PASSWORD not in data, name
def test_a_secret_from_a_config_file_is_also_scrubbed_from_a_chronology(checkout, tmp_path):
chron = tmp_path / "chron.md"
chron.write_text(f"1. Setup failed with {TOKEN} in the header\n")
bundle = collect(checkout, tmp_path, "--no-trace", "--chronology", str(chron))
assert TOKEN not in (bundle / "CHRONOLOGY.md").read_text()
assert "1. Setup failed" in (bundle / "CHRONOLOGY.md").read_text()
def test_environment_records_every_name_but_values_only_for_the_allowlist(
checkout, tmp_path, monkeypatch
):
monkeypatch.setenv("SOMETHING_PRIVATE", "very-private-value")
monkeypatch.setenv("WIKI_TRACE", "1")
monkeypatch.setenv("WIKITOOL_UPDATE_TOKEN", "allowlisted-but-secret")
bundle = collect(checkout, tmp_path, "--no-trace")
env = json.loads((bundle / "environment.json").read_text())["env"]
assert env["SOMETHING_PRIVATE"] == "<not collected>"
assert env["MY_API_TOKEN"] == "<not collected>"
assert env["WIKI_TRACE"] == "1"
assert env["WIKITOOL_UPDATE_TOKEN"] == "<removed>"
assert "PATH" in env and env["PATH"] == os.environ["PATH"]
text = all_text(bundle)
assert "very-private-value" not in text
assert "allowlisted-but-secret" not in text
def test_page_titles_are_kept_out_by_default_and_kept_with_titles(checkout, tmp_path):
bundle = collect(checkout, tmp_path, "--no-trace")
text = all_text(bundle)
assert TITLE not in text
assert OTHER_TITLE not in text
assert "Quelle Gamma" not in text
assert not (bundle / "tree-paths.txt").exists()
assert "kb/Concepts/CONTRACT.md" in text # stack-generated names are not titles
kept = collect(checkout, tmp_path / "second", "--no-trace", "--titles")
assert TITLE in all_text(kept)
assert TITLE in (kept / "tree-paths.txt").read_text()
def test_wikilinks_in_captured_output_lose_their_title(checkout, tmp_path):
bundle = collect(checkout, tmp_path, "--no-trace")
doctor = (bundle / "wikitool" / "doctor.txt").read_text()
assert f"[[{TITLE}]]" not in doctor
assert "[[<title" in doctor
def test_counting_calls_run_under_the_collectors_own_session_inherited_ones_do_not(
checkout, tmp_path, monkeypatch
):
monkeypatch.setenv("WIKITOOL_SESSION_ID", "callers-session")
bundle = collect(checkout, tmp_path, "--no-trace")
own = f"bugreport-{bundle.name.split('bugreport-', 1)[1]}"
for name in ("instructions-verify", "docs-verify"):
assert f"session: {own}" in (bundle / "wikitool" / f"{name}.txt").read_text()
for name in ("version-show", "doctor", "budget-status"):
assert "session: callers-session" in (bundle / "wikitool" / f"{name}.txt").read_text()
manifest = (bundle / "MANIFEST.md").read_text()
assert "callers-session (from WIKITOOL_SESSION_ID)" in manifest
def test_the_callers_trace_is_copied_and_no_trace_leaves_it_out(
checkout, tmp_path, monkeypatch
):
monkeypatch.setenv("WIKITOOL_SESSION_ID", "callers-session")
trace_dir = checkout / "reports" / "telemetry" / "callers-session"
trace_dir.mkdir(parents=True)
(trace_dir / "trace.jsonl").write_text('{"event": "x"}\n')
other = checkout / "reports" / "telemetry" / "other-session"
other.mkdir()
(other / "trace.jsonl").write_text('{"event": "y"}\n')
with_trace = collect(checkout, tmp_path / "a")
assert (with_trace / "trace.jsonl").read_text() == '{"event": "x"}\n'
assert "may contain page content and titles" in (with_trace / "MANIFEST.md").read_text()
without = collect(checkout, tmp_path / "b", "--no-trace")
assert not (without / "trace.jsonl").exists()
def test_a_missing_trace_lists_the_sessions_that_exist(checkout, tmp_path, monkeypatch):
monkeypatch.setenv("WIKITOOL_SESSION_ID", "nobody-wrote-this")
other = checkout / "reports" / "telemetry" / "some-session"
other.mkdir(parents=True)
(other / "trace.jsonl").write_text("12345")
bundle = collect(checkout, tmp_path)
assert not (bundle / "trace.jsonl").exists()
manifest = (bundle / "MANIFEST.md").read_text()
assert "some-session" in manifest and "5 bytes" in manifest
def test_a_bugreport_bucket_is_never_offered_as_a_trace(checkout, monkeypatch):
monkeypatch.setenv("WIKITOOL_SESSION_ID", "missing")
base = checkout / "reports" / "telemetry"
(base / "bugreport-20260101T000000Z").mkdir(parents=True)
(base / "real-session").mkdir()
trace = bugreport.collect_trace(checkout, None)
assert [item["session_directory"] for item in trace["listing"]] == ["real-session"]
def test_a_wikitool_that_does_not_start_still_yields_a_report(checkout, tmp_path, monkeypatch):
monkeypatch.setenv("FAKE_NOT_STARTING", "1")
bundle = collect(checkout, tmp_path, "--no-trace")
assert (bundle / "wikitool" / "version-show.txt").is_file()
assert not (bundle / "wikitool" / "doctor.txt").exists()
assert "ModuleNotFoundError" in (bundle / "wikitool" / "version-show.txt").read_text()
manifest = (bundle / "MANIFEST.md").read_text()
assert "did not start" in manifest
assert (bundle / "environment.json").is_file()
def test_no_launcher_at_all_still_yields_a_report(checkout, tmp_path):
(checkout / "tools" / "wikitool").unlink()
bundle = collect(checkout, tmp_path, "--no-trace")
assert not (bundle / "wikitool").exists()
assert "no launcher" in (bundle / "MANIFEST.md").read_text()
def test_a_hanging_wikitool_call_is_cut_off(checkout, tmp_path, monkeypatch):
monkeypatch.setattr(bugreport, "TIMEOUT_SECONDS", 1)
monkeypatch.setenv("FAKE_SLEEP", "1")
bundle = collect(checkout, tmp_path, "--no-trace")
assert "timed out after 1 s" in (bundle / "wikitool" / "doctor.txt").read_text()
assert (bundle / "wikitool" / "docs-verify.txt").is_file()
def test_the_tools_config_path_check_reports_what_is_missing(checkout, tmp_path):
bundle = collect(checkout, tmp_path, "--no-trace")
config = json.loads((bundle / "environment.json").read_text())["tools_config"]
assert config["status"] == "present"
assert config["path_checks"] == [
{"at": "tools.python.path", "path": "/definitely/not/here/python", "exists": False}
]
def test_a_missing_chronology_file_is_an_error_and_writes_nothing(checkout, tmp_path):
out = tmp_path / "out"
code = bugreport.main(
["--root", str(checkout), "--out", str(out), "--chronology", str(tmp_path / "nope.md")]
)
assert code == 1
assert not out.exists() or not list(out.iterdir())
def test_session_and_no_trace_exclude_each_other(checkout, tmp_path):
with pytest.raises(SystemExit):
bugreport.main(["--root", str(checkout), "--session", "x", "--no-trace"])
def test_transcripts_are_added_and_scrubbed(checkout, tmp_path):
transcript = tmp_path / "t.jsonl"
transcript.write_text(f'{{"msg": "token {TOKEN}"}}\n')
bundle = collect(checkout, tmp_path, "--no-trace", "--transcript", str(transcript))
copied = (bundle / "transcripts" / "t.jsonl").read_text()
assert TOKEN not in copied
def test_two_bundles_in_the_same_second_do_not_collide(checkout, tmp_path, monkeypatch):
monkeypatch.setattr(bugreport, "stamp_now", lambda: "20260101T000000Z")
out = tmp_path / "out"
assert bugreport.main(["--root", str(checkout), "--out", str(out), "--no-trace"]) == 0
assert bugreport.main(["--root", str(checkout), "--out", str(out), "--no-trace"]) == 0
assert sorted(p.name for p in out.iterdir() if p.is_dir()) == [
"bugreport-20260101T000000Z", "bugreport-20260101T000000Z-2",
]
def test_the_collector_leaves_the_checkout_untouched(checkout, tmp_path):
def snapshot():
return sorted(
(p.relative_to(checkout).as_posix(), p.stat().st_mtime_ns)
for p in checkout.rglob("*")
if p.is_file() and ".git/" not in p.as_posix()
)
before = snapshot()
collect(checkout, tmp_path, "--no-trace")
assert snapshot() == before
def test_the_collector_ships_with_a_distribution():
from chemenu.commands import dist_cmd
assert "tools/bugreport.py" in dist_cmd.build_plan()