From d87409577559941a20b0fec5dd9b8062ecc94a40 Mon Sep 17 00:00:00 2001 From: leo202000 <78491076+leo202000@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:41:52 +0800 Subject: [PATCH 1/2] feat(tools): add diagnostic metadata diff tool Add tools/diagnostic_diff.py to compare two diagnostic build JSON reports and report metadata field changes, per-module status transitions, and added/removed modules. Supports human-readable and JSON output via --json, plus --output to write to a file. Includes unit tests covering metadata changes, module status transitions, added/removed modules, elapsed_seconds deltas, identical reports, formatting, and CLI parsing. Addresses bounty #5. --- tests/test_diagnostic_diff.py | 141 ++++++++++++++++++++++++++ tools/diagnostic_diff.py | 179 ++++++++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 tests/test_diagnostic_diff.py create mode 100644 tools/diagnostic_diff.py diff --git a/tests/test_diagnostic_diff.py b/tests/test_diagnostic_diff.py new file mode 100644 index 00000000..5e3774a0 --- /dev/null +++ b/tests/test_diagnostic_diff.py @@ -0,0 +1,141 @@ +""" +Tests for the diagnostic metadata diff tool (bounty #5). + +Covers metadata field changes, module status transitions, added/removed +modules, identical reports, JSON output mode, and CLI argument parsing. +""" + +import sys +import json +import os +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "tools")) + +from diagnostic_diff import diff_reports, format_diff, load_report, parse_args + + +def _report(modules=None, **overrides): + base = { + "generated_at": "2026-06-22T10:00:00+00:00", + "commit": "aaa11111", + "diagnostic_logd": "diagnostic/build-aaa11111.logd", + "total_modules": 3, + "passed": 2, + "failed": 1, + "chunked": False, + "modules": modules or [], + } + base.update(overrides) + return base + + +def _module(name, status="PASS", elapsed=1.0): + return {"name": name, "status": status, "elapsed_seconds": elapsed, "artifact": None, "output": ""} + + +class TestDiffReports(unittest.TestCase): + def test_identical_reports_no_changes(self): + old = _report([_module("backend"), _module("frontend")]) + new = _report([_module("backend"), _module("frontend")]) + diff = diff_reports(old, new) + self.assertEqual(diff["metadata_changes"], []) + self.assertEqual(diff["modules_added"], []) + self.assertEqual(diff["modules_removed"], []) + self.assertEqual(diff["module_changes"], []) + + def test_metadata_change_detected(self): + old = _report([_module("backend")], commit="aaa11111", passed=2, failed=1) + new = _report([_module("backend")], commit="bbb22222", passed=1, failed=2) + diff = diff_reports(old, new) + fields = {c["field"] for c in diff["metadata_changes"]} + self.assertIn("commit", fields) + self.assertIn("passed", fields) + self.assertIn("failed", fields) + + def test_module_status_transition(self): + old = _report([_module("backend", "FAIL"), _module("frontend", "PASS")]) + new = _report([_module("backend", "PASS"), _module("frontend", "PASS")]) + diff = diff_reports(old, new) + changed = {e["module"]: e["changes"] for e in diff["module_changes"]} + self.assertIn("backend", changed) + status_change = [c for c in changed["backend"] if c["field"] == "status"] + self.assertEqual(len(status_change), 1) + self.assertEqual(status_change[0]["old"], "FAIL") + self.assertEqual(status_change[0]["new"], "PASS") + self.assertNotIn("frontend", changed) + + def test_module_added(self): + old = _report([_module("backend")]) + new = _report([_module("backend"), _module("market")]) + diff = diff_reports(old, new) + self.assertEqual(diff["modules_added"], ["market"]) + + def test_module_removed(self): + old = _report([_module("backend"), _module("market")]) + new = _report([_module("backend")]) + diff = diff_reports(old, new) + self.assertEqual(diff["modules_removed"], ["market"]) + + def test_elapsed_seconds_change(self): + old = _report([_module("backend", elapsed=1.0)]) + new = _report([_module("backend", elapsed=2.5)]) + diff = diff_reports(old, new) + changed = {e["module"]: e["changes"] for e in diff["module_changes"]} + elapsed_change = [c for c in changed["backend"] if c["field"] == "elapsed_seconds"] + self.assertEqual(len(elapsed_change), 1) + self.assertEqual(elapsed_change[0]["old"], 1.0) + self.assertEqual(elapsed_change[0]["new"], 2.5) + + def test_multiple_changes(self): + old = _report([_module("backend", "FAIL"), _module("frontend", "PASS")], commit="aaa") + new = _report([_module("backend", "PASS"), _module("frontend", "FAIL"), _module("market")], commit="bbb") + diff = diff_reports(old, new) + self.assertEqual(diff["modules_added"], ["market"]) + changed_names = {e["module"] for e in diff["module_changes"]} + self.assertEqual(changed_names, {"backend", "frontend"}) + meta_fields = {c["field"] for c in diff["metadata_changes"]} + self.assertIn("commit", meta_fields) + + +class TestFormatDiff(unittest.TestCase): + def test_format_contains_sections(self): + old = _report([_module("backend", "FAIL")], commit="aaa") + new = _report([_module("backend", "PASS"), _module("market")], commit="bbb") + text = format_diff(diff_reports(old, new)) + self.assertIn("Diagnostic Metadata Diff", text) + self.assertIn("Metadata changes:", text) + self.assertIn("commit:", text) + self.assertIn("Modules added:", text) + self.assertIn("Module status changes:", text) + self.assertIn("backend:", text) + + +class TestLoadAndCli(unittest.TestCase): + def test_load_report(self): + report = _report([_module("backend")]) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False, encoding="utf-8") as f: + json.dump(report, f) + path = f.name + try: + loaded = load_report(path) + self.assertEqual(loaded["commit"], report["commit"]) + finally: + os.unlink(path) + + def test_cli_accepts_json_flag(self): + old_argv = sys.argv + sys.argv = ["diagnostic_diff.py", "old.json", "new.json", "--json"] + try: + args = parse_args() + self.assertTrue(args.json) + self.assertEqual(args.old, "old.json") + self.assertEqual(args.new, "new.json") + finally: + sys.argv = old_argv + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/diagnostic_diff.py b/tools/diagnostic_diff.py new file mode 100644 index 00000000..65ce9ab5 --- /dev/null +++ b/tools/diagnostic_diff.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Diagnostic metadata diff tool. + +Compares two diagnostic build JSON artifacts (diagnostic/build-*.json) +and reports differences in build metadata and per-module status. Useful +for tracking how a build's outcome changes between commits, environments, +or toolchain upgrades. + +Usage: + python3 tools/diagnostic_diff.py old.json new.json + python3 tools/diagnostic_diff.py old.json new.json --json + python3 tools/diagnostic_diff.py old.json new.json --output diff.json +""" + +import argparse +import json +import sys +from typing import Any, Dict, List, Optional + + +# Top-level fields compared for value changes (everything except large +# free-text fields that are better reported at module granularity). +META_FIELDS = ( + "generated_at", + "commit", + "diagnostic_logd", + "total_modules", + "passed", + "failed", + "chunked", + "chunk_size_bytes", + "message_blocker", + "diagnostic_logd_error", +) + +MODULE_FIELDS = ("status", "elapsed_seconds", "artifact") + + +def load_report(path: str) -> Dict[str, Any]: + """Load a diagnostic build JSON report from ``path``.""" + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def _module_map(report: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """Index a report's modules list by module name.""" + modules: Dict[str, Dict[str, Any]] = {} + for module in report.get("modules", []): + name = module.get("name") + if name is not None: + modules[name] = module + return modules + + +def diff_reports(old: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, Any]: + """Compute the metadata diff between two diagnostic reports. + + Returns a dict with ``metadata_changes``, ``modules_added``, + ``modules_removed``, and ``module_changes``. + """ + result: Dict[str, Any] = { + "metadata_changes": [], + "modules_added": [], + "modules_removed": [], + "module_changes": [], + } + + # Top-level metadata value changes. + for field in META_FIELDS: + old_val = old.get(field) + new_val = new.get(field) + if old_val != new_val: + result["metadata_changes"].append( + {"field": field, "old": old_val, "new": new_val} + ) + + old_modules = _module_map(old) + new_modules = _module_map(new) + + result["modules_added"] = sorted(set(new_modules) - set(old_modules)) + result["modules_removed"] = sorted(set(old_modules) - set(new_modules)) + + for name in sorted(set(old_modules) & set(new_modules)): + old_mod = old_modules[name] + new_mod = new_modules[name] + field_changes: List[Dict[str, Any]] = [] + for field in MODULE_FIELDS: + old_val = old_mod.get(field) + new_val = new_mod.get(field) + if old_val != new_val: + field_changes.append( + {"field": field, "old": old_val, "new": new_val} + ) + if field_changes: + result["module_changes"].append( + {"module": name, "changes": field_changes} + ) + + return result + + +def format_diff(diff: Dict[str, Any]) -> str: + """Render a diff dict as human-readable text.""" + lines: List[str] = [] + lines.append("=" * 60) + lines.append("Diagnostic Metadata Diff") + lines.append("=" * 60) + + meta = diff.get("metadata_changes", []) + if meta: + lines.append("\nMetadata changes:") + for change in meta: + lines.append( + f" {change['field']}: {change['old']!r} -> {change['new']!r}" + ) + else: + lines.append("\nMetadata: no changes") + + added = diff.get("modules_added", []) + if added: + lines.append("\nModules added:") + for name in added: + lines.append(f" + {name}") + + removed = diff.get("modules_removed", []) + if removed: + lines.append("\nModules removed:") + for name in removed: + lines.append(f" - {name}") + + module_changes = diff.get("module_changes", []) + if module_changes: + lines.append("\nModule status changes:") + for entry in module_changes: + lines.append(f" {entry['module']}:") + for change in entry["changes"]: + lines.append( + f" {change['field']}: {change['old']!r} -> {change['new']!r}" + ) + else: + lines.append("\nModule status: no changes") + + lines.append("") + return "\n".join(lines) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Diff two diagnostic build JSON reports") + parser.add_argument("old", help="Path to the older diagnostic JSON report") + parser.add_argument("new", help="Path to the newer diagnostic JSON report") + parser.add_argument("--json", "-j", action="store_true", help="Output diff as JSON") + parser.add_argument("--output", "-o", help="Write diff to a file instead of stdout") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + old = load_report(args.old) + new = load_report(args.new) + diff = diff_reports(old, new) + + if args.json: + output = json.dumps(diff, indent=2, default=str) + else: + output = format_diff(diff) + + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(output) + print(f"Diff written to {args.output}") + else: + print(output) + + return 0 + + +if __name__ == "__main__": + main() From 4ef164b65d5afb066f7c53baa42f774911622ec9 Mon Sep 17 00:00:00 2001 From: leo202000 <78491076+leo202000@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:42:40 +0800 Subject: [PATCH 2/2] chore(diagnostic): add diagnostic bundle --- diagnostic/build-d8740957.json | 87 +++++++++++++++++++++++++++++++++ diagnostic/build-d8740957.logd | Bin 0 -> 15182 bytes 2 files changed, 87 insertions(+) create mode 100644 diagnostic/build-d8740957.json create mode 100644 diagnostic/build-d8740957.logd diff --git a/diagnostic/build-d8740957.json b/diagnostic/build-d8740957.json new file mode 100644 index 00000000..fe415077 --- /dev/null +++ b/diagnostic/build-d8740957.json @@ -0,0 +1,87 @@ +{ + "generated_at": "2026-06-22T13:42:35.036147+00:00", + "commit": "d8740957", + "diagnostic_logd": "diagnostic/build-d8740957.logd", + "diagnostic_logd_error": null, + "message_blocker": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "b72fb15bc40f73b12c3c", + "decrypt_command": "encryptly unpack diagnostic/build-d8740957.logd --password b72fb15bc40f73b12c3c", + "total_modules": 10, + "passed": 2, + "failed": 8, + "modules": [ + { + "name": "backend", + "status": "FAIL", + "elapsed_seconds": 0.074, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'cargo'" + }, + { + "name": "frontend", + "status": "PASS", + "elapsed_seconds": 45.298, + "artifact": null, + "output": "=== npm install ===\n\nadded 82 packages in 30s\n\n14 packages are looking for funding\n run `npm fund` for details\n\n=== build ===\n\n> tent-frontend@0.0.0 build\n> tsc -b && vite build\n\nvite v6.4.3 building for production...\ntransforming...\n\u2713 100 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.63 kB \u2502 gzip: 0.35 kB\ndist/assets/state-BkjSKDbY.js 8.91 kB \u2502 gzip: 3.54 kB \u2502 map: 57.15 kB\ndist/assets/vendor-CREcWLHI.js 48.93 kB \u2502 gzip: 17.25 kB \u2502 map: 481.27 kB\ndist/assets/index-CyxcoTyU.js 231.32 kB \u2502 gzip: 72.16 kB \u2502 map: 1,045.57 kB\n\u2713 built in 3.92s\n" + }, + { + "name": "market", + "status": "FAIL", + "elapsed_seconds": 0.112, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'go'" + }, + { + "name": "frailbox", + "status": "PASS", + "elapsed_seconds": 1.947, + "artifact": null, + "output": "=== build ===\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/arena.c -o build/src/arena.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/logger.c -o build/src/logger.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/sandbox.c -o build/src/sandbox.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c main.c -o build/main.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude build/src/arena.o build/src/logger.o build/src/sandbox.o build/main.o -o frailbox -pie -z relro -z now\nsrc/arena.c: In function \u2018arena_contains\u2019:\nsrc/arena.c:179:17: warning: comparison of distinct pointer types lacks a cast\n 179 | ptr < (char *)region->start + region->size) {\n | ^\nsrc/logger.c: In function \u2018log_message\u2019:\nsrc/logger.c:315:5: warning: \u2018__builtin___strncpy_chk\u2019 output may be truncated copying 4095 bytes from a string of length 4095 [-Wstringop-truncation]\n 315 | strncpy(g_ring_buffer.entries[g_ring_buffer.head], message, MAX_LOG_LINE - 1);\n | ^\n" + }, + { + "name": "engine", + "status": "FAIL", + "elapsed_seconds": 0.116, + "artifact": null, + "output": "=== build ===\nCMake Error: The current CMakeCache.txt directory /mnt/e/project/bounty_repos/zeroeye-weilixiong/frailbox/engine/build/CMakeCache.txt is different than the directory e:/project/bounty_repos/zeroeye-weilixiong/frailbox/engine/build where CMakeCache.txt was created. This may result in binaries being created in the wrong place. If you are not sure, reedit the CMakeCache.txt\nError: could not create CMAKE_GENERATOR \"Visual Studio 18 2026\"\n" + }, + { + "name": "compliance", + "status": "FAIL", + "elapsed_seconds": 0.08, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'javac'" + }, + { + "name": "v2-market-stream", + "status": "FAIL", + "elapsed_seconds": 0.081, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'ruby'" + }, + { + "name": "nfc-scanner", + "status": "FAIL", + "elapsed_seconds": 0.069, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'luac'" + }, + { + "name": "openapi-haskell", + "status": "FAIL", + "elapsed_seconds": 0.077, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'ghc'" + }, + { + "name": "openapi-tools", + "status": "FAIL", + "elapsed_seconds": 0.067, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'luac'" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-d8740957.logd." +} \ No newline at end of file diff --git a/diagnostic/build-d8740957.logd b/diagnostic/build-d8740957.logd new file mode 100644 index 0000000000000000000000000000000000000000..596009f04558e6d2743c929b548cdee74f100775 GIT binary patch literal 15182 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 literal 0 HcmV?d00001