Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit 01b5e8d

Browse files
toraidlcodex
andcommitted
feat: generate artifact diff reports for post-porting review
Add app-level diff report generation (src/app/diff_report.py) that captures before/after artifact state and emits structured JSON covering file additions/removals/modifications, build.prop key changes, and APK metadata deltas. Expose workflow controls via --enable-diff-report and --diff-report, and integrate report generation into the end-to-end flow using baseline state after initialization and final state after modification/repack. Add regression tests for CLI parsing and diff-report generation behavior, plus a focused unit test validating file and build.prop change detection. Update CN/EN README argument tables accordingly. Verification: - .venv/bin/python -m ruff check src/app/diff_report.py src/app/cli.py src/app/workflow.py tests/test_cli.py tests/test_workflow.py tests/test_diff_report.py README.md README_EN.md - .venv/bin/python -m mypy --config-file mypy-curated.ini - .venv/bin/python -m pytest -q tests/test_cli.py tests/test_workflow.py tests/test_diff_report.py - .venv/bin/python -m pytest -q Co-authored-by: OpenAI Codex <noreply@openai.com>
1 parent 99ac6c8 commit 01b5e8d

8 files changed

Lines changed: 315 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ sudo python3 main.py --stock <底包路径> --port <移植包路径> --pack-type
158158
| `--enable-snapshots` | 在关键阶段保存工作目录快照 | `false` |
159159
| `--snapshot-dir` | 快照目录(未设置时使用 `<work-dir>/snapshots`| `null` |
160160
| `--rollback-to-snapshot` | 从指定快照恢复目标工作目录并退出 | `null` |
161+
| `--enable-diff-report` | 生成产物差异报告(前后文件/属性/APK变化) | `false` |
162+
| `--diff-report` | 差异报告 JSON 输出路径 | `build/diff-report.json` |
161163

162164
---
163165

README_EN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ sudo python3 main.py --stock <path_to_stock_zip> --port <path_to_port_zip> --pac
160160
| `--enable-snapshots` | Capture workspace snapshots at key workflow stages | `false` |
161161
| `--snapshot-dir` | Snapshot directory (defaults to `<work-dir>/snapshots`) | `null` |
162162
| `--rollback-to-snapshot` | Restore target workspace from a named snapshot and exit | `null` |
163+
| `--enable-diff-report` | Generate artifact diff report (files/props/APK changes) | `false` |
164+
| `--diff-report` | Output path for artifact diff report JSON | `build/diff-report.json` |
163165

164166
---
165167

src/app/cli.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ def build_parser() -> argparse.ArgumentParser:
7676
default=None,
7777
help="Restore target workspace from the named snapshot and exit",
7878
)
79+
parser.add_argument(
80+
"--enable-diff-report",
81+
action="store_true",
82+
help="Generate before/after artifact diff report",
83+
)
84+
parser.add_argument(
85+
"--diff-report",
86+
default="build/diff-report.json",
87+
help="Output path for artifact diff report (default: build/diff-report.json)",
88+
)
7989
parser.add_argument(
8090
"--phases",
8191
nargs="+",

src/app/diff_report.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Artifact diff report generation for porting outputs."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import json
7+
import logging
8+
import re
9+
import subprocess
10+
from pathlib import Path
11+
from typing import Any
12+
13+
14+
def _sha256(path: Path) -> str:
15+
digest = hashlib.sha256()
16+
with open(path, "rb") as handle:
17+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
18+
digest.update(chunk)
19+
return digest.hexdigest()
20+
21+
22+
def _collect_files(root: Path) -> dict[str, dict[str, Any]]:
23+
files: dict[str, dict[str, Any]] = {}
24+
for path in root.rglob("*"):
25+
if not path.is_file():
26+
continue
27+
rel = str(path.relative_to(root))
28+
files[rel] = {
29+
"size": path.stat().st_size,
30+
"sha256": _sha256(path),
31+
}
32+
return files
33+
34+
35+
def _parse_prop_file(path: Path) -> dict[str, str]:
36+
props: dict[str, str] = {}
37+
with open(path, "r", encoding="utf-8", errors="ignore") as handle:
38+
for raw in handle:
39+
line = raw.strip()
40+
if not line or line.startswith("#") or "=" not in line:
41+
continue
42+
key, value = line.split("=", 1)
43+
props[key.strip()] = value.strip()
44+
return props
45+
46+
47+
def _collect_build_props(root: Path) -> dict[str, dict[str, str]]:
48+
result: dict[str, dict[str, str]] = {}
49+
for prop_file in root.rglob("build.prop"):
50+
if not prop_file.is_file():
51+
continue
52+
rel = str(prop_file.relative_to(root))
53+
result[rel] = _parse_prop_file(prop_file)
54+
return result
55+
56+
57+
def _extract_apk_metadata(apk_path: Path) -> dict[str, Any]:
58+
metadata: dict[str, Any] = {
59+
"size": apk_path.stat().st_size,
60+
"sha256": _sha256(apk_path),
61+
"package": None,
62+
"version_name": None,
63+
"version_code": None,
64+
}
65+
66+
for tool in ("aapt2", "aapt"):
67+
try:
68+
proc = subprocess.run(
69+
[tool, "dump", "badging", str(apk_path)],
70+
check=False,
71+
capture_output=True,
72+
text=True,
73+
)
74+
except FileNotFoundError:
75+
continue
76+
77+
if proc.returncode != 0 or not proc.stdout:
78+
continue
79+
80+
match = re.search(
81+
r"package: name='([^']+)'.*versionCode='([^']+)'.*versionName='([^']*)'",
82+
proc.stdout,
83+
)
84+
if match:
85+
metadata["package"] = match.group(1)
86+
metadata["version_code"] = match.group(2)
87+
metadata["version_name"] = match.group(3)
88+
break
89+
90+
return metadata
91+
92+
93+
def _collect_apks(root: Path) -> dict[str, dict[str, Any]]:
94+
result: dict[str, dict[str, Any]] = {}
95+
for apk in root.rglob("*.apk"):
96+
if not apk.is_file():
97+
continue
98+
rel = str(apk.relative_to(root))
99+
result[rel] = _extract_apk_metadata(apk)
100+
return result
101+
102+
103+
def collect_artifact_state(root: str | Path, logger: logging.Logger) -> dict[str, Any]:
104+
"""Collect file, build.prop, and APK metadata state for diffing."""
105+
target = Path(root).resolve()
106+
if not target.exists():
107+
logger.warning("Artifact state target does not exist: %s", target)
108+
return {"root": str(target), "files": {}, "build_props": {}, "apks": {}}
109+
110+
logger.info("Collecting artifact state from: %s", target)
111+
return {
112+
"root": str(target),
113+
"files": _collect_files(target),
114+
"build_props": _collect_build_props(target),
115+
"apks": _collect_apks(target),
116+
}
117+
118+
119+
def _diff_map(before: dict[str, Any], after: dict[str, Any]) -> dict[str, list[str]]:
120+
before_keys = set(before.keys())
121+
after_keys = set(after.keys())
122+
return {
123+
"added": sorted(after_keys - before_keys),
124+
"removed": sorted(before_keys - after_keys),
125+
"common": sorted(before_keys & after_keys),
126+
}
127+
128+
129+
def generate_diff_report(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
130+
"""Generate a structured artifact diff report."""
131+
file_scope = _diff_map(before.get("files", {}), after.get("files", {}))
132+
modified_files = [
133+
path
134+
for path in file_scope["common"]
135+
if before["files"][path] != after["files"][path]
136+
]
137+
138+
prop_changes: list[dict[str, Any]] = []
139+
prop_scope = _diff_map(before.get("build_props", {}), after.get("build_props", {}))
140+
for prop_path in prop_scope["common"]:
141+
before_props = before["build_props"][prop_path]
142+
after_props = after["build_props"][prop_path]
143+
keys = set(before_props.keys()) | set(after_props.keys())
144+
for key in sorted(keys):
145+
if before_props.get(key) != after_props.get(key):
146+
prop_changes.append(
147+
{
148+
"path": prop_path,
149+
"key": key,
150+
"before": before_props.get(key),
151+
"after": after_props.get(key),
152+
}
153+
)
154+
155+
apk_changes: list[dict[str, Any]] = []
156+
apk_scope = _diff_map(before.get("apks", {}), after.get("apks", {}))
157+
for path in apk_scope["added"]:
158+
apk_changes.append({"path": path, "change": "added", "before": None, "after": after["apks"][path]})
159+
for path in apk_scope["removed"]:
160+
apk_changes.append({"path": path, "change": "removed", "before": before["apks"][path], "after": None})
161+
for path in apk_scope["common"]:
162+
if before["apks"][path] != after["apks"][path]:
163+
apk_changes.append(
164+
{
165+
"path": path,
166+
"change": "modified",
167+
"before": before["apks"][path],
168+
"after": after["apks"][path],
169+
}
170+
)
171+
172+
return {
173+
"summary": {
174+
"files_added": len(file_scope["added"]),
175+
"files_removed": len(file_scope["removed"]),
176+
"files_modified": len(modified_files),
177+
"prop_changes": len(prop_changes),
178+
"apk_changes": len(apk_changes),
179+
},
180+
"files": {
181+
"added": file_scope["added"],
182+
"removed": file_scope["removed"],
183+
"modified": modified_files,
184+
},
185+
"build_props": {
186+
"added_files": prop_scope["added"],
187+
"removed_files": prop_scope["removed"],
188+
"changes": prop_changes,
189+
},
190+
"apks": apk_changes,
191+
}
192+
193+
194+
def save_diff_report(report: dict[str, Any], output_path: str | Path) -> Path:
195+
"""Persist the generated diff report to JSON."""
196+
path = Path(output_path).resolve()
197+
path.parent.mkdir(parents=True, exist_ok=True)
198+
path.write_text(json.dumps(report, indent=2), encoding="utf-8")
199+
return path

src/app/workflow.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pathlib import Path
77

88
from src.app.bootstrap import clean_work_dir, initialize_cache_manager
9+
from src.app.diff_report import collect_artifact_state, generate_diff_report, save_diff_report
910
from src.app.preflight import run_preflight, save_preflight_report
1011
from src.app.snapshots import StageSnapshotManager
1112
from src.core.config_loader import load_device_config
@@ -227,13 +228,21 @@ def execute_porting(args, logger: logging.Logger) -> int:
227228
logger.info(f"Port Device: {port.get_prop('ro.product.name_for_attestation')}")
228229

229230
phases_to_run = args.phases if args.phases else list(DEFAULT_PHASES)
231+
baseline_artifact_state = (
232+
collect_artifact_state(target_work_dir, logger) if args.enable_diff_report else None
233+
)
230234
run_modification_phases(ctx, phases_to_run, logger)
231235
if snapshot_manager:
232236
snapshot_manager.capture("phase3_modified", target_work_dir)
233237

234238
run_repacking(ctx, phases_to_run, pack_type, fs_type, target_work_dir, logger)
235239
if snapshot_manager and ("repack" in phases_to_run or phases_to_run == DEFAULT_PHASES):
236240
snapshot_manager.capture("phase4_repacked", target_work_dir)
241+
if args.enable_diff_report and baseline_artifact_state is not None:
242+
final_artifact_state = collect_artifact_state(target_work_dir, logger)
243+
diff_report = generate_diff_report(baseline_artifact_state, final_artifact_state)
244+
report_path = save_diff_report(diff_report, args.diff_report)
245+
logger.info(f"Artifact diff report saved to: {report_path}")
237246

238247
logger.info("=" * 70)
239248
logger.info("Porting completed successfully!")

tests/test_cli.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,18 @@ def test_parse_args_accepts_snapshot_flags():
4747
assert args.enable_snapshots is True
4848
assert args.snapshot_dir == "build/snapshots"
4949
assert args.rollback_to_snapshot == "phase3_modified"
50+
51+
52+
def test_parse_args_accepts_diff_report_flags():
53+
args = parse_args(
54+
[
55+
"--stock",
56+
"stock.zip",
57+
"--enable-diff-report",
58+
"--diff-report",
59+
"out/diff-report.json",
60+
]
61+
)
62+
63+
assert args.enable_diff_report is True
64+
assert args.diff_report == "out/diff-report.json"

tests/test_diff_report.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import logging
2+
from pathlib import Path
3+
4+
from src.app.diff_report import collect_artifact_state, generate_diff_report
5+
6+
7+
def test_generate_diff_report_tracks_file_and_prop_changes(tmp_path: Path):
8+
target = tmp_path / "target"
9+
target.mkdir(parents=True)
10+
11+
build_prop = target / "system" / "build.prop"
12+
build_prop.parent.mkdir(parents=True)
13+
build_prop.write_text("ro.product.name=device_a\nro.debuggable=0\n", encoding="utf-8")
14+
15+
before = collect_artifact_state(target, logger=logging.getLogger("test"))
16+
17+
build_prop.write_text("ro.product.name=device_b\nro.debuggable=0\n", encoding="utf-8")
18+
added = target / "system" / "new.conf"
19+
added.write_text("x", encoding="utf-8")
20+
21+
after = collect_artifact_state(target, logger=logging.getLogger("test"))
22+
report = generate_diff_report(before, after)
23+
24+
assert "system/new.conf" in report["files"]["added"]
25+
assert any(
26+
item["path"] == "system/build.prop"
27+
and item["key"] == "ro.product.name"
28+
and item["before"] == "device_a"
29+
and item["after"] == "device_b"
30+
for item in report["build_props"]["changes"]
31+
)

tests/test_workflow.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ def make_args(**overrides):
2929
"enable_snapshots": False,
3030
"snapshot_dir": None,
3131
"rollback_to_snapshot": None,
32+
"enable_diff_report": False,
33+
"diff_report": "build/diff-report.json",
3234
}
3335
base.update(overrides)
3436
return Namespace(**base)
@@ -249,3 +251,48 @@ def test_execute_porting_captures_snapshots_when_enabled():
249251
"phase3_modified",
250252
"phase4_repacked",
251253
]
254+
255+
256+
def test_execute_porting_generates_diff_report_when_enabled():
257+
logger = MagicMock()
258+
args = make_args(enable_diff_report=True)
259+
260+
with (
261+
patch("src.app.workflow.initialize_cache_manager") as bootstrap,
262+
patch("src.app.workflow.log_run_configuration"),
263+
patch("src.app.workflow.OtaToolsManager") as otatools_manager_cls,
264+
patch("src.app.workflow.resolve_remote_inputs"),
265+
patch("src.app.workflow.run_preflight") as run_preflight_mock,
266+
patch("src.app.workflow.save_preflight_report"),
267+
patch("src.app.workflow.resolve_work_paths") as resolve_work_paths,
268+
patch("src.app.workflow.RomPackage") as rom_package_cls,
269+
patch("src.app.workflow.PortingContext") as porting_context_cls,
270+
patch("src.app.workflow.load_device_config", return_value={}),
271+
patch("src.app.workflow.determine_pack_settings", return_value=("payload", "erofs")),
272+
patch("src.app.workflow.run_modification_phases"),
273+
patch("src.app.workflow.run_repacking"),
274+
patch("src.app.workflow.collect_artifact_state") as collect_artifact_state_mock,
275+
patch("src.app.workflow.generate_diff_report", return_value={"summary": {}}) as generate_mock,
276+
patch("src.app.workflow.save_diff_report") as save_diff_report_mock,
277+
):
278+
bootstrap.return_value.exit_code = None
279+
bootstrap.return_value.cache_manager = None
280+
otatools_manager_cls.return_value.ensure_otatools.return_value = True
281+
run_preflight_mock.return_value.has_failures.return_value = False
282+
resolve_work_paths.return_value = (
283+
MagicMock(),
284+
MagicMock(),
285+
MagicMock(),
286+
MagicMock(),
287+
)
288+
stock = rom_package_cls.return_value
289+
porting_context = porting_context_cls.return_value
290+
porting_context.stock = stock
291+
porting_context.device_config = {}
292+
collect_artifact_state_mock.side_effect = [{"files": {}}, {"files": {"a": {}}}]
293+
294+
assert execute_porting(args, logger) == 0
295+
296+
assert collect_artifact_state_mock.call_count == 2
297+
generate_mock.assert_called_once()
298+
save_diff_report_mock.assert_called_once()

0 commit comments

Comments
 (0)