easy-coding-harness 0.10.0-beta.0 → 0.10.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env python3
2
+ """Gate changed Java production lines with one or more JaCoCo XML reports."""
3
+
4
+ import argparse
5
+ import ast
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ import xml.etree.ElementTree as ET
12
+ from pathlib import Path
13
+
14
+
15
+ DEFAULT_THRESHOLD = 90
16
+ HUNK_PATTERN = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
17
+ TEST_PATH_PARTS = {"test", "tests", "testfixtures", "integrationtest"}
18
+
19
+
20
+ class CoverageError(Exception):
21
+ pass
22
+
23
+
24
+ def run_git(repo: Path, *args: str) -> str:
25
+ result = subprocess.run(
26
+ ["git", "-C", str(repo), *args],
27
+ check=False,
28
+ capture_output=True,
29
+ text=True,
30
+ )
31
+ if result.returncode != 0:
32
+ raise CoverageError(result.stderr.strip() or "git command failed")
33
+ return result.stdout
34
+
35
+
36
+ def changed_java_lines(repo: Path, base: str) -> dict[str, set[int]]:
37
+ diff = run_git(
38
+ repo,
39
+ "-c",
40
+ "core.quotePath=false",
41
+ "diff",
42
+ "--unified=0",
43
+ "--no-ext-diff",
44
+ base,
45
+ "--",
46
+ "*.java",
47
+ )
48
+ changed: dict[str, set[int]] = {}
49
+ current: str | None = None
50
+ new_line = 0
51
+ for line in diff.splitlines():
52
+ if line.startswith("+++ "):
53
+ target = line[4:].strip()
54
+ if target.startswith('"'):
55
+ try:
56
+ target = ast.literal_eval(target)
57
+ except (SyntaxError, ValueError) as error:
58
+ raise CoverageError(f"Cannot decode Git diff path: {target}") from error
59
+ current = None if target == "/dev/null" else target.removeprefix("b/")
60
+ if current and not is_production_java(current):
61
+ current = None
62
+ continue
63
+ match = HUNK_PATTERN.match(line)
64
+ if match:
65
+ new_line = int(match.group(1))
66
+ continue
67
+ if current is None or line.startswith(("--- ", "diff ", "index ")):
68
+ continue
69
+ if line.startswith("+") and not line.startswith("+++"):
70
+ changed.setdefault(current, set()).add(new_line)
71
+ new_line += 1
72
+ elif not line.startswith("-"):
73
+ new_line += 1
74
+ untracked = run_git(
75
+ repo,
76
+ "ls-files",
77
+ "--others",
78
+ "--exclude-standard",
79
+ "-z",
80
+ "--",
81
+ "*.java",
82
+ )
83
+ for file_path in filter(None, untracked.split("\0")):
84
+ if not is_production_java(file_path):
85
+ continue
86
+ candidate = repo / file_path
87
+ try:
88
+ line_count = len(candidate.read_text(encoding="utf-8").splitlines())
89
+ except (OSError, UnicodeError) as error:
90
+ raise CoverageError(f"Cannot read untracked Java source {file_path}: {error}") from error
91
+ changed.setdefault(file_path, set()).update(range(1, line_count + 1))
92
+ return changed
93
+
94
+
95
+ def is_production_java(file_path: str) -> bool:
96
+ path = Path(file_path)
97
+ if path.suffix != ".java":
98
+ return False
99
+ parts = {part.lower() for part in path.parts}
100
+ if TEST_PATH_PARTS & parts:
101
+ return False
102
+ return True
103
+
104
+
105
+ def report_module_prefix(report: Path, repo: Path) -> str:
106
+ relative = report.resolve().relative_to(repo.resolve()).as_posix()
107
+ if "/target/site/jacoco-aggregate/" in f"/{relative}":
108
+ return ""
109
+ markers = ("/target/", "/build/reports/")
110
+ for marker in markers:
111
+ if marker in f"/{relative}":
112
+ prefix = f"/{relative}".split(marker, 1)[0].lstrip("/")
113
+ return f"{prefix}/" if prefix else ""
114
+ return ""
115
+
116
+
117
+ def parse_reports(
118
+ repo: Path, reports: list[Path]
119
+ ) -> tuple[list[tuple[str, str, dict[int, bool], Path]], str]:
120
+ sources: list[tuple[str, str, dict[int, bool], Path]] = []
121
+ digest = hashlib.sha256()
122
+ for report in reports:
123
+ try:
124
+ payload = report.read_bytes()
125
+ root = ET.fromstring(payload)
126
+ except (OSError, ET.ParseError) as error:
127
+ raise CoverageError(f"Cannot read JaCoCo XML report {report}: {error}") from error
128
+ try:
129
+ report_name = report.resolve().relative_to(repo.resolve()).as_posix()
130
+ except ValueError as error:
131
+ raise CoverageError(f"JaCoCo XML report must be inside the Git repository: {report}") from error
132
+ digest.update(report_name.encode())
133
+ digest.update(b"\0")
134
+ digest.update(payload)
135
+ digest.update(b"\0")
136
+ prefix = report_module_prefix(report, repo)
137
+ for package in root.findall(".//package"):
138
+ package_name = package.get("name", "").strip("/")
139
+ for source in package.findall("sourcefile"):
140
+ source_name = source.get("name", "")
141
+ suffix = "/".join(part for part in (package_name, source_name) if part)
142
+ executable: dict[int, bool] = {}
143
+ try:
144
+ for line in source.findall("line"):
145
+ number = int(line.get("nr", "0"))
146
+ covered = int(line.get("ci", "0")) > 0 or int(line.get("cb", "0")) > 0
147
+ executable[number] = executable.get(number, False) or covered
148
+ except ValueError as error:
149
+ raise CoverageError(f"Invalid JaCoCo line counters in {report}") from error
150
+ sources.append((prefix, suffix, executable, report))
151
+ return sources, digest.hexdigest()
152
+
153
+
154
+ def discover_reports(repo: Path) -> list[Path]:
155
+ regular = [
156
+ *repo.glob("**/target/site/jacoco/jacoco.xml"),
157
+ *repo.glob("**/build/reports/jacoco/**/jacocoTestReport.xml"),
158
+ ]
159
+ reports = sorted({candidate.resolve() for candidate in regular if candidate.is_file()})
160
+ if reports:
161
+ return reports
162
+ aggregate = repo.glob("**/target/site/jacoco-aggregate/jacoco.xml")
163
+ return sorted({candidate.resolve() for candidate in aggregate if candidate.is_file()})
164
+
165
+
166
+ def read_project_threshold(repo: Path) -> int:
167
+ path = repo / ".easy-coding" / "config.yaml"
168
+ try:
169
+ lines = path.read_text(encoding="utf-8").splitlines()
170
+ except OSError:
171
+ return DEFAULT_THRESHOLD
172
+ in_behavior = False
173
+ behavior_indent = 0
174
+ schema_version = 0
175
+ for raw in lines:
176
+ clean = raw.split("#", 1)[0].rstrip()
177
+ stripped = clean.strip()
178
+ if not stripped:
179
+ continue
180
+ indent = len(clean) - len(clean.lstrip(" "))
181
+ if stripped == "behavior:":
182
+ in_behavior = True
183
+ behavior_indent = indent
184
+ continue
185
+ if in_behavior and indent <= behavior_indent:
186
+ in_behavior = False
187
+ if not in_behavior and indent == 0 and stripped.startswith("version:"):
188
+ try:
189
+ schema_version = int(stripped.split(":", 1)[1].strip().strip("'\""))
190
+ except ValueError:
191
+ schema_version = 0
192
+ continue
193
+ if (
194
+ schema_version >= 4
195
+ and in_behavior
196
+ and stripped.startswith("tdd_coverage_threshold:")
197
+ ):
198
+ try:
199
+ value = int(stripped.split(":", 1)[1].strip().strip("'\""))
200
+ except ValueError as error:
201
+ raise CoverageError("Invalid behavior.tdd_coverage_threshold") from error
202
+ if not 1 <= value <= 100:
203
+ raise CoverageError("TDD coverage threshold must be from 1 to 100")
204
+ return value
205
+ return DEFAULT_THRESHOLD
206
+
207
+
208
+ def calculate(
209
+ repo: Path, base: str, reports: list[Path], threshold: int
210
+ ) -> dict[str, object]:
211
+ baseline_sha = run_git(repo, "rev-parse", "--verify", f"{base}^{{commit}}").strip()
212
+ if re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", baseline_sha) is None:
213
+ raise CoverageError(f"Invalid Git baseline commit: {base}")
214
+ try:
215
+ run_git(repo, "merge-base", "--is-ancestor", baseline_sha, "HEAD")
216
+ except CoverageError as error:
217
+ raise CoverageError(
218
+ f"Git baseline {baseline_sha} is not an ancestor of HEAD"
219
+ ) from error
220
+ changed = changed_java_lines(repo, base)
221
+ sources, report_sha = parse_reports(repo, reports)
222
+ covered = 0
223
+ total = 0
224
+ files: list[dict[str, object]] = []
225
+ missing: list[str] = []
226
+ for file_path, added_lines in sorted(changed.items()):
227
+ matches: list[tuple[dict[int, bool], Path]] = []
228
+ for prefix, suffix, lines, report in sources:
229
+ if (not prefix or file_path.startswith(prefix)) and file_path.endswith(suffix):
230
+ matches.append((lines, report))
231
+ if len(matches) != 1:
232
+ missing.append(file_path)
233
+ continue
234
+ executable, matched_report = matches[0]
235
+ source_path = repo / file_path
236
+ try:
237
+ if matched_report.stat().st_mtime_ns < source_path.stat().st_mtime_ns:
238
+ raise CoverageError(
239
+ f"JaCoCo XML report is older than modified Java source {file_path}; regenerate unit-test coverage"
240
+ )
241
+ except OSError as error:
242
+ raise CoverageError(
243
+ f"Cannot compare JaCoCo report freshness for {file_path}: {error}"
244
+ ) from error
245
+ relevant = sorted(added_lines & executable.keys())
246
+ file_covered = sum(1 for number in relevant if executable[number])
247
+ covered += file_covered
248
+ total += len(relevant)
249
+ files.append(
250
+ {
251
+ "path": file_path,
252
+ "covered_lines": file_covered,
253
+ "total_lines": len(relevant),
254
+ }
255
+ )
256
+ if missing:
257
+ raise CoverageError(
258
+ "Modified production Java files are missing or ambiguous in JaCoCo XML: "
259
+ + ", ".join(missing)
260
+ )
261
+ percentage = 100.0 if total == 0 else round(covered * 100.0 / total, 2)
262
+ applicable = total > 0
263
+ return {
264
+ "baseline_sha": baseline_sha,
265
+ "covered_lines": covered,
266
+ "total_lines": total,
267
+ "percentage": percentage,
268
+ "threshold": threshold,
269
+ "applicable": applicable,
270
+ "not_applicable_reason": None if applicable else "no modified executable production Java lines",
271
+ "passed": percentage >= threshold,
272
+ "report_paths": [str(report.resolve().relative_to(repo.resolve())) for report in reports],
273
+ "report_sha256": report_sha,
274
+ "files": files,
275
+ }
276
+
277
+
278
+ def main() -> int:
279
+ parser = argparse.ArgumentParser(description=__doc__)
280
+ subcommands = parser.add_subparsers(dest="command", required=True)
281
+ check = subcommands.add_parser("check")
282
+ check.add_argument("--base", required=True)
283
+ check.add_argument("--repo", default=".")
284
+ check.add_argument("--report", action="append", default=[])
285
+ check.add_argument("--threshold", type=int)
286
+ check.add_argument("--output")
287
+ args = parser.parse_args()
288
+ try:
289
+ requested_repo = Path(args.repo).resolve()
290
+ repo = Path(run_git(requested_repo, "rev-parse", "--show-toplevel").strip()).resolve()
291
+ threshold = (
292
+ args.threshold
293
+ if args.threshold is not None
294
+ else read_project_threshold(repo)
295
+ )
296
+ if not 1 <= threshold <= 100:
297
+ raise CoverageError("TDD coverage threshold must be from 1 to 100")
298
+ reports = (
299
+ sorted({Path(item).resolve() for item in args.report})
300
+ if args.report
301
+ else discover_reports(repo)
302
+ )
303
+ if not reports:
304
+ raise CoverageError("No JaCoCo XML report found; pass --report or generate one first")
305
+ result = calculate(repo, args.base, reports, threshold)
306
+ payload = json.dumps(result, ensure_ascii=False, indent=2)
307
+ if args.output:
308
+ Path(args.output).write_text(payload + "\n", encoding="utf-8")
309
+ print(payload)
310
+ return 0 if result["passed"] else 1
311
+ except CoverageError as error:
312
+ print(json.dumps({"passed": False, "error": str(error)}, ensure_ascii=False), file=sys.stderr)
313
+ return 2
314
+
315
+
316
+ if __name__ == "__main__":
317
+ raise SystemExit(main())