From 7ae34fdf39547452ea60f5a08299ffb234057beb Mon Sep 17 00:00:00 2001 From: yaaertu Date: Wed, 2 Sep 2026 07:32:40 +0300 Subject: [PATCH 1/7] feat: add integrity-checked cross-source compare core --- src/fixbundle/compare.py | 475 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 src/fixbundle/compare.py diff --git a/src/fixbundle/compare.py b/src/fixbundle/compare.py new file mode 100644 index 0000000..10db0f5 --- /dev/null +++ b/src/fixbundle/compare.py @@ -0,0 +1,475 @@ +from __future__ import annotations + +import hashlib +import json +import re +import stat +import zipfile +from pathlib import Path +from typing import Any + +SUPPORTED_SCHEMAS = {"fixbundle/0.3", "fixbundle/0.4", "fixbundle/0.5"} +MAX_ZIP_MEMBERS = 2_000 +MAX_MEMBER_BYTES = 16_000_000 +MAX_TOTAL_UNCOMPRESSED_BYTES = 64_000_000 +_CHECKSUM_RE = re.compile(r"^([0-9a-fA-F]{64}) (.+)$") +_DIFF_FILE_RE = re.compile(r"^diff --git a/(.+?) b/(.+?)$", re.MULTILINE) +_MISSING = object() + +FIELD_ORDER = ( + "bundle.schema", + "bundle.capture_mode", + "identity.project", + "identity.repository", + "identity.workflow", + "identity.run_id", + "git.commit", + "git.current_head", + "git.changed_files", + "failure.commands", + "failure.failed_jobs", + "failure.failed_steps", + "failure.exceptions", + "production.services", + "production.trace_ids", + "production.span_names", + "runtime.stacks", + "runtime.python", + "runtime.platform", +) +STATUS_ORDER = ("changed", "added", "removed", "unavailable") + + +class CompareError(RuntimeError): + pass + + +def _safe_member_name(name: str) -> str: + if not name or "\x00" in name: + raise CompareError("unsafe ZIP member name") + if "\\" in name: + raise CompareError(f"unsafe ZIP member path: {name}") + if name.startswith("/") or re.match(r"^[A-Za-z]:", name): + raise CompareError(f"unsafe ZIP member path: {name}") + trimmed = name[:-1] if name.endswith("/") else name + if not trimmed: + raise CompareError(f"unsafe ZIP member path: {name}") + parts = trimmed.split("/") + if any(part in {"", ".", ".."} for part in parts): + raise CompareError(f"unsafe ZIP member path: {name}") + return name + + +def _is_symlink(info: zipfile.ZipInfo) -> bool: + mode = (info.external_attr >> 16) & 0o170000 + return mode == stat.S_IFLNK + + +class _BundleReader: + def __init__(self, path: Path): + self.path = path + if not path.is_file(): + raise CompareError(f"bundle not found: {path}") + try: + self.zf = zipfile.ZipFile(path) + except (OSError, zipfile.BadZipFile) as exc: + raise CompareError(f"invalid FixBundle ZIP: {path.name}") from exc + + infos = self.zf.infolist() + if len(infos) > MAX_ZIP_MEMBERS: + self.close() + raise CompareError(f"ZIP member count exceeds {MAX_ZIP_MEMBERS}") + + self.files: dict[str, zipfile.ZipInfo] = {} + seen: set[str] = set() + total = 0 + try: + for info in infos: + name = _safe_member_name(info.filename) + canonical = name[:-1] if name.endswith("/") else name + if canonical in seen: + raise CompareError(f"duplicate ZIP member: {canonical}") + seen.add(canonical) + if _is_symlink(info): + raise CompareError(f"symlink ZIP member is not allowed: {canonical}") + if info.flag_bits & 0x1: + raise CompareError(f"encrypted ZIP member is not supported: {canonical}") + if info.is_dir(): + continue + if info.file_size > MAX_MEMBER_BYTES: + raise CompareError(f"ZIP member exceeds {MAX_MEMBER_BYTES} bytes: {canonical}") + total += info.file_size + if total > MAX_TOTAL_UNCOMPRESSED_BYTES: + raise CompareError( + f"ZIP uncompressed size exceeds {MAX_TOTAL_UNCOMPRESSED_BYTES} bytes" + ) + self.files[canonical] = info + self._validate_checksums() + self.manifest = self._json("manifest.json", required=True) + if not isinstance(self.manifest, dict): + raise CompareError("manifest.json must contain an object") + schema = self.manifest.get("schema") + if schema not in SUPPORTED_SCHEMAS: + raise CompareError(f"unsupported FixBundle schema: {schema!r}") + except Exception: + self.close() + raise + + def close(self) -> None: + try: + self.zf.close() + except Exception: + pass + + def __enter__(self) -> "_BundleReader": + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def _bytes(self, name: str, *, required: bool = False) -> bytes | None: + info = self.files.get(name) + if info is None: + if required: + raise CompareError(f"required bundle member missing: {name}") + return None + try: + data = self.zf.read(info) + except (OSError, RuntimeError, zipfile.BadZipFile) as exc: + raise CompareError(f"cannot read bundle member: {name}") from exc + if len(data) != info.file_size: + raise CompareError(f"ZIP member size mismatch: {name}") + return data + + def _text(self, name: str, *, required: bool = False) -> str | None: + data = self._bytes(name, required=required) + if data is None: + return None + try: + return data.decode("utf-8") + except UnicodeDecodeError as exc: + raise CompareError(f"bundle member must be UTF-8 text: {name}") from exc + + def _json(self, name: str, *, required: bool = False) -> Any: + text = self._text(name, required=required) + if text is None: + return None + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise CompareError(f"malformed JSON in bundle member: {name}") from exc + + def _validate_checksums(self) -> None: + text = self._text("SHA256SUMS.txt", required=True) + assert text is not None + checksums: dict[str, str] = {} + for line_no, line in enumerate(text.splitlines(), start=1): + if not line: + continue + match = _CHECKSUM_RE.fullmatch(line) + if not match: + raise CompareError(f"malformed checksum line {line_no}") + digest, member = match.groups() + _safe_member_name(member) + if member.endswith("/"): + raise CompareError(f"checksum references a directory: {member}") + if member == "SHA256SUMS.txt": + raise CompareError("SHA256SUMS.txt must not checksum itself") + if member in checksums: + raise CompareError(f"duplicate checksum entry: {member}") + if member not in self.files: + raise CompareError(f"checksum references missing member: {member}") + checksums[member] = digest.lower() + + expected = set(self.files) - {"SHA256SUMS.txt"} + actual = set(checksums) + if actual != expected: + missing = sorted(expected - actual) + extra = sorted(actual - expected) + detail = [] + if missing: + detail.append(f"missing checksum entries: {', '.join(missing[:5])}") + if extra: + detail.append(f"unexpected checksum entries: {', '.join(extra[:5])}") + suffix = ": " + "; ".join(detail) if detail else "" + raise CompareError("checksum coverage mismatch" + suffix) + + for member in sorted(expected): + data = self._bytes(member, required=True) + assert data is not None + actual_digest = hashlib.sha256(data).hexdigest() + if actual_digest != checksums[member]: + raise CompareError(f"checksum mismatch: {member}") + + +def _sorted_unique(values: list[Any]) -> list[Any]: + keyed: dict[str, Any] = {} + for value in values: + key = json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + keyed[key] = value + return [keyed[key] for key in sorted(keyed)] + + +def _json_list(reader: _BundleReader, name: str) -> list[Any] | None: + value = reader._json(name) + if value is None: + return None + if not isinstance(value, list): + raise CompareError(f"{name} must contain a JSON array") + return value + + +def _text_value(reader: _BundleReader, name: str) -> str | object: + value = reader._text(name) + if value is None: + return _MISSING + return value.strip() + + +def _normalize_v03(reader: _BundleReader, fields: dict[str, Any]) -> None: + m = reader.manifest + incident = m.get("incident") if isinstance(m.get("incident"), dict) else {} + fields["bundle.capture_mode"] = incident.get("capture_mode") or "local" + if isinstance(m.get("project"), str): + fields["identity.project"] = m["project"] + + commit = incident.get("incident_commit") if incident else None + if not commit: + commit = _text_value(reader, "git/head.txt") + if commit is not _MISSING and commit: + fields["git.commit"] = commit + current_head = incident.get("current_head") if incident else None + if current_head: + fields["git.current_head"] = current_head + + diff_text = reader._text("git/diff.patch") + if diff_text is not None: + names: list[str] = [] + for a_name, b_name in _DIFF_FILE_RE.findall(diff_text): + names.append(b_name if b_name != "/dev/null" else a_name) + fields["git.changed_files"] = sorted(set(names)) + + commands = m.get("commands") if isinstance(m.get("commands"), list) else [] + failed: list[dict[str, Any]] = [] + for item in commands: + if not isinstance(item, dict): + continue + if item.get("timed_out") or item.get("exit_code") not in (0, None): + failed.append( + { + "command": item.get("command"), + "exit_code": item.get("exit_code"), + "timed_out": bool(item.get("timed_out")), + } + ) + fields["failure.commands"] = _sorted_unique(failed) + + stacks = m.get("stacks") if isinstance(m.get("stacks"), list) else None + if stacks is not None: + names = [ + item.get("stack") + for item in stacks + if isinstance(item, dict) and item.get("stack") + ] + fields["runtime.stacks"] = sorted(set(names)) + + system = reader._json("system.json") + if system is not None: + if not isinstance(system, dict): + raise CompareError("system.json must contain an object") + if system.get("python") is not None: + fields["runtime.python"] = system.get("python") + if system.get("platform") is not None: + fields["runtime.platform"] = system.get("platform") + + +def _normalize_v04(reader: _BundleReader, fields: dict[str, Any]) -> None: + m = reader.manifest + fields["bundle.capture_mode"] = m.get("capture_mode") or "github-actions-failure" + for field, key in ( + ("identity.repository", "repository"), + ("identity.workflow", "workflow"), + ("identity.run_id", "run_id"), + ("git.commit", "head_sha"), + ): + if m.get(key) is not None: + fields[field] = m.get(key) + + jobs = _json_list(reader, "github/jobs.json") + if jobs is not None: + failed_jobs: list[dict[str, Any]] = [] + failed_steps: list[dict[str, Any]] = [] + for job in jobs: + if not isinstance(job, dict) or job.get("conclusion") != "failure": + continue + failed_jobs.append({"id": job.get("id"), "name": job.get("name")}) + for step in job.get("steps") or []: + if isinstance(step, dict) and step.get("conclusion") == "failure": + failed_steps.append( + { + "job": job.get("name"), + "number": step.get("number"), + "name": step.get("name"), + } + ) + fields["failure.failed_jobs"] = _sorted_unique(failed_jobs) + fields["failure.failed_steps"] = _sorted_unique(failed_steps) + + commit = reader._json("github/commit.json") + if commit is not None: + if not isinstance(commit, dict): + raise CompareError("github/commit.json must contain an object") + files = commit.get("files") if isinstance(commit.get("files"), list) else [] + names = [ + item.get("filename") + for item in files + if isinstance(item, dict) and item.get("filename") + ] + fields["git.changed_files"] = sorted(set(names)) + + +def _normalize_v05(reader: _BundleReader, fields: dict[str, Any]) -> None: + m = reader.manifest + fields["bundle.capture_mode"] = m.get("capture_mode") or "otlp-file" + selected = m.get("selected") if isinstance(m.get("selected"), dict) else {} + trace_ids = selected.get("trace_ids") if isinstance(selected.get("trace_ids"), list) else [] + fields["production.trace_ids"] = sorted(str(x) for x in trace_ids) + + exceptions = _json_list(reader, "production/exceptions.json") + if exceptions is not None: + compact: list[dict[str, Any]] = [] + for item in exceptions: + if not isinstance(item, dict): + continue + compact.append( + { + "source": item.get("source"), + "type": item.get("type"), + "message": item.get("message"), + "trace_id": item.get("trace_id"), + "span_id": item.get("span_id"), + } + ) + fields["failure.exceptions"] = _sorted_unique(compact) + + services = _json_list(reader, "production/services.json") + if services is not None: + compact_services = [item for item in services if isinstance(item, dict)] + fields["production.services"] = _sorted_unique(compact_services) + + traces = _json_list(reader, "production/traces.json") + if traces is not None: + names = [ + item.get("name") + for item in traces + if isinstance(item, dict) and item.get("name") + ] + fields["production.span_names"] = sorted(set(names)) + + +def _normalize(reader: _BundleReader) -> dict[str, Any]: + schema = reader.manifest["schema"] + fields: dict[str, Any] = {"bundle.schema": schema} + if schema == "fixbundle/0.3": + _normalize_v03(reader, fields) + elif schema == "fixbundle/0.4": + _normalize_v04(reader, fields) + elif schema == "fixbundle/0.5": + _normalize_v05(reader, fields) + return fields + + +def compare_bundles(baseline: Path, incident: Path) -> dict[str, Any]: + baseline = Path(baseline) + incident = Path(incident) + with _BundleReader(baseline) as base_reader, _BundleReader(incident) as incident_reader: + base = _normalize(base_reader) + current = _normalize(incident_reader) + + changes: list[dict[str, Any]] = [] + unchanged = 0 + counts = {status: 0 for status in STATUS_ORDER} + for field in FIELD_ORDER: + before = base.get(field, _MISSING) + after = current.get(field, _MISSING) + if before is _MISSING and after is _MISSING: + status = "unavailable" + item = {"field": field, "status": status, "baseline": None, "incident": None} + elif before is _MISSING: + status = "added" + item = {"field": field, "status": status, "baseline": None, "incident": after} + elif after is _MISSING: + status = "removed" + item = {"field": field, "status": status, "baseline": before, "incident": None} + elif before != after: + status = "changed" + item = {"field": field, "status": status, "baseline": before, "incident": after} + else: + unchanged += 1 + continue + counts[status] += 1 + changes.append(item) + + return { + "schema": "fixbundle/compare-0.1", + "baseline": { + "name": baseline.name, + "schema": base.get("bundle.schema"), + "capture_mode": base.get("bundle.capture_mode"), + }, + "incident": { + "name": incident.name, + "schema": current.get("bundle.schema"), + "capture_mode": current.get("bundle.capture_mode"), + }, + "summary": {**counts, "unchanged": unchanged}, + "changes": changes, + } + + +def _display(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (dict, list)): + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(", ", ": "), + ) + return str(value) + + +def render_text(report: dict[str, Any]) -> str: + baseline = report["baseline"] + incident = report["incident"] + lines = [ + "FixBundle Compare", + f"Baseline: {baseline['name']} ({baseline.get('schema')} / {baseline.get('capture_mode')})", + f"Incident: {incident['name']} ({incident.get('schema')} / {incident.get('capture_mode')})", + "", + ] + changes = report.get("changes") or [] + for status in STATUS_ORDER: + group = [item for item in changes if item.get("status") == status] + if not group: + continue + lines.append(status.upper()) + for item in group: + lines.append( + f"- {item['field']}: {_display(item.get('baseline'))} -> {_display(item.get('incident'))}" + ) + lines.append("") + summary = report["summary"] + lines.append( + "Summary: " + + ", ".join( + f"{key}={summary[key]}" + for key in ("changed", "added", "removed", "unavailable", "unchanged") + ) + ) + return "\n".join(lines) + "\n" + + +def render_json(report: dict[str, Any]) -> str: + return json.dumps(report, indent=2, ensure_ascii=False, sort_keys=True) + "\n" From 050b3bc795901413568ed2bb996a9adab113d470 Mon Sep 17 00:00:00 2001 From: yaaertu Date: Wed, 2 Sep 2026 07:33:08 +0300 Subject: [PATCH 2/7] test: cover cross-source compare and hostile ZIP evidence --- tests/test_compare.py | 261 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 tests/test_compare.py diff --git a/tests/test_compare.py b/tests/test_compare.py new file mode 100644 index 0000000..332ea50 --- /dev/null +++ b/tests/test_compare.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import hashlib +import json +import stat +import zipfile +from pathlib import Path + +import pytest + +from fixbundle.compare import CompareError, compare_bundles, render_json, render_text + + +def _json(value: object) -> str: + return json.dumps(value, ensure_ascii=False) + + +def _bundle(path: Path, files: dict[str, str | bytes]) -> Path: + payload: dict[str, bytes] = {} + for name, value in files.items(): + payload[name] = value if isinstance(value, bytes) else value.encode("utf-8") + checksums = [ + f"{hashlib.sha256(payload[name]).hexdigest()} {name}" + for name in sorted(payload) + ] + payload["SHA256SUMS.txt"] = ("\n".join(checksums) + "\n").encode("utf-8") + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: + for name, value in payload.items(): + zf.writestr(name, value) + return path + + +def _local_bundle(path: Path, *, commit: str, exit_code: int, changed: str) -> Path: + return _bundle( + path, + { + "manifest.json": _json( + { + "schema": "fixbundle/0.3", + "project": "shop-api", + "stacks": [{"stack": "Python"}], + "commands": [ + { + "command": "pytest -q", + "exit_code": exit_code, + "duration_ms": 10, + "timed_out": False, + "output_file": "commands/01.log", + } + ], + "redactions": 0, + "privacy": {}, + } + ), + "system.json": _json({"python": "3.12.1", "platform": "test-os"}), + "stack.json": _json([{"stack": "Python"}]), + "git/head.txt": commit + "\n", + "git/diff.patch": f"diff --git a/{changed} b/{changed}\n", + "commands/01.log": "captured output\n", + "AI_HANDOFF.md": "evidence\n", + }, + ) + + +def _github_bundle(path: Path, *, sha: str, run_id: int, step: str) -> Path: + return _bundle( + path, + { + "manifest.json": _json( + { + "schema": "fixbundle/0.4", + "capture_mode": "github-actions-failure", + "repository": "acme/payments", + "workflow": "CI", + "run_id": run_id, + "head_sha": sha, + "failed_jobs": [10], + "log_files": ["github/jobs/10.log"], + "privacy": {}, + } + ), + "github/jobs.json": _json( + [ + { + "id": 10, + "name": "windows / py3.12", + "conclusion": "failure", + "steps": [ + { + "number": 5, + "name": step, + "conclusion": "failure", + } + ], + } + ] + ), + "github/commit.json": _json( + {"sha": sha, "files": [{"filename": "src/payments.py"}]} + ), + "github/jobs/10.log": "real failure\n", + "AI_HANDOFF.md": "evidence\n", + }, + ) + + +def _otlp_bundle(path: Path) -> Path: + trace_id = "0123456789abcdef0123456789abcdef" + return _bundle( + path, + { + "manifest.json": _json( + { + "schema": "fixbundle/0.5", + "capture_mode": "otlp-file", + "selected": { + "logs": 1, + "spans": 1, + "exceptions": 1, + "trace_ids": [trace_id], + }, + "privacy": {"network_required": False}, + } + ), + "production/exceptions.json": _json( + [ + { + "source": "span-event:exception", + "trace_id": trace_id, + "span_id": "0123456789abcdef", + "type": "PaymentGatewayError", + "message": "charge rejected", + "stacktrace": "omitted from compare normalization", + } + ] + ), + "production/services.json": _json( + [ + { + "service.name": "payments-api", + "service.version": "2.4.2", + "deployment.environment.name": "production", + "deployment.id": "deploy-42", + } + ] + ), + "production/traces.json": _json( + [ + { + "trace_id": trace_id, + "span_id": "0123456789abcdef", + "name": "POST /charge", + } + ] + ), + "production/logs.json": "[]", + "production/incident.json": _json({"trace_ids": [trace_id]}), + "AI_HANDOFF.md": "evidence\n", + }, + ) + + +def _change(report: dict, field: str) -> dict: + return next(item for item in report["changes"] if item["field"] == field) + + +def test_compare_local_to_local_is_deterministic(tmp_path: Path): + baseline = _local_bundle(tmp_path / "baseline.zip", commit="aaa", exit_code=0, changed="a.py") + incident = _local_bundle(tmp_path / "incident.zip", commit="bbb", exit_code=1, changed="b.py") + + report = compare_bundles(baseline, incident) + + assert _change(report, "git.commit")["status"] == "changed" + assert _change(report, "git.changed_files")["incident"] == ["b.py"] + assert _change(report, "failure.commands")["status"] == "changed" + assert render_json(report) == render_json(compare_bundles(baseline, incident)) + text = render_text(report) + assert "CHANGED" in text + assert "git.commit" in text + + +def test_compare_github_to_github_tracks_failed_step_and_commit(tmp_path: Path): + baseline = _github_bundle(tmp_path / "baseline.zip", sha="aaa", run_id=41, step="Tests") + incident = _github_bundle(tmp_path / "incident.zip", sha="bbb", run_id=42, step="Build") + + report = compare_bundles(baseline, incident) + + assert _change(report, "git.commit")["status"] == "changed" + assert _change(report, "identity.run_id")["status"] == "changed" + step = _change(report, "failure.failed_steps") + assert step["status"] == "changed" + assert step["incident"][0]["name"] == "Build" + + +def test_compare_github_to_otlp_reports_cross_source_availability(tmp_path: Path): + baseline = _github_bundle(tmp_path / "ci.zip", sha="aaa", run_id=41, step="Tests") + incident = _otlp_bundle(tmp_path / "production.zip") + + report = compare_bundles(baseline, incident) + + assert _change(report, "bundle.schema")["status"] == "changed" + assert _change(report, "identity.repository")["status"] == "removed" + assert _change(report, "failure.failed_steps")["status"] == "removed" + assert _change(report, "failure.exceptions")["status"] == "added" + assert _change(report, "production.services")["status"] == "added" + assert _change(report, "production.trace_ids")["status"] == "added" + + +def test_compare_rejects_checksum_tampering(tmp_path: Path): + good = _github_bundle(tmp_path / "good.zip", sha="aaa", run_id=41, step="Tests") + tampered = tmp_path / "tampered.zip" + with zipfile.ZipFile(good) as source, zipfile.ZipFile(tampered, "w") as target: + for info in source.infolist(): + data = source.read(info.filename) + if info.filename == "manifest.json": + data = data.replace(b'"run_id": 41', b'"run_id": 99') + target.writestr(info, data) + + with pytest.raises(CompareError, match="checksum mismatch: manifest.json"): + compare_bundles(tampered, good) + + +def test_compare_rejects_path_traversal_and_duplicate_members(tmp_path: Path): + good = _otlp_bundle(tmp_path / "good.zip") + traversal = tmp_path / "traversal.zip" + with zipfile.ZipFile(traversal, "w") as zf: + zf.writestr("../outside.txt", "nope") + zf.writestr("SHA256SUMS.txt", "") + with pytest.raises(CompareError, match="unsafe ZIP member path"): + compare_bundles(traversal, good) + + duplicate = tmp_path / "duplicate.zip" + manifest = _json({"schema": "fixbundle/0.5", "capture_mode": "otlp-file"}).encode() + digest = hashlib.sha256(manifest).hexdigest() + with zipfile.ZipFile(duplicate, "w") as zf: + zf.writestr("manifest.json", manifest) + zf.writestr("manifest.json", manifest) + zf.writestr("SHA256SUMS.txt", f"{digest} manifest.json\n") + with pytest.raises(CompareError, match="duplicate ZIP member"): + compare_bundles(duplicate, good) + + +def test_compare_rejects_symlink_and_unsupported_schema(tmp_path: Path): + good = _otlp_bundle(tmp_path / "good.zip") + symlink = tmp_path / "symlink.zip" + link = zipfile.ZipInfo("manifest.json") + link.create_system = 3 + link.external_attr = (stat.S_IFLNK | 0o777) << 16 + with zipfile.ZipFile(symlink, "w") as zf: + zf.writestr(link, "target") + digest = hashlib.sha256(b"target").hexdigest() + zf.writestr("SHA256SUMS.txt", f"{digest} manifest.json\n") + with pytest.raises(CompareError, match="symlink ZIP member"): + compare_bundles(symlink, good) + + unsupported = _bundle( + tmp_path / "unsupported.zip", + {"manifest.json": _json({"schema": "fixbundle/9.9"})}, + ) + with pytest.raises(CompareError, match="unsupported FixBundle schema"): + compare_bundles(unsupported, good) From 0d78e4bcea9efb6f2c3fe170a5ef8a9143aec298 Mon Sep 17 00:00:00 2001 From: yaaertu Date: Wed, 2 Sep 2026 07:33:43 +0300 Subject: [PATCH 3/7] feat: expose cross-source compare CLI --- src/fixbundle/cli.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/fixbundle/cli.py b/src/fixbundle/cli.py index 9d516ce..3ac4ae9 100644 --- a/src/fixbundle/cli.py +++ b/src/fixbundle/cli.py @@ -8,6 +8,7 @@ from . import __version__ from .collect import build_bundle +from .compare import compare_bundles, render_json, render_text from .github import DEFAULT_MAX_LOG_CHARS, build_github_bundle from .history import build_historical_bundle from .otlp import DEFAULT_MAX_INPUT_BYTES, DEFAULT_MAX_RECORDS, build_otlp_bundle @@ -76,6 +77,17 @@ def otlp_parser() -> argparse.ArgumentParser: return p +def compare_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="fixbundle compare", + description="Compare two integrity-checked FixBundle ZIPs and report deterministic evidence changes.", + ) + p.add_argument("baseline", metavar="BASELINE_ZIP", help="Known-good or earlier FixBundle ZIP") + p.add_argument("incident", metavar="INCIDENT_ZIP", help="Broken or later FixBundle ZIP") + p.add_argument("--format", choices=["text", "json"], default="text", dest="output_format", help="Output format") + return p + + def _lang(value: str) -> str: if value != "auto": return value @@ -170,6 +182,19 @@ def _otlp_main(argv: list[str]) -> int: return 0 +def _compare_main(argv: list[str]) -> int: + args = compare_parser().parse_args(argv) + try: + report = compare_bundles(Path(args.baseline), Path(args.incident)) + except Exception as exc: + print(f"fixbundle compare: failed: {exc}", file=sys.stderr) + return 1 + + rendered = render_json(report) if args.output_format == "json" else render_text(report) + print(rendered, end="") + return 0 + + def main(argv: list[str] | None = None) -> int: _configure_stdio() raw = list(sys.argv[1:] if argv is None else argv) @@ -177,6 +202,8 @@ def main(argv: list[str] | None = None) -> int: return _github_main(raw[1:]) if raw and raw[0] == "otlp": return _otlp_main(raw[1:]) + if raw and raw[0] == "compare": + return _compare_main(raw[1:]) args = parser().parse_args(raw) lang = _lang(args.lang) From e07249ef13323d35074cc82ffa824f745f865935 Mon Sep 17 00:00:00 2001 From: yaaertu Date: Wed, 2 Sep 2026 07:33:56 +0300 Subject: [PATCH 4/7] test: exercise compare CLI end to end --- tests/test_compare_cli.py | 88 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/test_compare_cli.py diff --git a/tests/test_compare_cli.py b/tests/test_compare_cli.py new file mode 100644 index 0000000..cc67959 --- /dev/null +++ b/tests/test_compare_cli.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +import zipfile +from pathlib import Path + + +def _bundle(path: Path, *, commit: str) -> Path: + files = { + "manifest.json": json.dumps( + { + "schema": "fixbundle/0.3", + "project": "cli-compare-demo", + "stacks": [{"stack": "Python"}], + "commands": [], + "privacy": {}, + } + ).encode(), + "git/head.txt": (commit + "\n").encode(), + "git/diff.patch": b"", + "system.json": json.dumps({"python": "3.12", "platform": "test"}).encode(), + "AI_HANDOFF.md": b"evidence\n", + } + lines = [ + f"{hashlib.sha256(files[name]).hexdigest()} {name}" + for name in sorted(files) + ] + files["SHA256SUMS.txt"] = ("\n".join(lines) + "\n").encode() + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: + for name, data in files.items(): + zf.writestr(name, data) + return path + + +def test_compare_cli_json_output(tmp_path: Path): + baseline = _bundle(tmp_path / "baseline.zip", commit="aaa") + incident = _bundle(tmp_path / "incident.zip", commit="bbb") + + proc = subprocess.run( + [ + sys.executable, + "-m", + "fixbundle.cli", + "compare", + str(baseline), + str(incident), + "--format", + "json", + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + timeout=30, + ) + + assert proc.returncode == 0, proc.stdout + report = json.loads(proc.stdout) + commit = next(item for item in report["changes"] if item["field"] == "git.commit") + assert commit == { + "baseline": "aaa", + "field": "git.commit", + "incident": "bbb", + "status": "changed", + } + + +def test_compare_cli_fails_closed_on_invalid_zip(tmp_path: Path): + good = _bundle(tmp_path / "good.zip", commit="aaa") + bad = tmp_path / "bad.zip" + bad.write_text("not a zip", encoding="utf-8") + + proc = subprocess.run( + [sys.executable, "-m", "fixbundle.cli", "compare", str(good), str(bad)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + timeout=30, + ) + + assert proc.returncode == 1 + assert "fixbundle compare: failed:" in proc.stdout From 3d1d224cd72b6703fc185799c4fac1a5e75742b9 Mon Sep 17 00:00:00 2001 From: yaaertu Date: Wed, 2 Sep 2026 07:36:09 +0300 Subject: [PATCH 5/7] demo: compare two real OTLP FixBundle artifacts --- scripts/demo_compare.py | 170 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 scripts/demo_compare.py diff --git a/scripts/demo_compare.py b/scripts/demo_compare.py new file mode 100644 index 0000000..3098db5 --- /dev/null +++ b/scripts/demo_compare.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import json +import tempfile +from datetime import datetime +from pathlib import Path + +from fixbundle.compare import compare_bundles, render_text +from fixbundle.otlp import build_otlp_bundle + +BASELINE_TRACE = "11111111111111111111111111111111" +INCIDENT_TRACE = "22222222222222222222222222222222" +SPAN_ID = "0123456789abcdef" + + +def ns(text: str) -> str: + dt = datetime.fromisoformat(text.replace("Z", "+00:00")) + return str(int(dt.timestamp() * 1_000_000_000)) + + +def attr(key: str, value: str) -> dict: + return {"key": key, "value": {"stringValue": value}} + + +def write_incident( + root: Path, + *, + name: str, + trace_id: str, + version: str, + exception_type: str | None, +) -> Path: + logs = root / f"{name}-logs.jsonl" + traces = root / f"{name}-traces.jsonl" + log_attributes = [attr("http.request.method", "POST")] + severity = "INFO" + message = "charge completed" + status = {"code": 1} + events: list[dict] = [] + if exception_type: + severity = "ERROR" + message = "charge rejected" + status = {"code": 2, "message": "gateway timeout"} + log_attributes.extend( + [ + attr("exception.type", exception_type), + attr("exception.message", "gateway timeout"), + attr("exception.stacktrace", f"{exception_type}: timeout\n at charge.py:42"), + ] + ) + events.append( + { + "name": "exception", + "timeUnixNano": ns("2026-09-02T01:02:03Z"), + "attributes": [ + attr("exception.type", exception_type), + attr("exception.message", "gateway timeout"), + ], + } + ) + + resource = [ + attr("service.name", "payments-api"), + attr("service.version", version), + attr("deployment.environment.name", "production"), + attr("deployment.id", f"deploy-{version}"), + ] + log_payload = { + "resourceLogs": [ + { + "resource": {"attributes": resource}, + "scopeLogs": [ + { + "scope": {"name": "payments.logger"}, + "logRecords": [ + { + "timeUnixNano": ns("2026-09-02T01:02:03Z"), + "severityText": severity, + "traceId": trace_id, + "spanId": SPAN_ID, + "body": {"stringValue": message}, + "attributes": log_attributes, + } + ], + } + ], + } + ] + } + trace_payload = { + "resourceSpans": [ + { + "resource": {"attributes": resource}, + "scopeSpans": [ + { + "scope": {"name": "payments.tracer"}, + "spans": [ + { + "traceId": trace_id, + "spanId": SPAN_ID, + "name": "POST /charge", + "startTimeUnixNano": ns("2026-09-02T01:02:02Z"), + "endTimeUnixNano": ns("2026-09-02T01:02:04Z"), + "status": status, + "attributes": [], + "events": events, + } + ], + } + ], + } + ] + } + logs.write_text(json.dumps(log_payload) + "\n", encoding="utf-8") + traces.write_text(json.dumps(trace_payload) + "\n", encoding="utf-8") + zip_path, _ = build_otlp_bundle( + logs_path=logs, + traces_path=traces, + output_dir=root / f"{name}-out", + trace_id=trace_id, + ) + return zip_path + + +def change(report: dict, field: str) -> dict: + return next(item for item in report["changes"] if item["field"] == field) + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="fixbundle-compare-demo-") as tmp: + root = Path(tmp) + baseline = write_incident( + root, + name="baseline", + trace_id=BASELINE_TRACE, + version="2.4.1", + exception_type=None, + ) + incident = write_incident( + root, + name="incident", + trace_id=INCIDENT_TRACE, + version="2.4.2", + exception_type="PaymentGatewayError", + ) + + report = compare_bundles(baseline, incident) + services = change(report, "production.services") + exceptions = change(report, "failure.exceptions") + traces = change(report, "production.trace_ids") + + assert services["status"] == "changed" + assert services["baseline"][0]["service.version"] == "2.4.1" + assert services["incident"][0]["service.version"] == "2.4.2" + assert exceptions["status"] == "changed" + assert exceptions["baseline"] == [] + assert {item["type"] for item in exceptions["incident"]} == {"PaymentGatewayError"} + assert traces["baseline"] == [BASELINE_TRACE] + assert traces["incident"] == [INCIDENT_TRACE] + + print(render_text(report), end="") + print("PASS input_integrity=validated") + print("PASS service_version=2.4.1->2.4.2") + print("PASS exception=none->PaymentGatewayError") + print(f"PASS trace_id={BASELINE_TRACE}->{INCIDENT_TRACE}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d1e669f09cbe16ef9188af601d5c4c83593fea2a Mon Sep 17 00:00:00 2001 From: yaaertu Date: Wed, 2 Sep 2026 07:36:20 +0300 Subject: [PATCH 6/7] ci: gate cross-source compare demo --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f29a2db..2e8946e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,8 @@ jobs: run: python scripts/demo.py - name: OTLP production evidence demo run: python scripts/demo_otlp.py + - name: Cross-source compare demo + run: python scripts/demo_compare.py live-github-evidence: name: Live GitHub failure evidence From 9082cf792fca5cfdda3d03f3e7394824a641fa1d Mon Sep 17 00:00:00 2001 From: yaaertu Date: Wed, 2 Sep 2026 07:41:22 +0300 Subject: [PATCH 7/7] release: prepare v0.6 cross-source evidence compare --- AGENTS.md | 66 +++++++++------ CHANGELOG.md | 27 +++++++ README.md | 156 ++++++++++++++++++++++-------------- ROADMAP.md | 53 ++++++------ docs/product/NEXT.md | 83 +++++++------------ docs/product/REPO_HOME.md | 28 ++++--- docs/product/V06_COMPARE.md | 72 +++++++++++++++++ pyproject.toml | 6 +- src/fixbundle/__init__.py | 2 +- 9 files changed, 315 insertions(+), 178 deletions(-) create mode 100644 docs/product/V06_COMPARE.md diff --git a/AGENTS.md b/AGENTS.md index f114cc6..0abcae7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,9 +3,9 @@ Bu dosya Codex, Claude Code, Cursor ve diğer kodlama ajanlarının projeyi aynı kurallarla sürdürebilmesi için repo-local çalışma sözleşmesidir. ## Ürün cümlesi -**Package local failures, historical Git bugs, and failed GitHub Actions runs into redacted AI-ready evidence bundles.** +**Capture failure evidence, keep it portable, and compare what changed without trusting one vendor or one AI tool.** -Ürün sınırı: repository context packer veya vendor-specific “explain error” aracı değil, **portable failure evidence**. +Ürün sınırı: repository context packer, observability dashboard, generic log diff veya vendor-specific “explain error” aracı değil. FixBundle'ın çekirdeği **portable failure evidence + deterministic evidence comparison**. ## Her bakım turunun sırası 1. Güncel `main`, CI, açık issue/PR, README ve canlı repo metadata'sını oku. @@ -16,37 +16,52 @@ Bu dosya Codex, Claude Code, Cursor ve diğer kodlama ajanlarının projeyi ayn ```bash python -m pytest -q python scripts/demo.py + python scripts/demo_otlp.py + python scripts/demo_compare.py fixbundle --version fixbundle . --recommend --lang tr ``` -6. GitHub Native davranışı değişirse ayrıca live gate'i koru: +6. GitHub Native davranışı değişirse live gate'i koru: ```bash export GITHUB_TOKEN= fixbundle github --repo yaertu/fixbundle --run 33587184675 --output .fixbundle-live --lang en python scripts/verify_live_github.py .fixbundle-live ``` -7. CI sonucunu görmeden cross-platform veya live PASS yazma. -8. Star/download/benchmark/user quote/sponsor/novelty iddiası uydurma. -9. Secret veya proprietary bundle içeriğini repoya koyma. -10. Sırf aktivite görünsün diye commit atma. +7. Compare input/archive/security davranışı değişirse checksum, hostile ZIP ve cross-source tests geçmeden merge etme. +8. CI sonucunu görmeden cross-platform veya live PASS yazma. +9. Star/download/benchmark/user quote/sponsor/novelty iddiası uydurma. +10. Secret veya proprietary bundle içeriğini repoya koyma. +11. Sırf aktivite görünsün diye commit atma. -## GitHub ana sayfa senkronu -Her positioning/release değişiminde README ve `docs/product/REPO_HOME.md` birlikte kontrol edilir. +## Kanıt ankrajları +### v0.4 GitHub Native +- source failed run: `33587184675` +- proof run: `33589138174` +- expected: platform matrix + Live GitHub failure evidence PASS +- evidence: `docs/evidence/V04_LIVE_GITHUB.md` + +### v0.5 Production Evidence +- OTLP Protocol File Exporter JSON/JSONL +- exact trace/span correlation +- local/offline, bounded, redacted, checksummed +- demo: `scripts/demo_otlp.py` -v0.4 sonrası hedef açıklama: -`Package local failures, historical Git bugs, and failed GitHub Actions runs into redacted AI-ready evidence bundles.` +### v0.6 Cross-source Compare +- CLI: `fixbundle compare baseline.zip incident.zip [--format json]` +- input schemas: `fixbundle/0.3`, `0.4`, `0.5` +- integrity is validated before evidence interpretation +- ZIPs are never extracted or mutated +- no network/LLM required +- demo: `scripts/demo_compare.py` +- design: `docs/product/V06_COMPARE.md` -Hedef topics: -`ai-debugging`, `developer-tools`, `devtools`, `production-debugging`, `temporal-debugging`, `support-bundle`, `diagnostics`, `bug-report`, `ai-coding-assistant`, `github-actions`, `codex`, `claude-code`, `cursor`, `reproducibility`, `git`, `llm` +## GitHub ana sayfa senkronu +Her positioning/release değişiminde README ve `docs/product/REPO_HOME.md` birlikte kontrol edilir. -Connector About/Topics yazamıyorsa yalnız bu UI adımı maintainer'a kısa görev olarak verilir. Değiştirildiğini görmeden değişti denmez. +v0.6 target description: +`Package failures into redacted evidence bundles and compare what changed across local, CI, and OpenTelemetry incidents.` -## v0.4 kanıt ankrajı -- source failed run: `33587184675` -- proof run: `33589138174` -- proof commit: `d15385a7f9ecd0a0dbd1c67b0caad6f7aa21bb95` -- expected: platform matrix 9/9 + Live GitHub failure evidence PASS -- evidence: `docs/evidence/V04_LIVE_GITHUB.md` +Target topics are maintained in `docs/product/REPO_HOME.md`. Connected tools About/Topics yazamıyorsa yalnız bu UI adımı maintainer'a kısa görev olarak verilir. Değiştirildiğini görmeden değişti denmez. ## Sürüm kararı - docs-only: version bump yok @@ -54,14 +69,17 @@ Connector About/Topics yazamıyorsa yalnız bu UI adımı maintainer'a kısa gö - bug fix: patch - stable schema kırılması (v1+): semver major +## Current product gate +v0.6 sonrası varsayılan hareket yeni adapter veya panel yazmak değildir. Önce `docs/product/NEXT.md` içindeki **distribution / repeat-use gate** sınanır. Amaç, unrelated bir maintainer'ın bir FixBundle artifact'ını saklayıp sonraki incident'ta compare için yeniden kullanmasıdır. + ## Devam promptu ```text Continue as FixBundle repository steward for yaertu/fixbundle. Start from current main and read AGENTS.md, README, ROADMAP, docs/product/NEXT.md, REPO_HOME.md, METRICS.md, open issues/PRs and latest CI before changing anything. -Run /truth + /audit discipline. Search current GitHub/Reddit/web and official docs for actual failure-evidence pain, competing tools and platform-native capabilities. Never build a wrapper that merely duplicates a vendor's existing LLM/debugging feature. -Current v0.4 anchor is the real failed GitHub Actions run 33587184675 and successful proof run 33589138174. Preserve the live verification gate when touching GitHub capture. -The researched v0.5 direction is vendor-neutral Production Evidence Import, starting with OpenTelemetry Protocol File Exporter JSON/JSONL logs/traces. Normalize trace/span/service/environment/release/exception evidence into the existing FixBundle schema family, with bounded selection, redaction, checksums and tests. Sentry should be an optional adapter only where it adds portability/correlation beyond Sentry's own llmFormat/event APIs. -Make only evidence-backed changes. Keep README/CHANGELOG/ROADMAP/evidence/repo-home metadata recommendations synchronized. Never fabricate users, stars, benchmarks, tests, compatibility, screenshots or novelty. Check CI after changes and repair failures caused by the change before claiming success. +Run /truth + /audit discipline. Preserve the proven local, historical, live GitHub, OTLP and compare evidence gates. Compare must validate bundle integrity before interpretation and must remain read-only, local and deterministic. +Search current GitHub/Reddit/web and official docs for actual failure-evidence pain, competing tools, repeat-use signals and distribution friction. Never build a wrapper that merely duplicates a vendor's existing LLM/debugging feature. +The current highest-value gate is distribution and repeat use: prove that an unrelated maintainer can install FixBundle, capture a real failure, retain the artifact, and use compare when the next incident changes or recurs. Do not start v0.7 merely to increase version count. +Make only evidence-backed changes. Keep README/CHANGELOG/ROADMAP/evidence/repo-home metadata recommendations synchronized. Never fabricate users, stars, downloads, benchmarks, tests, compatibility, screenshots or novelty. Check CI after changes and repair failures caused by the change before claiming success. End with: what changed, proof, current CI, live public metrics, repo-home metadata status, and the single highest-value next move. ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 63bd7f5..640abd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 0.6.0 — 2026-09-02 + +### Added +- `fixbundle compare baseline.zip incident.zip` deterministic evidence comparison. +- `--format json` machine-readable compare output. +- `fixbundle/0.3`, `fixbundle/0.4` ve `fixbundle/0.5` input normalization. +- Fixed-order comparison for capture identity, project/repository/workflow/run, Git commit + changed files, failed commands/jobs/steps, OTLP exceptions/services/traces and runtime identity. +- `changed`, `added`, `removed`, `unavailable` detailed statuses + `unchanged` summary count. +- `scripts/demo_compare.py`: iki gerçek OTLP FixBundle artifact'ı üretip service version / exception / trace drift'ini doğrulayan reproducible demo. +- `docs/product/V06_COMPARE.md`: compare integrity, normalization and semantic contract. + +### Safety / hardening +- Compare, evidence interpretation'dan önce her iki bundle'ın `SHA256SUMS.txt` dosyasını strict doğrular. +- Exact checksum coverage zorunlu; missing/extra/malformed entries ve checksum mismatch fail-closed. +- Absolute ZIP path, Windows drive path, `..`, backslash, NUL, duplicate members, symlink ve encrypted members reddedilir. +- ZIP member count, per-member byte ve total uncompressed byte bounds uygulanır. +- Input ZIP hiçbir zaman extract edilmez veya mutate edilmez. +- Unknown FixBundle schema fail-closed davranır. +- Compare core LLM/network gerektirmez ve causal root-cause iddiası üretmez. + +### Verification +- `tests/test_compare.py`: local↔local, GitHub↔GitHub, GitHub↔OTLP, checksum tamper, path traversal, duplicate member, symlink ve unsupported-schema regression coverage. +- `tests/test_compare_cli.py`: real CLI JSON output + invalid ZIP fail-closed subprocess coverage. +- `scripts/demo_compare.py`: real v0.5 bundle generation → integrity validation → deterministic compare. +- GitHub Actions PR run `33591450004`: Ubuntu + Windows + macOS × Python 3.10 / 3.12 / 3.13 ve Live GitHub evidence job PASS; compare demo platform matrix içinde PASS. +- Existing historical, OTLP production ve live GitHub evidence gates korunur. + ## 0.5.0 — 2026-09-02 ### Added diff --git a/README.md b/README.md index bd19b22..ebfcbfb 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,31 @@

FixBundle 🧰

-

Hatanın hikâyesini değil, kanıtını paketle.

+

Hatanın hikâyesini değil, kanıtını paketle. Sonra iki kanıtı karşılaştırıp ne değiştiğini gör.

CI Python 3.10+ - Version 0.5.0 + Version 0.6.0 Local first License MIT

FixBundle: failure to portable debugging evidence

-Bir hata dört farklı yerde ortaya çıkabilir: **local command**, **eski Git commit'i**, **GitHub Actions** veya **production telemetry**. FixBundle bunları aynı fikre indirger: bounded + redacted + checksum'lı bir evidence ZIP. Paketi Codex'e, Claude Code'a, Cursor'a, ChatGPT'ye veya insan destek ekibine verebilirsin. +Bir hata **local command**, **eski Git commit'i**, **GitHub Actions** veya **production telemetry** içinde çıkabilir. FixBundle bunları bounded + redacted + checksum'lı evidence ZIP'lere çevirir. v0.6 ile iki FixBundle artifact'ını karşılaştırıp commit, failed step, exception, service, trace ve runtime evidence'ında ne değiştiğini deterministic olarak görebilirsin. -## ⚡ 4 giriş, 1 evidence paketi +Aynı artifact Codex, Claude Code, Cursor, ChatGPT veya insan destek ekibine taşınabilir. Core evidence üretimi ve compare akışı otomatik upload yapmaz. + +## ⚡ Kurulum + +PyPI yayını yapılana kadar: + +```bash +pipx install git+https://github.com/yaertu/fixbundle.git +``` + +## 🧰 Capture + compare ```bash # Local failure @@ -36,64 +46,90 @@ fixbundle otlp \ --traces ./otel-traces.jsonl \ --trace-id \ --lang tr + +# Before / after evidence comparison +fixbundle compare baseline.zip incident.zip +fixbundle compare baseline.zip incident.zip --format json +``` + +GitHub capture için mümkün olan en dar **Actions: Read + Contents: Read** token'ı kullan. OTLP capture ve compare local/offline çalışır; account veya network istemez. + +## 🔬 v0.6 — Cross-source Evidence Compare + +`fixbundle compare` iki ZIP'i satır satır log diff'ine çevirmek yerine önce evidence integrity'sini doğrular, sonra bilinen FixBundle schema'larını ortak alanlara normalize eder. + +Karşılaştırılan evidence sınıfları: +- schema + capture mode +- project / repository / workflow / run identity +- Git commit + changed files +- failed local commands +- failed GitHub jobs + steps +- production exceptions +- service/version/environment/deployment identity +- trace IDs + span names +- stack + Python/platform runtime identity + +Rapor statüleri sabittir: + +```text +CHANGED iki tarafta var, değer farklı +ADDED baseline'da yok, incident'ta var +REMOVED baseline'da var, incident'ta yok +UNAVAILABLE iki tarafta da bu evidence yok +UNCHANGED eşit; summary'de sayılır ``` -PyPI yayını yapılana kadar kurulum: +Cross-source comparison'da `removed`, “gerçek dünyada silindi” anlamına gelmek zorunda değildir. Incident kaynağının o evidence türünü taşımadığı anlamına da gelebilir. Compare causal claim üretmez. + +### Compare güvenlik sınırı + +Input ZIP untrusted kabul edilir. `manifest.json` yorumlanmadan önce: +- `SHA256SUMS.txt` strict parse edilir ve tüm evidence dosyalarının hash'i doğrulanır, +- missing/extra checksum coverage reddedilir, +- checksum mismatch fail-closed olur, +- absolute path, `..`, Windows drive path, backslash ve NUL reddedilir, +- duplicate member, symlink ve encrypted member reddedilir, +- member count / per-member / total uncompressed size bound uygulanır, +- yalnız `fixbundle/0.3`, `fixbundle/0.4`, `fixbundle/0.5` input schema'ları kabul edilir, +- ZIP hiçbir zaman extract edilmez ve input bundle mutate edilmez. + +Ayrıntı: [`docs/product/V06_COMPARE.md`](docs/product/V06_COMPARE.md). + +### Gerçek artifact compare demosu ```bash -pipx install git+https://github.com/yaertu/fixbundle.git +python scripts/demo_compare.py ``` -GitHub capture için mümkün olan en dar **Actions: Read + Contents: Read** token'ı kullan. OTLP capture tamamen localdir; account veya network istemez. +Demo iki gerçek `build_otlp_bundle()` çıktısını üretir ve compare eder: -## 🔭 v0.5: production olayı artık dışarıda kalmıyor +```text +PASS input_integrity=validated +PASS service_version=2.4.1->2.4.2 +PASS exception=none->PaymentGatewayError +PASS trace_id=11111111111111111111111111111111->22222222222222222222222222222222 +``` -`fixbundle otlp`, OpenTelemetry Protocol File Exporter JSON Lines girdisini doğrudan okur: +v0.6 compare gate GitHub Actions run `33591450004` içinde **Ubuntu + Windows + macOS × Python 3.10 / 3.12 / 3.13** ve Live GitHub evidence job ile doğrulandı. +## 🔭 v0.5 — Production Evidence Import + +`fixbundle otlp`, OpenTelemetry Protocol File Exporter JSON/JSONL girdisini local olarak normalize eder: - `resourceLogs → scopeLogs → logRecords` - `resourceSpans → scopeSpans → spans` - exact `traceId` / `spanId` correlation -- `service.name`, service version ve deployment environment evidence +- service/version/deployment identity - `exception.type`, `exception.message`, `exception.stacktrace` -- `--trace-id`, `--since`, `--until` ile bounded selection +- `--trace-id`, `--since`, `--until` bounded selection - input byte + record guards -- selected / omitted record provenance - redaction + SHA-256 integrity -Üretilen production paketi: - -```text -AI_HANDOFF.md -manifest.json -SHA256SUMS.txt -production/ - incident.json - exceptions.json - services.json - traces.json - logs.json -``` - -### Tek komutlu OTLP kanıt demosu +Tek komutlu kanıt demosu: ```bash python scripts/demo_otlp.py ``` -CI'da doğrulanan demo çıktısı: - -```text -PASS trace_id=4bf92f3577b34da6a3ce929d0e0e4736 -PASS correlated_logs=1 -PASS correlated_spans=1 -PASS exception=PaymentGatewayError -PASS service=payments-api -PASS secret_redacted -PASS checksums=7 -``` - -Demo sentetik bir ürün hikâyesi değil, gerçek OTLP nested shape'ini kullanan yeniden üretilebilir bir capture senaryosudur. - ## 🎬 Historical Git kanıtı

@@ -116,18 +152,17 @@ PASS checksums=9 PASS token_not_serialized ``` -Ayrıntı: [`docs/evidence/V04_LIVE_GITHUB.md`](docs/evidence/V04_LIVE_GITHUB.md). - -Bu live gate v0.5 CI içinde de korunur. GitHub capture bozulursa production özelliği yeşil görünemez. +Ayrıntı: [`docs/evidence/V04_LIVE_GITHUB.md`](docs/evidence/V04_LIVE_GITHUB.md). Bu live gate sonraki sürümlerde de CI invariant'ı olarak korunur. ## 🛡️ Privacy by default - `.env`, `.npmrc`, `.pypirc` ve bilinen secret dosyaları local source capture'da dışlanır. - API key, bearer token, GitHub/OpenAI/Google/AWS token kalıpları, JWT, private key ve URL credentials maskelenir. - Local project/home path'leri anonimleştirilir. -- OTLP input absolute path'i manifest'e yazılmaz; yalnız dosya adı + byte/record provenance tutulur. +- OTLP input absolute path'i manifest'e yazılmaz. - Text, patch, log ve telemetry girdileri bound'larla sınırlandırılır. - GitHub log redirect'lerinde bearer token signed blob URL'ye forward edilmez. +- Compare input ZIP'lerini extract etmez. - Hiçbir mode bundle'ı otomatik upload etmez. Redaction kusursuzluk garantisi değildir. Hassas/proprietary bir bundle'ı public paylaşmadan önce ZIP'i kontrol et. @@ -137,57 +172,58 @@ Redaction kusursuzluk garantisi değildir. Hassas/proprietary bir bundle'ı publ | Araç / yaklaşım | Ana iş | |---|---| | **Repomix** | repository → LLM context | -| **temporal-debug-skill** | historical worktree agent akışı | | **GitHub Copilot** | GitHub içinde failed check açıklama | | **Sentry / observability AI** | kendi telemetry backend'i içinde teşhis | | **OTel MCP sunucuları** | canlı telemetry'yi agent'a sorgulatma | -| **FixBundle** | **failure evidence'i bounded, redacted, agent/vendor bağımsız artifact'e çevirme** | +| **Generic diff tools** | text/file difference | +| **FixBundle** | **failure evidence capture + portable integrity + cross-source evidence comparison** | -FixBundle observability dashboard veya AI chat değildir. Ürün sınırı **portable failure evidence**. +FixBundle observability dashboard, AI chat veya root-cause oracle değildir. ## ✅ Doğrulama zinciri -Güncel gate: - ```text pytest -q python scripts/demo.py python scripts/demo_otlp.py +python scripts/demo_compare.py fixbundle --version fixbundle . --recommend --lang en Live GitHub failure evidence Ubuntu / Windows / macOS × Python 3.10 / 3.12 / 3.13 ``` -CI sonucu görülmeden README'ye platform PASS iddiası eklenmez. +CI sonucu görülmeden README'ye platform/live PASS iddiası eklenmez. ## 🌍 English quick summary -**Turn local failures, historical Git bugs, failed GitHub Actions runs, and OpenTelemetry production incidents into redacted, checksummed evidence bundles.** FixBundle is local-first and keeps the evidence portable across AI coding tools and human support. +**Capture local, historical Git, GitHub Actions, and OpenTelemetry production failures as redacted checksummed evidence, then compare two FixBundle artifacts to see what changed.** The core workflow is local-first and portable across AI coding tools and human support. ```bash fixbundle . --run "npm test" -fixbundle . --commit --run "npm test" fixbundle github --repo owner/repo --run fixbundle otlp --logs otel-logs.jsonl --traces otel-traces.jsonl --trace-id +fixbundle compare baseline.zip incident.zip --format json ``` ## 🗺️ Yol haritası - **v0.3 ✅ Temporal Evidence** - **v0.4 ✅ GitHub Native** -- **v0.5 Production Evidence Import:** OTLP core + bounded production incident normalization -- **v0.6 Regression Fingerprints:** bundle-vs-bundle failure/environment/dependency drift -- **v0.7 Agent Handoff:** tool-specific export profiles without changing evidence truth +- **v0.5 ✅ Production Evidence Import** +- **v0.6 ✅ Cross-source Evidence Compare** +- **Distribution gate:** repeat-use kanıtı, packaging/discovery friction +- **v0.7 Agent Handoff:** yalnız gerçek tool-specific handoff ihtiyacı kanıtlanırsa - **v1.0 Stable Evidence Protocol:** public schema + plugin SDK + signed manifest option -Araştırma ve tasarım: [`docs/product/V05_PRODUCTION_EVIDENCE.md`](docs/product/V05_PRODUCTION_EVIDENCE.md). +Sıradaki hedef: [`docs/product/NEXT.md`](docs/product/NEXT.md). ## 🤝 Proje - [Contributing](CONTRIBUTING.md) - [Security](SECURITY.md) - [Roadmap](ROADMAP.md) +- [v0.6 compare design](docs/product/V06_COMPARE.md) - [Live v0.4 evidence](docs/evidence/V04_LIVE_GITHUB.md) - [Landscape](docs/product/LANDSCAPE.md) - [Monetization](docs/product/MONETIZATION.md) @@ -196,11 +232,11 @@ Araştırma ve tasarım: [`docs/product/V05_PRODUCTION_EVIDENCE.md`](docs/produc ## 🔎 GitHub About / Topics -v0.5 hedef About: +v0.6 hedef About: -> Package local, historical, CI, and OpenTelemetry production failures into redacted portable debugging evidence. +> Package failures into redacted evidence bundles and compare what changed across local, CI, and OpenTelemetry incidents. -Hedef topics mevcut discovery setine `github-actions`, `opentelemetry` ve `observability` ekler. Canlı metadata ile öneri [`docs/product/REPO_HOME.md`](docs/product/REPO_HOME.md) içinde ayrı tutulur; UI'da gerçekten değişmeden “güncellendi” denmez. +Hedef topic seti [`docs/product/REPO_HOME.md`](docs/product/REPO_HOME.md) içinde tutulur. GitHub UI/API'de gerçekten değişmeden “güncellendi” denmez. ## 📜 Lisans diff --git a/ROADMAP.md b/ROADMAP.md index 45c091b..f95aded 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,37 +23,44 @@ Roadmap, “daha fazla özellik” yerine **daha iyi failure evidence ve daha k Kanıt: `docs/evidence/V04_LIVE_GITHUB.md`. ## v0.5.0 — Production Evidence Import ✅ -**Sonuç:** “sadece production'da oldu” olayını tek observability vendor'ına kilitlemeden FixBundle evidence protokolüne al. - -İlk core input: **OpenTelemetry Protocol File Exporter JSON Lines**. -- `fixbundle otlp --logs ... [--traces ...]` -- exact `traceId` / `spanId` correlation -- `service.name`, service version, deployment environment/id evidence -- stable `exception.type`, `exception.message`, `exception.stacktrace` +**Sonuç:** “sadece production'da oldu” olayını tek observability vendor'ına kilitlemeden evidence protocolüne al. +- OpenTelemetry Protocol File Exporter JSON/JSONL +- exact trace/span correlation +- service/version/environment/deployment identity +- stable exception evidence - explicit trace/time-window selection -- selected/omitted provenance -- input byte + record guards -- redaction + checksums + AI handoff -- local/offline, account'suz, auto-upload yok -- reproducible `scripts/demo_otlp.py` +- bounded input + redaction + checksums +- local/offline capture +- reproducible OTLP demo + +## v0.6.0 — Cross-source Evidence Compare ✅ +**Sonuç:** iki FixBundle ZIP'i ver, integrity-checked deterministic “ne değişti?” raporu al. +- `fixbundle compare baseline.zip incident.zip` +- `--format json` machine-readable output +- input `SHA256SUMS.txt` doğrulaması interpretation'dan önce +- path traversal, duplicate member, symlink, malformed/tampered ZIP fail-closed +- `fixbundle/0.3`, `0.4`, `0.5` normalization +- local↔local, GitHub↔GitHub, GitHub↔OTLP comparison tests +- commit / changed-file / failed-command / job / step / exception / service / trace / runtime drift +- deterministic `changed`, `added`, `removed`, `unavailable` semantics +- network/LLM yok, input extraction/mutation yok +- real FixBundle OTLP baseline→incident compare demo CI gate + +Tasarım/güvenlik: `docs/product/V06_COMPARE.md`. -Sentry adapter ancak portable normalization veya cross-source correlation gibi ek değer sağladığında gelecek; Sentry'nin mevcut LLM/event API'sini sırf wrapper olsun diye tekrar etmeyeceğiz. +## Distribution gate +v0.7'ye geçmeden önce repeat-use hipotezini sınayacağız. Hedef daha fazla commit değil, gerçek bir maintainer'ın FixBundle artifact'ını saklayıp sonraki incident'ta tekrar kullanması. -## v0.6.0 — Regression Fingerprints -**Sonuç:** “önceden çalışıyordu, şimdi neden bozuk?” sorusunu evidence-vs-evidence karşılaştır. -- normalized failure signature -- exception/trace drift -- dependency/environment drift -- changed-file/release correlation -- deterministic before/after report +Detay: `docs/product/NEXT.md`. ## v0.7.0 — Agent Handoff -- Codex / Claude Code / Cursor için tool-specific export profiles -- ortak kanıtı vendor-specific talimatlardan ayırma +- Codex / Claude Code / Cursor tool-specific export profiles +- ortak kanıtı vendor-specific instruction katmanından ayırma - prompt-injection-safe evidence boundaries +- yalnız gerçek handoff friction kanıtlanırsa ## v0.8.0 — Source Adapters -Demand kanıtlanırsa Sentry ve diğer production source adapter'ları ortak evidence protocolüne bağla. Adapter sayısı başarı metriği değildir; aynı problemi tekrar eden wrapper eklenmez. +Demand kanıtlanırsa Sentry ve diğer production source adapter'larını ortak evidence protocolüne bağla. Adapter sayısı başarı metriği değildir; mevcut vendor özelliğini tekrarlayan wrapper eklenmez. ## v1.0 — Stable Evidence Protocol - versioned public schema diff --git a/docs/product/NEXT.md b/docs/product/NEXT.md index 16f0590..a1b55fa 100644 --- a/docs/product/NEXT.md +++ b/docs/product/NEXT.md @@ -1,62 +1,35 @@ # Next move -## v0.6 candidate — Cross-source Evidence Compare - -**User result:** two FixBundle artifacts in, a deterministic “what changed?” report out. - -v0.5 closes the production-ingestion gap with OTLP. The next useful problem is no longer “collect more logs.” It is comparing a known-good/baseline incident against a broken/current incident without forcing the engineer to manually jump between Git, CI, telemetry and support bundles. - -## Research boundary -Do not build another generic log diff or vendor error-grouping engine. - -- Sentry already owns vendor-specific issue grouping/fingerprints. -- Existing log comparison products can compare two log sets and highlight new/missing/spiking events. -- Git already owns source-level diff/bisect. -- SRE discussions still repeatedly identify “what changed?” and switching among GitHub/observability/tickets/docs as painful. - -The FixBundle-specific wedge is **cross-source artifact comparison**: compare the normalized evidence we already capture from local commands, historical Git, GitHub Actions and OTLP production incidents. +## Distribution gate — prove repeat use before v0.7 + +v0.3–v0.6 now cover the evidence lifecycle: + +```text +local / historical / GitHub Actions / OTLP production + ↓ + FixBundle evidence ZIP + ↓ + compare baseline incident + ↓ + deterministic what-changed +``` -## Proposed CLI +The next highest-value move is **not another adapter**. It is proving that an unrelated maintainer can install FixBundle, capture a real failure, keep the ZIP, and use FixBundle again when the incident changes or recurs. -```bash -fixbundle compare baseline.zip incident.zip -``` +### Definition of done for the distribution gate +- publish a GitHub v0.6.0 release only after main CI is green, +- README first screen explains capture + compare in one glance, +- repository About/topics reflect GitHub Actions, OpenTelemetry and regression comparison, +- provide one copy/paste install path and one copy/paste compare path, +- show reproducible local, historical, live GitHub, OTLP and compare proof without fabricated metrics, +- ask for real issue/discussion feedback around failed CI and production incident handoff, +- record only observed stars/forks/issues/downloads; no vanity projections, +- do not begin v0.7 solely because v0.6 is merged. -Optional machine output: +### Adoption question +**Would someone keep a FixBundle artifact because comparing it with the next incident saves time?** -```bash -fixbundle compare baseline.zip incident.zip --format json -``` +If the answer is not demonstrated, improve packaging, docs, discovery and workflow friction before adding more sources. -## Deterministic comparison layers -1. Bundle/schema/capture-mode identity. -2. Failure signature changes without pretending to replace Sentry grouping. -3. Exception type/message presence and trace/service identity drift. -4. Service/release/environment/deployment changes. -5. Command exit-code and failed job/step changes. -6. Git commit/diff evidence when present. -7. Stack/runtime/dependency evidence changes when present. -8. Missing evidence explicitly reported instead of guessed. - -## Non-goals -- no LLM required for the core diff, -- no “root cause guaranteed” claim, -- no fuzzy merging of unrelated traces/incidents, -- no raw line-by-line dump as the primary result, -- no Sentry fingerprint clone. - -## Definition of done -- compare two valid FixBundle ZIPs read-only, -- validate checksums before comparison, -- reject unsafe ZIP paths / malformed manifests / incompatible unsupported schema, -- normalize evidence across different capture modes, -- emit deterministic JSON plus human-readable Markdown/text, -- clearly separate added / removed / changed / unavailable evidence, -- tests for local↔local, GitHub↔GitHub and GitHub/OTLP cross-source cases, -- reproducible before/after demo, -- existing historical, live GitHub and OTLP gates remain green. - -## Why this could matter -FixBundle becomes more useful on the **second incident**, not only the first. That is directly aligned with the adoption gate that matters most: somebody choosing to use the tool again because prior evidence became a baseline. - -Research notes are intentionally conservative: “what changed?” is a real incident-response problem, but comparison itself is not novel. The product value must come from a portable, normalized, integrity-checked artifact boundary across sources. +### Candidate after distribution proof +v0.7 Agent Handoff can add Codex / Claude Code / Cursor export profiles, but only if users need tool-specific handoff beyond the common portable evidence contract. diff --git a/docs/product/REPO_HOME.md b/docs/product/REPO_HOME.md index ccdf023..c44d3b2 100644 --- a/docs/product/REPO_HOME.md +++ b/docs/product/REPO_HOME.md @@ -3,7 +3,7 @@ Keep this file synchronized with the GitHub repository home page after every positioning/release change. ## Target About description -`Package local failures, historical Git bugs, and failed GitHub Actions runs into redacted AI-ready evidence bundles.` +`Package failures into redacted evidence bundles and compare what changed across local, CI, and OpenTelemetry incidents.` ## Target Topics Use these discovery topics, in this order: @@ -18,31 +18,35 @@ Use these discovery topics, in this order: 8. `bug-report` 9. `ai-coding-assistant` 10. `github-actions` -11. `codex` -12. `claude-code` -13. `cursor` -14. `reproducibility` -15. `git` -16. `llm` +11. `opentelemetry` +12. `observability` +13. `regression-testing` +14. `incident-response` +15. `codex` +16. `claude-code` +17. `cursor` +18. `reproducibility` +19. `git` +20. `llm` Do not use unrelated trending tags. ## Homepage -For now use the repository URL. Add a dedicated landing page only after it provides a measurable advantage over the README/live demo. +For now use the repository URL. Add a dedicated landing page only after it provides a measurable advantage over the README/demo path. ## Social preview Use a 1280×640 visual that says: - FixBundle -- Local / Historical / CI Failure → Redacted Evidence ZIP -- Portable across AI tools and human support +- Failure → Redacted Evidence ZIP → What Changed? +- Local · GitHub Actions · OpenTelemetry · portable across AI tools Do not include fabricated star/download counters. ## Last audited live state — 2026-09-02 -GitHub API showed the maintainer's manual metadata update is live: +GitHub API previously confirmed: - description: `Package a broken repo, failed command, or historical Git commit into a redacted AI-ready debugging bundle.` - topics: `ai-coding-assistant`, `ai-debugging`, `bug-report`, `claude-code`, `codex`, `cursor`, `developer-tools`, `devtools`, `diagnostics`, `git`, `llm`, `production-debugging`, `reproducibility`, `support-bundle`, `temporal-debugging` - stars: 0 - forks: 0 -v0.4 adds GitHub Actions as a proven input, so the target description/topics above are now one release ahead of the live About metadata. The connector can edit repository files but does not currently expose About/Topics mutation. Never claim the target metadata is live until GitHub API confirms it after the maintainer changes the UI. +v0.5 added OpenTelemetry and v0.6 adds evidence comparison, so target metadata is ahead of the last confirmed live About state. The connected repository tools currently do not expose About/Topics mutation. Never claim target metadata is live until GitHub API confirms it after the maintainer updates the repository UI. diff --git a/docs/product/V06_COMPARE.md b/docs/product/V06_COMPARE.md new file mode 100644 index 0000000..ca473e0 --- /dev/null +++ b/docs/product/V06_COMPARE.md @@ -0,0 +1,72 @@ +# v0.6 Cross-source Evidence Compare + +## User result + +```bash +fixbundle compare baseline.zip incident.zip +fixbundle compare baseline.zip incident.zip --format json +``` + +Two FixBundle artifacts become one deterministic, integrity-checked **what changed?** report. Compare does not claim root cause and does not send evidence to an LLM or network service. + +## Supported inputs +- `fixbundle/0.3`: local + historical Git evidence +- `fixbundle/0.4`: GitHub Actions failure evidence +- `fixbundle/0.5`: OTLP production evidence + +## Integrity before interpretation +Compare treats a ZIP as untrusted input. Before parsing `manifest.json` or evidence JSON it: +1. validates member paths, +2. rejects absolute paths, Windows drive paths, `..`, backslashes and NULs, +3. rejects duplicate members, symlink members and encrypted members, +4. enforces member-count, per-member and total uncompressed-size bounds, +5. parses `SHA256SUMS.txt` strictly, +6. requires exact checksum coverage for every file except `SHA256SUMS.txt` itself, +7. hashes every covered member and fails closed on mismatch, +8. accepts only known FixBundle schemas. + +The implementation never extracts the input archives and never mutates them. + +## Normalized evidence fields +Fixed-order comparison currently covers: +- bundle schema + capture mode +- project / repository / workflow / run identity +- incident commit + current head +- changed files +- failed local commands +- failed GitHub jobs + steps +- production exceptions +- service/version/environment/deployment identity +- trace IDs + span names +- detected stacks + Python/platform runtime identity + +## Status semantics +- `changed`: both inputs provide the field and values differ +- `added`: baseline lacks the field, incident provides it +- `removed`: baseline provides the field, incident lacks it +- `unavailable`: neither input provides the field +- equal fields are counted as `unchanged` but omitted from the detailed change list + +These are evidence-availability/value semantics, not causal claims. In cross-source comparisons, `removed` can simply mean that the incident source does not carry that evidence type. + +## Determinism +- fixed field order +- canonical sorting/deduplication for list/dict evidence +- no timestamp in compare output +- JSON output uses stable key sorting +- same two valid artifacts produce the same report + +## Verification +`tests/test_compare.py` covers local↔local, GitHub↔GitHub, GitHub↔OTLP, checksum tampering, traversal, duplicate members, symlinks and unsupported schemas. + +`tests/test_compare_cli.py` runs the real CLI subprocess and verifies JSON output + fail-closed invalid ZIP behavior. + +`scripts/demo_compare.py` creates two real v0.5 OTLP FixBundle artifacts, validates their checksums through compare, and proves service version, exception and trace drift. CI runs this demo on Ubuntu, Windows and macOS across Python 3.10 / 3.12 / 3.13. + +## Non-goals +- no root-cause guarantee +- no fuzzy incident joining +- no generic line-by-line log diff +- no Sentry fingerprint clone +- no automatic upload +- no input extraction or mutation diff --git a/pyproject.toml b/pyproject.toml index ac091db..94ff05f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "fixbundle" -version = "0.5.0" -description = "Package local, historical, CI, and OpenTelemetry production failures into redacted portable debugging evidence." +version = "0.6.0" +description = "Package failures into redacted evidence bundles and compare what changed across local, CI, and production incidents." readme = "README.md" requires-python = ">=3.10" license = {text = "MIT"} authors = [{name = "yaaertu codeR"}] -keywords = ["ai", "debugging", "opentelemetry", "otlp", "observability", "bug-report", "diagnostics", "github-actions", "codex", "claude-code", "cursor", "reproducibility", "support-bundle", "developer-tools"] +keywords = ["ai", "debugging", "opentelemetry", "otlp", "observability", "regression", "incident-response", "bug-report", "diagnostics", "github-actions", "codex", "claude-code", "cursor", "reproducibility", "support-bundle", "developer-tools"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", diff --git a/src/fixbundle/__init__.py b/src/fixbundle/__init__.py index 3d18726..906d362 100644 --- a/src/fixbundle/__init__.py +++ b/src/fixbundle/__init__.py @@ -1 +1 @@ -__version__ = "0.5.0" +__version__ = "0.6.0"