From 7fcdb41a8523d31966617d3308daee3b4ddcf8ea Mon Sep 17 00:00:00 2001 From: hangshuo652 Date: Wed, 27 May 2026 08:42:41 +0800 Subject: [PATCH] init: cobol-java migration verification platform v3 (42 tests, JCL module) --- DESIGN.md | 40 ++++++ config.py | 48 -------- config/__init__.py | 49 ++++++++ data/diff_result.py | 1 + jcl/__init__.py | 0 jcl/executor.py | 101 +++++++++++++++ jcl/parser.py | 118 ++++++++++++++++++ orchestrator.py | 16 +++ tasks/6aaa7e43.json | 1 + tests/fixtures/simple.cbl | 9 +- tests/test_golden.py | 226 ++++++++++++++++++++++++++++++++++ uploads/6aaa7e43/copybook.cpy | 4 + uploads/6aaa7e43/java | 1 + uploads/6aaa7e43/mapping.yaml | 13 ++ uploads/6aaa7e43/program.cbl | 13 ++ web/api.py | 99 ++++++++------- web/static/script.js | 31 +++-- web/static/style.css | 81 ++++++++++-- web/templates/result.html | 106 +++++++++++++--- web/templates/upload.html | 53 +++++--- web/worker.py | 8 ++ 21 files changed, 870 insertions(+), 148 deletions(-) create mode 100644 DESIGN.md delete mode 100644 config.py create mode 100644 jcl/__init__.py create mode 100644 jcl/executor.py create mode 100644 jcl/parser.py create mode 100644 tasks/6aaa7e43.json create mode 100644 tests/test_golden.py create mode 100644 uploads/6aaa7e43/copybook.cpy create mode 100644 uploads/6aaa7e43/java create mode 100644 uploads/6aaa7e43/mapping.yaml create mode 100644 uploads/6aaa7e43/program.cbl diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..cd31392 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,40 @@ +# DESIGN.md — verify-cli Web UI + +## Aesthetic +Terminal Developer Tool — 命令行工具的可视化包装。暗底、等宽、最小装饰。 +用户的第一反应应该是"这是我的终端,只不过多了个表单"。 + +## Typography +- Primary: SF Mono / Fira Code / Cascadia Code / Consolas (等宽字体堆栈) +- 全部使用系统原生字体,零外部依赖 +- 层级差异通过字号和颜色区分,不换字体家族 + +## Color +| Token | Hex | Usage | +|-------|-----|-------| +| bg | #0a0e14 | 页面底色 | +| panel | #12171f | 卡片/表单容器 | +| border | #1f2937 | 分割线 | +| text | #b2becd | 正文 | +| dim | #5c6e80 | 标签/次要信息 | +| accent | #39bae6 | 链接/操作色 | +| green | #7fd962 | 成功状态 | +| red | #f26d78 | 错误状态 | +| yellow | #ffad66 | 等待中状态 | + +## Layout +- 单栏,最大宽度 680px +- Header → Sections → Footer 的垂直流 +- 表单使用 CSS Grid 双列布局 +- 结果页 Section 分离 Summary 和 Field Results + +## Spacing +- 页面 padding: 3rem 1.5rem +- Section 内 padding: 1.5rem, 间距 1rem +- 表单 label 间距: .75rem + +## Decisions +- 不使用 Jinja2(3.1+ 与 Starlette 不兼容),改用字符串替换 +- 不使用任何 CSS 框架,纯手写 CSS variables +- 不使用 emoji 或装饰图标,状态通过颜色边框表达 +- 无紫色渐变、无 3 列 icon grid、无居中布局、无装饰性波浪 — AI slop 反模式检查通过 diff --git a/config.py b/config.py deleted file mode 100644 index c48828c..0000000 --- a/config.py +++ /dev/null @@ -1,48 +0,0 @@ -from dataclasses import dataclass, field -from pathlib import Path - - -@dataclass -class Config: - project_name: str = "" - copybook_paths: list = field(default_factory=lambda: ["./copybooks"]) - dialect: str = "ibm" - llm_model: str = "gpt-4o-mini" - llm_timeout: int = 15 - llm_cache_dir: str = ".cache/llm" - coverage_default: str = "boundary" - rounding_mode: str = "TRUNCATE" - tolerance: float = 0.01 - runner_mode: str = "native" - spark_master: str = "local[*]" - spark_input_format: str = "json" - num_records: int = 1000 - branch_pass: float = 0.80 - max_llm_cost: float = 0.50 - - @classmethod - def from_toml(cls, path="aurak.toml"): - import tomllib - try: - with open(path, "rb") as f: - d = tomllib.load(f) - except: - return cls() - c = cls() - p = d.get("project", {}) - c.project_name = p.get("name", "") - c.copybook_paths = p.get("copybook_paths", c.copybook_paths) - c.dialect = p.get("dialect", "ibm") - ll = d.get("llm", {}) - c.llm_model = ll.get("model", c.llm_model) - co = d.get("coverage", {}) - c.coverage_default = co.get("default_target", "boundary") - cp = d.get("comparison", {}) - c.rounding_mode = cp.get("rounding_mode", "TRUNCATE") - c.tolerance = cp.get("default_tolerance", c.tolerance) - r = d.get("runner", {}) - c.runner_mode = r.get("mode", "native") - s = d.get("spark", {}) - c.spark_master = s.get("master", "local[*]") - c.num_records = s.get("num_records", c.num_records) - return c diff --git a/config/__init__.py b/config/__init__.py index e69de29..d943037 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass, field +from pathlib import Path +from .mapping import MappingConfig, FieldMapping + + +@dataclass +class Config: + project_name: str = "" + copybook_paths: list = field(default_factory=lambda: ["./copybooks"]) + dialect: str = "ibm" + llm_model: str = "gpt-4o-mini" + llm_timeout: int = 15 + llm_cache_dir: str = ".cache/llm" + coverage_default: str = "boundary" + rounding_mode: str = "TRUNCATE" + tolerance: float = 0.01 + runner_mode: str = "native" + spark_master: str = "local[*]" + spark_input_format: str = "json" + num_records: int = 1000 + branch_pass: float = 0.80 + max_llm_cost: float = 0.50 + + @classmethod + def from_toml(cls, path="aurak.toml"): + import tomllib + try: + with open(path, "rb") as f: + d = tomllib.load(f) + except: + return cls() + c = cls() + p = d.get("project", {}) + c.project_name = p.get("name", "") + c.copybook_paths = p.get("copybook_paths", c.copybook_paths) + c.dialect = p.get("dialect", "ibm") + ll = d.get("llm", {}) + c.llm_model = ll.get("model", c.llm_model) + co = d.get("coverage", {}) + c.coverage_default = co.get("default_target", "boundary") + cp = d.get("comparison", {}) + c.rounding_mode = cp.get("rounding_mode", "TRUNCATE") + c.tolerance = cp.get("default_tolerance", c.tolerance) + r = d.get("runner", {}) + c.runner_mode = r.get("mode", "native") + s = d.get("spark", {}) + c.spark_master = s.get("master", "local[*]") + c.num_records = s.get("num_records", c.num_records) + return c diff --git a/data/diff_result.py b/data/diff_result.py index d971576..c35b16d 100644 --- a/data/diff_result.py +++ b/data/diff_result.py @@ -30,6 +30,7 @@ class VerificationRun: branch_rate: float = 0.0 llm_cost: float = 0.0 report_path: str = "" + debug: dict = field(default_factory=dict) def __post_init__(self): if not self.timestamp: diff --git a/jcl/__init__.py b/jcl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jcl/executor.py b/jcl/executor.py new file mode 100644 index 0000000..06c13e9 --- /dev/null +++ b/jcl/executor.py @@ -0,0 +1,101 @@ +"""JCL Executor — executes parsed JCL steps using platform components.""" +from pathlib import Path +from jcl.parser import Job, JobStep, CondParam, COND_OPS + + +class JclExecutor: + """Executes JCL Job steps. Uses platform CobolRunner for COBOL programs.""" + + def __init__(self, root_dir: str, cobol_dir: str, copybook_dir: str): + self.root_dir = Path(root_dir) + self.cobol_dir = Path(cobol_dir) + self.copybook_dir = Path(copybook_dir) + self.bin_dir = self.root_dir / "bin" + self.bin_dir.mkdir(exist_ok=True) + self.last_rc: int = 0 + self.step_rcs: dict[str, int] = {} + self.results: dict[str, dict] = {} + + def run(self, job: Job) -> int: + for step in job.steps: + rc = self._execute_step(step) + self.last_rc = rc + self.step_rcs[step.step_name] = rc + + return self.last_rc + + def _execute_step(self, step: JobStep) -> int: + # COND check + if step.cond and not self._check_cond(step.cond): + self.results[step.step_name] = {"status": "SKIPPED", "cond": str(step.cond)} + return 0 + + prog = step.program.upper() + if prog == "SORT": + return self._run_sort(step) + + # Use platform's CobolRunner + import sys + sys.path.insert(0, str(Path(__file__).parent.parent)) + + from runners.cobol_runner import CobolRunner + + cbl_path = self.cobol_dir / f"{step.program}.cbl" + runner = CobolRunner() + build = runner.compile(str(cbl_path)) + if not build.success: + self.results[step.step_name] = {"status": "COMPILE_ERROR", "log": build.log[-200:]} + return 8 + + # Map DD entries to environment variables + env_in = {} + env_out = {} + for dd in step.dd_entries: + name = dd.dd_name.upper() + if dd.dsn: + path = self._resolve_path(dd.dsn) + if name in ("TRANSIN", "MEMBER", "VALIDIN", "RATE", "BILLING"): + env_in[name] = str(path) + elif name in ("VALIDOUT", "REJECT", "REPORTERR", "CALCOUT", "STMT", "SUMMARY"): + env_out[name] = str(path) + + input_path = env_in.get(list(env_in.keys())[0], "") + output_path = env_out.get(list(env_out.keys())[0], str(self.root_dir / "data" / "work" / f"{step.step_name.lower()}_out.bin")) + + run = runner.run(build.artifact_path, input_path, output_path) + self.results[step.step_name] = { + "status": "OK" if run.success else "ERROR", + "rc": 0 if run.success else 12 + } + return 0 if run.success else 12 + + def _check_cond(self, cond: CondParam) -> bool: + if cond.step_name: + target = self.step_rcs.get(cond.step_name, 0) + return not COND_OPS.get(cond.operator, lambda x, y: False)(target, cond.code) + return True + + def _run_sort(self, step: JobStep) -> int: + import subprocess + infile = None + outfile = None + for dd in step.dd_entries: + n = dd.dd_name.upper() + if n == "SORTIN" and dd.dsn: + infile = self._resolve_path(dd.dsn) + elif n == "SORTOUT" and dd.dsn: + outfile = self._resolve_path(dd.dsn) + + if not infile or not outfile: + return 12 + + if infile.exists(): + lines = sorted(infile.read_text().splitlines()) + outfile.write_text("\n".join(lines)) + self.results[step.step_name] = {"status": "OK", "rc": 0} + return 0 + + def _resolve_path(self, dsn: str) -> Path: + import re + dsn = re.sub(r"\(\+?\d+\)", "", dsn).strip(".") + return (self.root_dir / dsn.lstrip("/")).resolve() diff --git a/jcl/parser.py b/jcl/parser.py new file mode 100644 index 0000000..2216e2a --- /dev/null +++ b/jcl/parser.py @@ -0,0 +1,118 @@ +"""JCL Parser — Phase 2. Parses JCL scripts into structured Job objects.""" +import re +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class DDEntry: + dd_name: str + dsn: Optional[str] = None + disp: Optional[str] = None + sysout: Optional[str] = None + inline_data: list[str] = field(default_factory=list) + + +@dataclass +class CondParam: + code: int + operator: str # EQ, NE, GT, GE, LT, LE + step_name: Optional[str] = None + + +@dataclass +class JobStep: + step_name: str + program: str + dd_entries: list[DDEntry] = field(default_factory=list) + cond: Optional[CondParam] = None + parm: Optional[str] = None + + +@dataclass +class Job: + job_name: str + steps: list[JobStep] = field(default_factory=list) + + +COND_OPS = { + "EQ": lambda rc, code: rc == code, "NE": lambda rc, code: rc != code, + "GT": lambda rc, code: rc > code, "GE": lambda rc, code: rc >= code, + "LT": lambda rc, code: rc < code, "LE": lambda rc, code: rc <= code, +} + + +def parse_jcl(filepath: str) -> Optional[Job]: + """Parse a JCL file into a Job object.""" + with open(filepath, "r", encoding="utf-8") as f: + lines = _merge_continuations(f.readlines()) + + job = None + current_step: Optional[JobStep] = None + in_sysin = False + sysin_lines: list[str] = [] + + for line in lines: + stripped = line.strip() + if stripped.startswith("//*") or not stripped: + continue + + if in_sysin: + if stripped == "/*": + if current_step and current_step.dd_entries: + current_step.dd_entries[-1].inline_data = sysin_lines + sysin_lines = []; in_sysin = False + else: + sysin_lines.append(stripped) + continue + + if not stripped.startswith("//"): + continue + + content = stripped[2:].strip() + + # JOB + if re.search(r"\bJOB\b", content, re.IGNORECASE): + job = Job(job_name=content.split()[0]) + continue + + # EXEC + m = re.match(r"(\w+)\s+EXEC\s+(?:PGM=)?(\w+)", content, re.IGNORECASE) + if m: + step_name, program = m.group(1), m.group(2) + cond = None + cm = re.search(r"COND=\s*\(\s*(\d+)\s*,\s*(\w+)", content, re.IGNORECASE) + if cm: + cond = CondParam(code=int(cm.group(1)), operator=cm.group(2).upper()) + current_step = JobStep(step_name=step_name, program=program, cond=cond) + if job: + job.steps.append(current_step) + continue + + # DD + m = re.match(r"(\w+)\s+DD\s*(.*)", content, re.IGNORECASE) + if m and current_step is not None: + dd = DDEntry(dd_name=m.group(1)) + params = m.group(2) + dm = re.search(r"DSN=\s*([^\s,]+)", params, re.IGNORECASE) + if dm: + dd.dsn = dm.group(1) + if dd.dd_name.upper() == "SYSIN" and "*" in params: + in_sysin = True + current_step.dd_entries.append(dd) + continue + + return job + + +def _merge_continuations(lines: list[str]) -> list[str]: + merged, buf = [], "" + for line in lines: + s = line.rstrip("\n\r") + buf = (buf + s) if buf else s + if s.rstrip().endswith(",") and not s.strip().startswith("//*"): + continue + merged.append(buf); buf = "" + if buf: + merged.append(buf) + return merged diff --git a/orchestrator.py b/orchestrator.py index d88cfcf..c0201dd 100644 --- a/orchestrator.py +++ b/orchestrator.py @@ -10,6 +10,7 @@ from runners.cobol_runner import CobolRunner from runners.data_writer import DataWriter from agents.agent1_parser import Agent1Parser from agents.agent2_data import Agent2Data +from agents.agent3_diagnostic import Agent3Diagnostic from agents.llm import LLMClient from comparator.aligner import align_records from comparator.field_compare import compare_field @@ -31,6 +32,9 @@ def run_pipeline(cfg: Config, cpath: str, cbl: str, java: str, map_path: str) -> llm = LLMClient(model=cfg.llm_model, timeout=cfg.llm_timeout, cache_dir=cfg.llm_cache_dir) tree = Agent1Parser(llm).parse(text) vr.llm_cost += 0.002 + vr.debug["field_tree"] = [{"name":f.name,"level":f.level,"pic":f.pic,"usage":f.usage, + "offset":f.offset,"length":f.length,"redefines":f.redefines} + for f in tree.flatten().values()] if not tree.fields: return _done(vr, t0, "BLOCKED", 2) if vr.llm_cost > cfg.max_llm_cost: @@ -38,6 +42,8 @@ def run_pipeline(cfg: Config, cpath: str, cbl: str, java: str, map_path: str) -> suite = Agent2Data(llm).design(tree, cfg.coverage_default, cfg.runner_mode == "spark") vr.llm_cost += 0.002 + vr.debug["test_cases"] = [{"id":tc.id,"fields":tc.fields,"targets":tc.coverage_targets} for tc in suite.test_cases] + vr.debug["spark_config"] = {"records":suite.spark_config.num_records} if suite.has_spark else None bundle = TestDataBundle(base_path=Path("test-data-bundle")) bundle.ensure_dirs() @@ -51,6 +57,7 @@ def run_pipeline(cfg: Config, cpath: str, cbl: str, java: str, map_path: str) -> cob = CobolRunner() build = cob.compile(cbl, cfg.dialect) + vr.debug["cobol_build"] = {"ok": build.success, "log": build.log[-300:]} if not build.success: return _done(vr, t0, "BLOCKED", 2) co = Path("cobol_out.bin") @@ -61,6 +68,7 @@ def run_pipeline(cfg: Config, cpath: str, cbl: str, java: str, map_path: str) -> return _done(vr, t0, "BLOCKED", 2) runner: Runner = SparkJavaRunner(cfg.spark_master) if cfg.runner_mode == "spark" else NativeJavaRunner() jb = runner.compile(java) + vr.debug["java_build"] = {"ok": jb.success, "log": jb.log[-300:]} if not jb.success: return _done(vr, t0, "BLOCKED", 2) inp = str(bundle.spark_input_dir() if cfg.runner_mode == "spark" else bundle.native_input()) @@ -95,6 +103,14 @@ def run_pipeline(cfg: Config, cpath: str, cbl: str, java: str, map_path: str) -> vr.status = "PASS" if m == 0 else "MISMATCH" vr.exit_code = 0 if m == 0 else 1 + diag = Agent3Diagnostic(llm) + for fr in frs: + if fr.status in ("MISMATCH", "NOT_SET", "NPE"): + try: + fr.suggestion = diag.analyze(fr) or "" + except: + pass + rd = Path(f"reports/{vr.program}") / vr.timestamp rd.mkdir(parents=True, exist_ok=True) g = ReportGenerator() diff --git a/tasks/6aaa7e43.json b/tasks/6aaa7e43.json new file mode 100644 index 0000000..4e521be --- /dev/null +++ b/tasks/6aaa7e43.json @@ -0,0 +1 @@ +{"id": "6aaa7e43", "status": "done", "copybook": "uploads\\6aaa7e43\\copybook.cpy", "cobol_src": "uploads\\6aaa7e43\\program.cbl", "java_src": "uploads\\6aaa7e43\\java", "mapping": "uploads\\6aaa7e43\\mapping.yaml", "runner": "native", "created": "2026-05-25T09:19:06.763502", "fields": [], "result": {"program": "java", "status": "ERROR", "matched": 0, "mismatched": 0, "duration": 34.83706521987915, "runner": "native"}} \ No newline at end of file diff --git a/tests/fixtures/simple.cbl b/tests/fixtures/simple.cbl index ee280d4..ab03bd8 100644 --- a/tests/fixtures/simple.cbl +++ b/tests/fixtures/simple.cbl @@ -7,7 +7,10 @@ 05 BR-STATUS PIC X. 05 BR-DATE PIC 9(8). PROCEDURE DIVISION. - DISPLAY BR-AMT. - DISPLAY BR-STATUS. - DISPLAY BR-DATE. + MOVE 1500 TO BR-AMT. + MOVE 'A' TO BR-STATUS. + MOVE 20260522 TO BR-DATE. + DISPLAY BR-AMT + DISPLAY BR-STATUS + DISPLAY BR-DATE STOP RUN. diff --git a/tests/test_golden.py b/tests/test_golden.py new file mode 100644 index 0000000..82383a6 --- /dev/null +++ b/tests/test_golden.py @@ -0,0 +1,226 @@ +""" +Golden tests — 对接真实 COBOL 信用卡月结系统 +数据源: D:\cobol-java\jcl-cobol-git\ +验证目标: 28 transactions → 20 valid + 8 rejected → 6 cards → ¥48,250.20 total +""" +import sys, os +sys.path.insert(0, r"D:\cobol-java\v3-gstack-code-gen") + +from pathlib import Path +from data.field_tree import Field, FieldTree +from comparator.cobol_binary_reader import CobolBinaryReader +from comparator.normalizer import Normalizer +from comparator.field_compare import compare_field +from comparator.aligner import align_records +from preprocessor import CopybookPreprocessor + +GOLDEN = Path(r"D:\cobol-java\jcl-cobol-git") + + +# ── Test 1: TXCPY COPYBOOK 结构验证 ── +def test_txcpy_field_count(): + """TXCPY COPYBOOK defines 6 top-level fields.""" + txcpy = FieldTree(fields=[ + Field(name="TX-CARD-NO", level=5, pic="9(16)", usage="DISPLAY", offset=0, length=16), + Field(name="TX-DATE", level=5, pic="9(8)", usage="DISPLAY", offset=16, length=8), + Field(name="TX-TYPE", level=5, pic="X", usage="DISPLAY", offset=24, length=1), + Field(name="TX-AMOUNT", level=5, pic="S9(9)V99", usage="DISPLAY", offset=25, length=11), + Field(name="TX-CURRENCY", level=5, pic="X(3)", usage="DISPLAY", offset=36, length=3), + Field(name="TX-MERCHANT", level=5, pic="X(20)", usage="DISPLAY", offset=39, length=20), + ], copybook_name="TXCPY") + flat = txcpy.flatten() + assert len(flat) == 6, f"Expected 6 fields, got {len(flat)}" + assert flat["TX-CARD-NO"].length == 16 + assert flat["TX-AMOUNT"].length == 11 + + +# ── Test 2: 二进制文件读取 ── +def test_read_transactions(): + """读取交易数据,验证第一条记录结构正确。""" + txcpy = FieldTree(fields=[ + Field(name="TX-CARD-NO", level=5, pic="9(16)", usage="DISPLAY", offset=0, length=16), + Field(name="TX-DATE", level=5, pic="9(8)", usage="DISPLAY", offset=16, length=8), + Field(name="TX-TYPE", level=5, pic="X", usage="DISPLAY", offset=24, length=1), + Field(name="TX-AMOUNT", level=5, pic="S9(9)V99", usage="DISPLAY", offset=25, length=12), + Field(name="TX-CURRENCY", level=5, pic="X(3)", usage="DISPLAY", offset=37, length=3), + Field(name="TX-MERCHANT", level=5, pic="X(20)", usage="DISPLAY", offset=40, length=20), + ], copybook_name="TXCPY") + + reader = CobolBinaryReader() + records = reader.read(str(GOLDEN / "data/input/transactions.dat"), txcpy) + + # 文件 2044 bytes, record size ≈ 73, 大约 28 条记录 + assert len(records) >= 28, f"Expected >=28, got {len(records)}" + + r0 = records[0] + assert r0["TX-CARD-NO"] == "6222021234567800" + assert r0["TX-DATE"] == "20260501" + assert r0["TX-TYPE"] == "P" + + +# ── Test 3: COMP-3 RATE 解析 ── +def test_comp3_rate(): + """验证利率表 COMP-3 编码。""" + n = Normalizer() + data = (GOLDEN / "data/input/rate.dat").read_bytes() + + assert len(data) == 24, "2 records × 12 bytes" + + # Record 1: Cash rate + assert n.normalize_comp3(data[1:4]) == "5" # 0.0005 + assert chr(data[0]) == "C" + + # Record 2: Overdue rate + assert n.normalize_comp3(data[13:16]) == "500" # 0.0500 + assert chr(data[12]) == "O" + + +# ── Test 4: 管道输出一致性 ── +def test_pipeline_counts(): + """验证 28→20+8→6 的管道计数。""" + validated = len((GOLDEN / "data/work/validated_tx.dat").read_text().splitlines()) + rejected = len((GOLDEN / "data/output/rejected_tx.dat").read_text().splitlines()) + + assert validated == 20, f"Expected 20 valid, got {validated}" + assert rejected == 8, f"Expected 8 rejected, got {rejected}" + + +def test_error_report_coverage(): + """验证全部 7 条校验规则被触发。""" + errors = (GOLDEN / "data/output/error_report.dat").read_text() + + expected_errors = ["INVALID-CARD", "FROZEN-CARD", "INVALID-MERCHANT", + "INVALID-AMOUNT", "INVALID-REFUND", "OUT-OF-MONTH", "MEMBER-NOT-FOUND"] + for e in expected_errors: + assert e in errors, f"Missing error: {e}" + + +def test_grand_total(): + """验证全局合计金额。""" + summary = (GOLDEN / "data/output/summary_report.dat").read_text() + assert "AMOUNT: 48250.20" in summary + assert "INTEREST: 300.00" in summary + assert "FEE: 800.00" in summary + assert "CARDS:00006" in summary + + +# ── Test 5: COPY REPLACING 展开 ── +def test_copy_replacing_datesub(): + """验证 DATESUB COPYBOOK 的 REPLACING 展开。""" + pp = CopybookPreprocessor(paths=[str(GOLDEN / "copybooks")]) + source = " COPY DATESUB REPLACING ==:TAG:== BY ==WS-RUN==." + result = pp.expand(source) + assert "WS-RUN" in result or "DATESUB" in result + + +# ── Test 6: 比对引擎集成 ── +def test_compare_pipeline_output(): + """验证比对引擎可以处理同源数据的 self-compare(应全部 PASS)。""" + reader = CobolBinaryReader() + txcpy = FieldTree(fields=[ + Field(name="TX-CARD-NO", level=5, pic="9(16)", usage="DISPLAY", offset=0, length=16), + Field(name="TX-DATE", level=5, pic="9(8)", usage="DISPLAY", offset=16, length=8), + Field(name="TX-TYPE", level=5, pic="X", usage="DISPLAY", offset=24, length=1), + Field(name="TX-AMOUNT", level=5, pic="S9(9)V99", usage="DISPLAY", offset=25, length=11), + Field(name="TX-CURRENCY", level=5, pic="X(3)", usage="DISPLAY", offset=36, length=3), + Field(name="TX-MERCHANT", level=5, pic="X(20)", usage="DISPLAY", offset=39, length=20), + ], copybook_name="TXCPY") + + records = reader.read(str(GOLDEN / "data/input/transactions.dat"), txcpy) + + # Self-compare: 同源数据应对齐后全 PASS + aligned = align_records(records, records, key_field="TX-CARD-NO") + assert len(aligned) == len(records), f"Alignment lost records: {len(aligned)} vs {len(records)}" + + # 每个字段 self-compare 应 PASS + for c, j, _ in aligned: + for key in ["TX-TYPE", "TX-CURRENCY", "TX-MERCHANT"]: + fr = compare_field(key, str(c.get(key, "")), str(j.get(key, "")), "string") + assert fr.status == "PASS", f"Self-compare failed: {key}" + + +# ── JCL Tests ── + +def test_jcl_parse(): + """验证 JCL 解析器正确解析 CREDIT25.jcl""" + import sys + sys.path.insert(0, str(GOLDEN.parent / "v3-gstack-code-gen")) + from jcl.parser import parse_jcl + + jcl_path = str(GOLDEN / "jcl" / "CREDIT25.jcl") + job = parse_jcl(jcl_path) + + assert job is not None, "JCL parse returned None" + assert job.job_name == "CREDIT25", f"Expected CREDIT25, got {job.job_name}" + assert len(job.steps) == 4, f"Expected 4 steps, got {len(job.steps)}" + + # Step 1: SORT + assert job.steps[0].step_name == "STEP1" + assert job.steps[0].program == "SORT" + assert len(job.steps[0].dd_entries) == 3 # SORTIN, SORTOUT, SYSIN + assert any(dd.dd_name.upper() == "SORTIN" for dd in job.steps[0].dd_entries) + assert any(dd.dd_name.upper() == "SORTOUT" for dd in job.steps[0].dd_entries) + + # Step 2: CRDVAL with COND + assert job.steps[1].step_name == "STEP2" + assert job.steps[1].program == "CRDVAL" + assert job.steps[1].cond is not None + assert job.steps[1].cond.code == 0 + assert job.steps[1].cond.operator == "NE" + assert len(job.steps[1].dd_entries) == 6 # TRANSIN, MEMBER, VALIDOUT, REJECT, REPORTERR, SYSOUT + + # Step 3: CRDCALC with COND + assert job.steps[2].step_name == "STEP3" + assert job.steps[2].program == "CRDCALC" + assert job.steps[2].cond.code == 0 + + # Step 4: CRDRPT with COND + assert job.steps[3].step_name == "STEP4" + assert job.steps[3].program == "CRDRPT" + assert job.steps[3].cond.code == 0 + + +def test_jcl_dd_mapping(): + """验证 JCL DD 语句正确提取""" + import sys + sys.path.insert(0, str(GOLDEN.parent / "v3-gstack-code-gen")) + from jcl.parser import parse_jcl + + job = parse_jcl(str(GOLDEN / "jcl" / "CREDIT25.jcl")) + + # Check DD names for CRDVAL step + crdval_dds = {dd.dd_name.upper() for dd in job.steps[1].dd_entries} + assert crdval_dds >= {"TRANSIN", "MEMBER", "VALIDOUT", "REJECT", "REPORTERR"}, \ + f"Missing DDs: {crdval_dds}" + + # Check DD names for CRDCALC step + crdcalc_dds = {dd.dd_name.upper() for dd in job.steps[2].dd_entries} + assert crdcalc_dds >= {"VALIDIN", "RATE", "CALCOUT"}, \ + f"Missing DDs: {crdcalc_dds}" + + # Check DD names for CRDRPT step + crdrpt_dds = {dd.dd_name.upper() for dd in job.steps[3].dd_entries} + assert crdrpt_dds >= {"BILLING", "STMT", "SUMMARY"}, \ + f"Missing DDs: {crdrpt_dds}" + + +def test_jcl_job_info(): + """验证 JCL Job 基本信息""" + import sys + sys.path.insert(0, str(GOLDEN.parent / "v3-gstack-code-gen")) + from jcl.parser import parse_jcl + + job = parse_jcl(str(GOLDEN / "jcl" / "CREDIT25.jcl")) + + assert job.job_name == "CREDIT25" + assert len(job.steps) == 4 + + programs = [s.program for s in job.steps] + assert programs == ["SORT", "CRDVAL", "CRDCALC", "CRDRPT"], \ + f"Unexpected program order: {programs}" + + # Verify all COND are (0,NE) — run step if previous step succeeded + for i in [1, 2, 3]: + assert job.steps[i].cond is not None, f"Step {i+1} missing COND" + assert job.steps[i].cond.code == 0 + assert job.steps[i].cond.operator == "NE" diff --git a/uploads/6aaa7e43/copybook.cpy b/uploads/6aaa7e43/copybook.cpy new file mode 100644 index 0000000..fdb299a --- /dev/null +++ b/uploads/6aaa7e43/copybook.cpy @@ -0,0 +1,4 @@ +01 BILL-RECORD. + 05 BR-AMT PIC S9(7)V99 COMP-3. + 05 BR-STATUS PIC X. + 05 BR-DATE PIC 9(8). diff --git a/uploads/6aaa7e43/java b/uploads/6aaa7e43/java new file mode 100644 index 0000000..89cc7a3 --- /dev/null +++ b/uploads/6aaa7e43/java @@ -0,0 +1 @@ +4.0.0testtest1.01717com.fasterxml.jackson.corejackson-databind2.17.0 diff --git a/uploads/6aaa7e43/mapping.yaml b/uploads/6aaa7e43/mapping.yaml new file mode 100644 index 0000000..92dc11c --- /dev/null +++ b/uploads/6aaa7e43/mapping.yaml @@ -0,0 +1,13 @@ +program: SIMPLE +field_mapping: + - cobol_field: BR-AMT + java_field: billAmount + type: decimal + precision: 2 + - cobol_field: BR-STATUS + java_field: statusCode + type: string + - cobol_field: BR-DATE + java_field: billDate + type: date + format: YYYYMMDD diff --git a/uploads/6aaa7e43/program.cbl b/uploads/6aaa7e43/program.cbl new file mode 100644 index 0000000..ee280d4 --- /dev/null +++ b/uploads/6aaa7e43/program.cbl @@ -0,0 +1,13 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. SIMPLE. + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 BILL-RECORD. + 05 BR-AMT PIC S9(7)V99 COMP-3. + 05 BR-STATUS PIC X. + 05 BR-DATE PIC 9(8). + PROCEDURE DIVISION. + DISPLAY BR-AMT. + DISPLAY BR-STATUS. + DISPLAY BR-DATE. + STOP RUN. diff --git a/web/api.py b/web/api.py index d7daa4d..438dc5b 100644 --- a/web/api.py +++ b/web/api.py @@ -1,80 +1,89 @@ """Web API layer — wraps orchestrator with 202+ polling pattern.""" -import uuid, json, shutil, sys, os +import uuid, json, sys, os from pathlib import Path from datetime import datetime from fastapi import FastAPI, UploadFile, File, Form, HTTPException from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -from starlette.requests import Request sys.path.insert(0, str(Path(__file__).parent.parent)) from config import Config from orchestrator import run_pipeline -app = FastAPI(title="COBOL→Java Verify") -app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static") -templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) +app = FastAPI(title="COBOL->Java Verify") +BASE = Path(__file__).parent +app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static") -TASKS_DIR = Path("tasks") -TASKS_DIR.mkdir(exist_ok=True) -UPLOAD_DIR = Path("uploads") -UPLOAD_DIR.mkdir(exist_ok=True) -MAX_SIZE = 10 * 1024 * 1024 # 10MB +TASKS_DIR = Path("tasks"); TASKS_DIR.mkdir(exist_ok=True) +UPLOAD_DIR = Path("uploads"); UPLOAD_DIR.mkdir(exist_ok=True) +MAX_SIZE = 10 * 1024 * 1024 @app.get("/", response_class=HTMLResponse) -async def index(request: Request): - return templates.TemplateResponse("upload.html", {"request": request}) +async def index(): + return (BASE / "templates" / "upload.html").read_text(encoding="utf-8") @app.post("/verify") async def verify( - copybook: UploadFile = File(...), - cobol_src: UploadFile = File(...), - java_src: UploadFile = File(...), - mapping: UploadFile = File(...), + copybook: UploadFile = File(...), cobol_src: UploadFile = File(...), + java_src: UploadFile = File(...), mapping: UploadFile = File(...), runner: str = Form("native"), ): task_id = str(uuid.uuid4())[:8] - task_dir = UPLOAD_DIR / task_id - task_dir.mkdir(parents=True, exist_ok=True) - - for f, name in [(copybook, "copybook.cpy"), (cobol_src, "program.cbl"), - (java_src, "java"), (mapping, "mapping.yaml")]: + task_dir = UPLOAD_DIR / task_id; task_dir.mkdir(parents=True, exist_ok=True) + for f, name in [(copybook,"copybook.cpy"),(cobol_src,"program.cbl"), + (java_src,"java"),(mapping,"mapping.yaml")]: content = await f.read() if len(content) > MAX_SIZE: - raise HTTPException(413, f"{f.filename} exceeds 10MB limit") - dest = task_dir / name - dest.write_bytes(content) - - task_file = TASKS_DIR / f"{task_id}.json" - task_file.write_text(json.dumps({ - "id": task_id, "status": "queued", - "copybook": str(task_dir / "copybook.cpy"), - "cobol_src": str(task_dir / "program.cbl"), - "java_src": str(task_dir / "java"), - "mapping": str(task_dir / "mapping.yaml"), - "runner": runner, "created": datetime.now().isoformat() - })) - - return JSONResponse({"task_id": task_id, "status": "queued"}, status_code=202) + raise HTTPException(413, f"{f.filename} exceeds 10MB") + (task_dir / name).write_bytes(content) + (TASKS_DIR / f"{task_id}.json").write_text(json.dumps({ + "id":task_id,"status":"queued","copybook":str(task_dir/"copybook.cpy"), + "cobol_src":str(task_dir/"program.cbl"),"java_src":str(task_dir/"java"), + "mapping":str(task_dir/"mapping.yaml"),"runner":runner, + "created":datetime.now().isoformat()})) + return JSONResponse({"task_id":task_id,"status":"queued"}, status_code=202) @app.get("/status/{task_id}") async def status(task_id: str): tf = TASKS_DIR / f"{task_id}.json" - if not tf.exists(): - raise HTTPException(404, "task not found") + if not tf.exists(): raise HTTPException(404, "task not found") data = json.loads(tf.read_text()) - return JSONResponse({"task_id": task_id, "status": data.get("status", "unknown"), - "result": data.get("result")}) + return JSONResponse({"task_id":task_id,"status":data.get("status","unknown"), + "result":data.get("result"),"fields":data.get("fields",[])}) + + +@app.get("/fields/{task_id}") +async def fields(task_id: str): + tf = TASKS_DIR / f"{task_id}.json" + if not tf.exists(): raise HTTPException(404, "task not found") + data = json.loads(tf.read_text()) + return JSONResponse({"task_id":task_id,"fields":data.get("fields",[]), + "debug":data.get("debug",{}), + "build_log":data.get("build_log","")}) @app.get("/result/{task_id}", response_class=HTMLResponse) -async def result(request: Request, task_id: str): +async def result(task_id: str): tf = TASKS_DIR / f"{task_id}.json" - if not tf.exists(): - raise HTTPException(404, "task not found") + if not tf.exists(): raise HTTPException(404, "task not found") data = json.loads(tf.read_text()) - return templates.TemplateResponse("result.html", {"request": request, "task": data}) + html = (BASE / "templates" / "result.html").read_text(encoding="utf-8") + html = html.replace("{{ task.id }}", data.get("id", task_id)) + if data.get("status") == "done" and data.get("result"): + r = data["result"] + html = html.replace("{{ task.status }}", "done") + html = html.replace("{{ task.result.status }}", r.get("status","")) + html = html.replace("{{ task.result.program }}", r.get("program","")) + html = html.replace("{{ task.result.matched }}", str(r.get("matched",0))) + html = html.replace("{{ task.result.mismatched }}", str(r.get("mismatched",0))) + html = html.replace("{{ task.result.runner }}", r.get("runner","")) + html = html.replace("{{ task.result.duration }}", str(r.get("duration",0))) + elif data.get("status") == "error": + html = html.replace("{{ task.status }}", "error") + html = html.replace("{{ task.result.status }}", data.get("result","")) + else: + html = html.replace("{{ task.status }}", data.get("status","queued")) + return HTMLResponse(html) diff --git a/web/static/script.js b/web/static/script.js index f2f33b0..2f33f7c 100644 --- a/web/static/script.js +++ b/web/static/script.js @@ -1,25 +1,36 @@ document.getElementById("verify-form").addEventListener("submit", async (e) => { e.preventDefault(); - const btn = e.target.querySelector("button"); + const btn = e.target.querySelector("button[type=submit]"); btn.disabled = true; - btn.textContent = "Submitting..."; - document.getElementById("status").textContent = ""; - document.getElementById("result").innerHTML = ""; + btn.textContent = "$ uploading..."; + document.getElementById("status-area").innerHTML = ""; const fd = new FormData(e.target); try { const r = await fetch("/verify", { method: "POST", body: fd }); const d = await r.json(); if (r.ok) { - document.getElementById("status").textContent = "Task " + d.task_id + " queued"; - document.getElementById("result").innerHTML = - '

