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 @@
+
${d.task_id} submitted. Worker processing.
+ 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 %}
-