Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions diagnostic/build-94ad8d29.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"generated_at": "2026-06-18T23:29:56.596099+00:00",
"commit": "94ad8d29",
"diagnostic_logd": null,
"diagnostic_logd_error": "encryptly binary not found (detected windows-x64; available: linux-arm64, linux-x64, macos-arm64, macos-x64, windows-arm64, windows-x64); cannot create diagnostic\\build-94ad8d29.logd",
"message_blocker": "encryptly could not create an archive. You may have timed out; try launching it in the background and waiting for it to finish with no timeout due to a bug in encryptly.",
"chunked": false,
"chunk_size_bytes": null,
"password": null,
"decrypt_command": null,
"total_modules": 1,
"passed": 0,
"failed": 1,
"modules": [
{
"name": "encryptly-preflight",
"status": "FAIL",
"elapsed_seconds": 0.097,
"artifact": null,
"output": "encryptly could not create an archive. You may have timed out; try launching it in the background and waiting for it to finish with no timeout due to a bug in encryptly. encryptly binary not found (detected windows-x64; available: linux-arm64, linux-x64, macos-arm64, macos-x64, windows-arm64, windows-x64)"
}
],
"pr_note": "Encrypted diagnostic logd artifact was not created; include this JSON report showing why. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging."
}
101 changes: 101 additions & 0 deletions tests/test_diagnostic_diff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
Tests for diagnostic_diff tool (issue #5).
"""

import json
import os
import sys
import tempfile
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
from diagnostic_diff import diff_diagnostics, render_text, load_diagnostic


BASELINE_DATA = {
"commit": "abc12345",
"total_modules": 3,
"passed": 2,
"failed": 1,
"chunked": False,
"modules": [
{"name": "backend", "status": "PASS", "elapsed_seconds": 5.2, "artifact": "bin/backend", "output": "ok"},
{"name": "frontend", "status": "PASS", "elapsed_seconds": 3.1, "artifact": "dist/", "output": "ok"},
{"name": "frailbox", "status": "FAIL", "elapsed_seconds": 0, "artifact": None, "output": "make not found"},
],
}

CURRENT_DATA = {
"commit": "def67890",
"total_modules": 3,
"passed": 3,
"failed": 0,
"chunked": False,
"modules": [
{"name": "backend", "status": "PASS", "elapsed_seconds": 4.8, "artifact": "bin/backend", "output": "ok"},
{"name": "frontend", "status": "PASS", "elapsed_seconds": 3.1, "artifact": "dist/", "output": "ok"},
{"name": "frailbox", "status": "PASS", "elapsed_seconds": 1.2, "artifact": "bin/frailbox", "output": "ok"},
],
}


class TestDiagnosticDiff(unittest.TestCase):

def _write_temp(self, data):
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as f:
json.dump(data, f)
return path

def test_diff_detects_changed_fields(self):
result = diff_diagnostics(BASELINE_DATA, CURRENT_DATA)
self.assertGreater(result["summary"]["total_changes"], 0)
self.assertTrue(result["summary"]["passed_changed"])
self.assertTrue(result["summary"]["failed_changed"])

def test_diff_detects_module_status_change(self):
result = diff_diagnostics(BASELINE_DATA, CURRENT_DATA)
frailbox_changes = [c for c in result["changes"] if "frailbox" in c["field"]]
self.assertGreater(len(frailbox_changes), 0)
status_change = [c for c in frailbox_changes if c["field"] == "module.frailbox.status"]
self.assertEqual(len(status_change), 1)
self.assertEqual(status_change[0]["baseline"], "FAIL")
self.assertEqual(status_change[0]["current"], "PASS")

def test_diff_identical_diagnostics_no_changes(self):
result = diff_diagnostics(BASELINE_DATA, BASELINE_DATA)
self.assertEqual(result["summary"]["total_changes"], 0)

def test_diff_detects_added_module(self):
current = json.loads(json.dumps(CURRENT_DATA))
current["modules"].append({"name": "newmod", "status": "PASS", "elapsed_seconds": 1, "artifact": None, "output": "ok"})
result = diff_diagnostics(BASELINE_DATA, current)
self.assertEqual(result["summary"]["modules_added"], 1)

def test_diff_detects_removed_module(self):
baseline = json.loads(json.dumps(BASELINE_DATA))
baseline["modules"].append({"name": "extramod", "status": "PASS", "elapsed_seconds": 1, "artifact": None, "output": "ok"})
result = diff_diagnostics(baseline, CURRENT_DATA)
self.assertEqual(result["summary"]["modules_removed"], 1)

def test_render_text_produces_markdown(self):
result = diff_diagnostics(BASELINE_DATA, CURRENT_DATA)
text = render_text(result)
self.assertIn("# Diagnostic Diff Report", text)
self.assertIn("abc12345", text)
self.assertIn("def67890", text)

def test_render_text_identical(self):
result = diff_diagnostics(BASELINE_DATA, BASELINE_DATA)
text = render_text(result)
self.assertIn("No differences detected", text)

def test_load_diagnostic_file_not_found(self):
with self.assertRaises(FileNotFoundError):
load_diagnostic("/nonexistent/path.json")


if __name__ == "__main__":
unittest.main()
163 changes: 163 additions & 0 deletions tools/diagnostic_diff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
Diagnostic metadata diff tool.

Compares two build diagnostic JSON files and reports differences in
module status, pass/fail counts, build metadata, and per-module output.

Usage:
python3 tools/diagnostic_diff.py <baseline.json> <current.json>
python3 tools/diagnostic_diff.py <baseline.json> <current.json> --json
python3 tools/diagnostic_diff.py <baseline.json> <current.json> --summary
"""

import argparse
import json
import sys
from pathlib import Path
from typing import Any


def load_diagnostic(path: str) -> dict[str, Any]:
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"Diagnostic file not found: {path}")
with p.open("r", encoding="utf-8") as f:
return json.load(f)


def _modules_to_dict(data: dict[str, Any]) -> dict[str, dict[str, Any]]:
modules = data.get("modules", [])
return {m["name"]: m for m in modules if "name" in m}


def diff_diagnostics(
baseline: dict[str, Any],
current: dict[str, Any],
) -> dict[str, Any]:
changes: list[dict[str, Any]] = []

for field in ("commit", "total_modules", "passed", "failed", "chunked"):
old_val = baseline.get(field)
new_val = current.get(field)
if old_val != new_val:
changes.append({
"field": field,
"baseline": old_val,
"current": new_val,
"change": "modified",
})

old_mods = _modules_to_dict(baseline)
new_mods = _modules_to_dict(current)

for name in set(old_mods.keys()) | set(new_mods.keys()):
old_mod = old_mods.get(name)
new_mod = new_mods.get(name)
if old_mod and not new_mod:
changes.append({
"field": f"module.{name}",
"baseline": old_mod.get("status"),
"current": None,
"change": "removed",
})
elif new_mod and not old_mod:
changes.append({
"field": f"module.{name}",
"baseline": None,
"current": new_mod.get("status"),
"change": "added",
})
elif old_mod and new_mod:
for sub in ("status", "elapsed_seconds", "artifact", "output"):
ov = old_mod.get(sub)
nv = new_mod.get(sub)
if ov != nv:
changes.append({
"field": f"module.{name}.{sub}",
"baseline": ov,
"current": nv,
"change": "modified",
})

summary = {
"total_changes": len(changes),
"modules_added": sum(1 for c in changes if c["change"] == "added" and c["field"].startswith("module.")),
"modules_removed": sum(1 for c in changes if c["change"] == "removed" and c["field"].startswith("module.")),
"modules_modified": sum(1 for c in changes if c["change"] == "modified" and c["field"].startswith("module.")),
"metadata_changed": sum(1 for c in changes if not c["field"].startswith("module.")),
"passed_changed": baseline.get("passed") != current.get("passed"),
"failed_changed": baseline.get("failed") != current.get("failed"),
"baseline_commit": baseline.get("commit"),
"current_commit": current.get("commit"),
"baseline_passed": baseline.get("passed", 0),
"current_passed": current.get("passed", 0),
"baseline_failed": baseline.get("failed", 0),
"current_failed": current.get("failed", 0),
}

return {
"summary": summary,
"changes": changes,
}


def render_text(result: dict[str, Any]) -> str:
s = result["summary"]
lines = [
"# Diagnostic Diff Report",
"",
f"Baseline commit: {s['baseline_commit']}",
f"Current commit: {s['current_commit']}",
"",
f"Pass count: {s['baseline_passed']} -> {s['current_passed']}{' (changed)' if s['passed_changed'] else ''}",
f"Fail count: {s['baseline_failed']} -> {s['current_failed']}{' (changed)' if s['failed_changed'] else ''}",
"",
f"Total changes: {s['total_changes']}",
f" Modules added: {s['modules_added']}",
f" Modules removed: {s['modules_removed']}",
f" Modules modified: {s['modules_modified']}",
f" Metadata changed: {s['metadata_changed']}",
"",
]
if not result["changes"]:
lines.append("No differences detected. Diagnostics are identical.")
else:
lines.append("## Changes")
lines.append("")
lines.append("| Field | Baseline | Current | Change |")
lines.append("| --- | --- | --- | --- |")
for c in result["changes"]:
lines.append(f"| {c['field']} | {c['baseline']} | {c['current']} | {c['change']} |")
return "\n".join(lines) + "\n"


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Diff two build diagnostic JSON files.")
parser.add_argument("baseline", help="Baseline diagnostic JSON file path")
parser.add_argument("current", help="Current diagnostic JSON file path")
parser.add_argument("--json", action="store_true", help="Output as JSON instead of Markdown")
parser.add_argument("--summary", action="store_true", help="Output only the summary section")
args = parser.parse_args(argv)

try:
baseline = load_diagnostic(args.baseline)
current = load_diagnostic(args.current)
except FileNotFoundError as e:
print(f"error: {e}", file=sys.stderr)
return 2

result = diff_diagnostics(baseline, current)

if args.json:
print(json.dumps(result, indent=2, default=str))
elif args.summary:
print(json.dumps(result["summary"], indent=2, default=str))
else:
print(render_text(result), end="")

return 0


if __name__ == "__main__":
raise SystemExit(main())