View result →

'; + document.getElementById("status-area").innerHTML = ` +
+
● Queued
+ Task ${d.task_id} submitted. Worker processing. +
Runner
${fd.get("runner")}
+ Open result page → +
`; } else { - document.getElementById("status").textContent = "Error: " + (d.detail || "unknown"); + document.getElementById("status-area").innerHTML = ` +
+
✗ Error
+ ${d.detail || "Upload failed"} +
`; } } catch (err) { - document.getElementById("status").textContent = "Upload failed: " + err.message; + document.getElementById("status-area").innerHTML = ` +
+
✗ Network Error
+ ${err.message} +
`; } btn.disabled = false; - btn.textContent = "Verify"; + btn.textContent = "$ verify"; }); diff --git a/web/static/style.css b/web/static/style.css index 2286ffa..c33933c 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1,11 +1,70 @@ -body { font-family: monospace; max-width: 900px; margin: 2rem auto; padding: 0 1rem; color: #333; } -h1 { font-size: 1.2rem; } -label { display: block; margin: .75rem 0; } -input, select { display: block; margin-top: .25rem; } -button { margin-top: 1rem; padding: .5rem 1.5rem; cursor: pointer; font-size: 1rem; } -#status { margin-top: 1rem; font-weight: bold; } -#result { margin-top: 1rem; } -pre { background: #f0f0f0; padding: 1rem; border-radius: 4px; } -.pass { border-left: 4px solid #4caf50; } -.fail { border-left: 4px solid #f44336; } -.container a { display: inline-block; margin-top: 1rem; } +:root { + --bg: #0a0e14; + --panel: #12171f; + --border: #1f2937; + --text: #b2becd; + --dim: #5c6e80; + --accent: #39bae6; + --green: #7fd962; + --red: #f26d78; + --yellow: #ffad66; + --font: "SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: var(--font); background: var(--bg); color: var(--text); line-height: 1.6; } +body::before { content:""; position:fixed; top:0; left:0; width:100%; height:100%; background: radial-gradient(ellipse at 20% 20%, rgba(57,186,230,.03) 0%, transparent 50%), radial-gradient(ellipse at 80% 80%, rgba(127,217,98,.02) 0%, transparent 50%); pointer-events:none; z-index:0; } +.container { max-width: 680px; margin: 0 auto; padding: 3rem 1.5rem; position:relative; z-index:1; } +/* Header */ +header { margin-bottom: 2.5rem; border-bottom: 1px solid var(--border); padding-bottom: 1.5rem; } +.badge { display:inline-block; padding:.15rem .6rem; background:rgba(57,186,230,.12); color:var(--accent); border-radius:3px; font-size:.7rem; text-transform:uppercase; letter-spacing:.08em; margin-bottom:.75rem; } +h1 { font-size:1.3rem; font-weight:400; color:#e6edf3; line-height:1.3; } +h1 span { color: var(--accent); } +.tagline { font-size:.8rem; color:var(--dim); margin-top:.4rem; } +/* Sections */ +section { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:1.5rem; margin-bottom:1rem; } +section h2 { font-size:.8rem; color:var(--dim); text-transform:uppercase; letter-spacing:.06em; margin-bottom:1rem; font-weight:400; } +/* Form */ +.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:.75rem; } +.form-grid .full { grid-column:1/-1; } +label { font-size:.75rem; color:var(--dim); } +label strong { display:block; color:var(--text); font-weight:400; margin-bottom:.25rem; font-size:.8rem; } +input[type="file"], select { width:100%; padding:.55rem .7rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; color:var(--text); font-family:inherit; font-size:.8rem; margin-top:.15rem; transition:border-color .2s; } +input[type="file"]:hover, select:hover { border-color: var(--accent); } +input[type="file"]::file-selector-button { background:var(--border); color:var(--text); border:none; padding:.35rem .8rem; border-radius:4px; margin-right:.5rem; cursor:pointer; font-family:inherit; font-size:.75rem; } +select { cursor:pointer; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%235c6e80'%3E%3Cpath d='M6 8L2 4h8z'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right .5rem center; appearance:none; padding-right:2rem; } +/* Buttons */ +.actions { display:flex; gap:.5rem; margin-top:1.25rem; } +.btn { padding:.6rem 1.25rem; border-radius:6px; font-family:inherit; font-size:.8rem; cursor:pointer; border:none; transition: all .2s; } +.btn-primary { background:var(--accent); color:var(--bg); } +.btn-primary:hover { background:#4fc8f0; } +.btn-primary:disabled { background:var(--border); color:var(--dim); cursor:not-allowed; } +.btn-secondary { background:transparent; color:var(--dim); border:1px solid var(--border); } +.btn-secondary:hover { color:var(--text); border-color:var(--dim); } +/* Status & Results */ +#status-area { margin-top:1rem; } +.status-card { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:1rem 1.5rem; font-size:.8rem; } +.status-card.success { border-left:3px solid var(--green); } +.status-card.pending { border-left:3px solid var(--yellow); } +.status-card.error { border-left:3px solid var(--red); } +.status-card .title { font-weight:600; margin-bottom:.25rem; } +.status-card.success .title { color:var(--green); } +.status-card.pending .title { color:var(--yellow); } +.status-card.error .title { color:var(--red); } +.matrix { display:grid; grid-template-columns:auto 1fr; gap:.3rem 1.5rem; margin:.75rem 0; font-size:.8rem; } +.matrix dt { color:var(--dim); } +.matrix dd { color:var(--text); } +.divider { border-top:1px solid var(--border); margin:1rem 0; } +.result-link { display:inline-block; margin-top:.5rem; color:var(--accent); text-decoration:none; font-size:.8rem; } +.result-link:hover { text-decoration:underline; } +/* Footer */ +footer { margin-top:3rem; padding-top:1rem; border-top:1px solid var(--border); font-size:.7rem; color:var(--dim); display:flex; justify-content:space-between; } +footer a { color:var(--dim); text-decoration:none; } +footer a:hover { color:var(--text); } +/* Field table */ +table { width:100%; border-collapse:collapse; font-size:.8rem; } +th { text-align:left; color:var(--dim); font-weight:400; padding:.5rem .75rem; border-bottom:1px solid var(--border); } +td { padding:.45rem .75rem; border-bottom:1px solid rgba(31,41,55,.5); } +tr.pass td:first-child { border-left:3px solid var(--green); } +tr.tolerated td:first-child { border-left:3px solid var(--yellow); } +tr.fail td:first-child { border-left:3px solid var(--red); } +pre { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:1rem; font-family:inherit; font-size:.8rem; overflow-x:auto; margin-top:.5rem; } diff --git a/web/templates/result.html b/web/templates/result.html index 74fc27d..10cacef 100644 --- a/web/templates/result.html +++ b/web/templates/result.html @@ -1,27 +1,99 @@ -Result - {{ task.id }} - + + +Result — {{ task.id }} + + +
-

Verification Result

-{% if task.status == "done" and task.result %} -
Status: {{ task.result.status }}
-Program: {{ task.result.program }}
-Matched: {{ task.result.matched }} | Mismatched: {{ task.result.mismatched }}
-Runner: {{ task.result.runner }} | Duration: {{ task.result.duration }}s
-{% elif task.status == "error" %} -
{{ task.result }}
-{% else %} -
Status: {{ task.status }} — polling...
+ +
+
Verification Result
+

{{ task.id }}

+
Status: loading...
+
+ +
+

Summary

+
+
Status
{{ task.result.status }}
+
Program
{{ task.result.program }}
+
Matched
{{ task.result.matched }}
+
Mismatched
{{ task.result.mismatched }}
+
Runner
{{ task.result.runner }}
+
Duration
{{ task.result.duration }}s
+
+
+ +
+

Field Results

+
+
+ +
+

Pipeline Details

+
+
+ +
+ ← New Verification + + + +
-{% endif %} -← New verification - - + + diff --git a/web/templates/upload.html b/web/templates/upload.html index 2a28a1b..9a7c3c2 100644 --- a/web/templates/upload.html +++ b/web/templates/upload.html @@ -1,18 +1,43 @@ -COBOL→Java Verify - + + +COBOL → Java Migration Verification + + +
-

COBOL→Java Migration Verification

-
- - - - - - -
-
-
+ +
+
Developer Tool
+

verify — COBOL to Java/Spark Migration

+
Automated field-level verification for COBOL→Java migration pipelines
+
+ +
+

Upload Sources

+
+
+ + + + + +
+
+ + +
+
+
+ +
+ + +
- + + diff --git a/web/worker.py b/web/worker.py index 80500c2..1349182 100644 --- a/web/worker.py +++ b/web/worker.py @@ -32,12 +32,20 @@ def main(): vr = run_pipeline(cfg, data["copybook"], data["cobol_src"], data["java_src"], data["mapping"]) + fields = [{"name":fr.field_name,"status":fr.status, + "cobol":fr.cobol_value,"java":fr.java_value, + "suggestion":fr.suggestion} for fr in vr.field_results] + data["status"] = "done" + data["fields"] = fields + data["debug"] = vr.debug data["result"] = { "program": vr.program, "status": vr.status, "matched": vr.fields_matched, "mismatched": vr.fields_mismatched, "duration": vr.duration_s, "runner": vr.runner, } + if vr.report_path and "BLOCKED" in vr.status: + data["build_log"] = vr.report_path tf.write_text(json.dumps(data)) except Exception as e: