easy-coding-harness 0.10.0-beta.0 → 0.10.0-beta.2
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.
- package/CHANGELOG.md +31 -0
- package/README.md +9 -3
- package/dist/cli.js +331 -46
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +8 -0
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +9 -2
- package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +1 -1
- package/templates/common/skills/ec-analysis/SKILL.md +38 -0
- package/templates/common/skills/ec-config/SKILL.md +68 -0
- package/templates/common/skills/ec-implementing/SKILL.md +11 -0
- package/templates/common/skills/ec-memory/SKILL.md +4 -0
- package/templates/common/skills/ec-reviewing/SKILL.md +6 -0
- package/templates/common/skills/ec-task-management/SKILL.md +6 -31
- package/templates/common/skills/ec-tdd-init/SKILL.md +99 -0
- package/templates/common/skills/ec-verification/SKILL.md +53 -1
- package/templates/common/skills/ec-workflow/SKILL.md +16 -4
- package/templates/main-constraint/AGENTS.md.tpl +13 -3
- package/templates/main-constraint/CLAUDE.md.tpl +13 -3
- package/templates/runtime/tools/easy_coding_java_coverage.py +317 -0
- package/templates/runtime/tools/easy_coding_tdd_readiness.py +306 -0
- package/templates/shared-hooks/easy_coding_state.py +846 -13
- package/templates/shared-hooks/inject-subagent-context.py +5 -0
|
@@ -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())
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Record and verify Easy Coding Java TDD infrastructure readiness.
|
|
3
|
+
|
|
4
|
+
This tool validates infrastructure only. It never measures repository-wide coverage and never
|
|
5
|
+
creates business tests. Coverage acceptance remains scoped to production lines changed after a
|
|
6
|
+
task's frozen baseline.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
SCHEMA = "easy-coding/tdd-readiness-v1"
|
|
22
|
+
COVERAGE_SCOPE = "changed-production-lines"
|
|
23
|
+
RECEIPT = Path(".easy-coding/tdd/readiness.json")
|
|
24
|
+
TDD_BASE_VARIABLE = "EASY_CODING_TDD_BASE_SHA"
|
|
25
|
+
TDD_THRESHOLD_VARIABLE = "EASY_CODING_TDD_THRESHOLD"
|
|
26
|
+
COVERAGE_TOOL_PATH = ".easy-coding/tools/easy_coding_java_coverage.py"
|
|
27
|
+
JAVA_BUILD_FILE_NAMES = {"pom.xml", "build.gradle", "build.gradle.kts"}
|
|
28
|
+
GITLAB_CI_ENTRY_FILES = {".gitlab-ci.yml", ".gitlab-ci.yaml"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReadinessError(RuntimeError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sha256(path: Path) -> str:
|
|
36
|
+
digest = hashlib.sha256()
|
|
37
|
+
with path.open("rb") as handle:
|
|
38
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
39
|
+
digest.update(chunk)
|
|
40
|
+
return digest.hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def project_file(root: Path, value: str) -> tuple[str, Path]:
|
|
44
|
+
candidate = Path(value)
|
|
45
|
+
if candidate.is_absolute():
|
|
46
|
+
resolved = candidate.resolve()
|
|
47
|
+
else:
|
|
48
|
+
resolved = (root / candidate).resolve()
|
|
49
|
+
try:
|
|
50
|
+
relative = resolved.relative_to(root.resolve()).as_posix()
|
|
51
|
+
except ValueError as error:
|
|
52
|
+
raise ReadinessError(f"Path escapes project root: {value}") from error
|
|
53
|
+
if not resolved.is_file():
|
|
54
|
+
raise ReadinessError(f"Required file is missing: {relative}")
|
|
55
|
+
return relative, resolved
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def file_record(root: Path, value: str) -> dict[str, str]:
|
|
59
|
+
relative, resolved = project_file(root, value)
|
|
60
|
+
return {"path": relative, "sha256": sha256(resolved)}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def safe_report_pattern(value: str) -> bool:
|
|
64
|
+
candidate = Path(value)
|
|
65
|
+
return bool(value.strip()) and not candidate.is_absolute() and ".." not in candidate.parts
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def required_gate_variables(command: str) -> bool:
|
|
69
|
+
try:
|
|
70
|
+
tokens = shlex.split(command)
|
|
71
|
+
except ValueError:
|
|
72
|
+
return False
|
|
73
|
+
options: dict[str, str] = {}
|
|
74
|
+
for index, token in enumerate(tokens[:-1]):
|
|
75
|
+
if token in {"--base", "--threshold"}:
|
|
76
|
+
options[token] = tokens[index + 1]
|
|
77
|
+
return options.get("--base") in {
|
|
78
|
+
f"${TDD_BASE_VARIABLE}",
|
|
79
|
+
"$" + "{" + TDD_BASE_VARIABLE + "}",
|
|
80
|
+
} and options.get("--threshold") in {
|
|
81
|
+
f"${TDD_THRESHOLD_VARIABLE}",
|
|
82
|
+
"$" + "{" + TDD_THRESHOLD_VARIABLE + "}",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ci_contract_reasons(contents: list[str]) -> list[str]:
|
|
87
|
+
combined = "\n".join(
|
|
88
|
+
re.sub(r"\s+#.*$", "", re.sub(r"^\s*#.*$", "", line))
|
|
89
|
+
for line in "\n".join(contents).splitlines()
|
|
90
|
+
)
|
|
91
|
+
lowered = combined.lower()
|
|
92
|
+
reasons: list[str] = []
|
|
93
|
+
for marker in (
|
|
94
|
+
"jacoco",
|
|
95
|
+
"artifacts",
|
|
96
|
+
COVERAGE_TOOL_PATH,
|
|
97
|
+
TDD_BASE_VARIABLE,
|
|
98
|
+
TDD_THRESHOLD_VARIABLE,
|
|
99
|
+
):
|
|
100
|
+
if marker.lower() not in lowered:
|
|
101
|
+
reasons.append(f"CI files do not contain required marker: {marker}")
|
|
102
|
+
if not required_gate_variables(combined):
|
|
103
|
+
reasons.append(
|
|
104
|
+
"CI changed-line gate must use the task baseline and threshold variables"
|
|
105
|
+
)
|
|
106
|
+
if re.search(r"(?:^|\n)\s*stage\s*:\s*['\"]?test['\"]?\s*(?:#.*)?(?:\n|$)", combined, re.I) is None:
|
|
107
|
+
reasons.append("CI files do not declare a TEST-stage job")
|
|
108
|
+
return reasons
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_records(root: Path, value: object, field: str, reasons: list[str]) -> list[str]:
|
|
112
|
+
if not isinstance(value, list) or not value:
|
|
113
|
+
reasons.append(f"{field} must contain at least one file")
|
|
114
|
+
return []
|
|
115
|
+
contents: list[str] = []
|
|
116
|
+
for item in value:
|
|
117
|
+
if not isinstance(item, dict):
|
|
118
|
+
reasons.append(f"{field} contains an invalid record")
|
|
119
|
+
continue
|
|
120
|
+
file_name = item.get("path")
|
|
121
|
+
expected = item.get("sha256")
|
|
122
|
+
if not isinstance(file_name, str) or not isinstance(expected, str):
|
|
123
|
+
reasons.append(f"{field} contains an invalid path or SHA-256")
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
_, resolved = project_file(root, file_name)
|
|
127
|
+
if sha256(resolved) != expected:
|
|
128
|
+
reasons.append(f"readiness file changed: {file_name}")
|
|
129
|
+
contents.append(resolved.read_text(encoding="utf-8"))
|
|
130
|
+
except (OSError, UnicodeError, ReadinessError) as error:
|
|
131
|
+
reasons.append(str(error))
|
|
132
|
+
return contents
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def inspect(root: Path) -> dict[str, object]:
|
|
136
|
+
receipt = root / RECEIPT
|
|
137
|
+
if not receipt.is_file():
|
|
138
|
+
return {
|
|
139
|
+
"status": "needs_init",
|
|
140
|
+
"coverage_scope": COVERAGE_SCOPE,
|
|
141
|
+
"reasons": ["TDD readiness receipt is missing"],
|
|
142
|
+
"receipt": RECEIPT.as_posix(),
|
|
143
|
+
}
|
|
144
|
+
try:
|
|
145
|
+
manifest = json.loads(receipt.read_text(encoding="utf-8"))
|
|
146
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
147
|
+
return {
|
|
148
|
+
"status": "needs_init",
|
|
149
|
+
"coverage_scope": COVERAGE_SCOPE,
|
|
150
|
+
"reasons": ["TDD readiness receipt is invalid"],
|
|
151
|
+
"receipt": RECEIPT.as_posix(),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
reasons: list[str] = []
|
|
155
|
+
if not isinstance(manifest, dict):
|
|
156
|
+
reasons.append("TDD readiness receipt must be a JSON object")
|
|
157
|
+
manifest = {}
|
|
158
|
+
if manifest.get("schema") != SCHEMA:
|
|
159
|
+
reasons.append("unsupported readiness schema")
|
|
160
|
+
if manifest.get("provider") != "gitlab":
|
|
161
|
+
reasons.append("readiness provider must be gitlab")
|
|
162
|
+
if manifest.get("coverage_scope") != COVERAGE_SCOPE:
|
|
163
|
+
reasons.append("coverage scope must be changed-production-lines")
|
|
164
|
+
if manifest.get("historical_coverage_required") is not False:
|
|
165
|
+
reasons.append("historical coverage must remain disabled")
|
|
166
|
+
patterns = manifest.get("coverage_report_patterns")
|
|
167
|
+
if not isinstance(patterns, list) or not patterns or not all(
|
|
168
|
+
isinstance(item, str) and safe_report_pattern(item) for item in patterns
|
|
169
|
+
):
|
|
170
|
+
reasons.append(
|
|
171
|
+
"coverage_report_patterns must contain safe project-relative report patterns"
|
|
172
|
+
)
|
|
173
|
+
gate = manifest.get("changed_line_gate_command")
|
|
174
|
+
if not isinstance(gate, str) or COVERAGE_TOOL_PATH not in gate:
|
|
175
|
+
reasons.append("changed-line coverage gate command is missing")
|
|
176
|
+
elif not required_gate_variables(gate):
|
|
177
|
+
reasons.append(
|
|
178
|
+
"changed-line coverage gate must use the task baseline and threshold variables"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
manifest_build_files = manifest.get("build_files")
|
|
182
|
+
manifest_ci_files = manifest.get("ci_files")
|
|
183
|
+
manifest_tool_files = manifest.get("tool_files")
|
|
184
|
+
build_contents = parse_records(root, manifest_build_files, "build_files", reasons)
|
|
185
|
+
ci_contents = parse_records(root, manifest_ci_files, "ci_files", reasons)
|
|
186
|
+
parse_records(root, manifest_tool_files, "tool_files", reasons)
|
|
187
|
+
build_paths = {
|
|
188
|
+
Path(item.get("path", "")).name
|
|
189
|
+
for item in manifest_build_files
|
|
190
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
191
|
+
} if isinstance(manifest_build_files, list) else set()
|
|
192
|
+
ci_paths = {
|
|
193
|
+
item.get("path", "").replace("\\", "/")
|
|
194
|
+
for item in manifest_ci_files
|
|
195
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
196
|
+
} if isinstance(manifest_ci_files, list) else set()
|
|
197
|
+
if not build_paths.intersection(JAVA_BUILD_FILE_NAMES):
|
|
198
|
+
reasons.append("build_files must include a Maven or Gradle Java build file")
|
|
199
|
+
if not ci_paths.intersection(GITLAB_CI_ENTRY_FILES):
|
|
200
|
+
reasons.append("ci_files must include the project-root GitLab CI entry file")
|
|
201
|
+
tool_paths = {
|
|
202
|
+
item.get("path", "").replace("\\", "/")
|
|
203
|
+
for item in manifest_tool_files
|
|
204
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
205
|
+
} if isinstance(manifest_tool_files, list) else set()
|
|
206
|
+
if COVERAGE_TOOL_PATH not in tool_paths:
|
|
207
|
+
reasons.append(f"tool_files must include {COVERAGE_TOOL_PATH}")
|
|
208
|
+
if not any("jacoco" in content.lower() for content in build_contents):
|
|
209
|
+
reasons.append("build files do not configure JaCoCo")
|
|
210
|
+
reasons.extend(ci_contract_reasons(ci_contents))
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
"status": "ready" if not reasons else "needs_init",
|
|
214
|
+
"coverage_scope": COVERAGE_SCOPE,
|
|
215
|
+
"reasons": list(dict.fromkeys(reasons)),
|
|
216
|
+
"receipt": RECEIPT.as_posix(),
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def record(args: argparse.Namespace, root: Path) -> dict[str, object]:
|
|
221
|
+
if not args.build_file:
|
|
222
|
+
raise ReadinessError("At least one --build-file is required.")
|
|
223
|
+
if not args.ci_file:
|
|
224
|
+
raise ReadinessError("At least one --ci-file is required.")
|
|
225
|
+
if not args.coverage_report:
|
|
226
|
+
raise ReadinessError("At least one --coverage-report is required.")
|
|
227
|
+
if not all(safe_report_pattern(value) for value in args.coverage_report):
|
|
228
|
+
raise ReadinessError("--coverage-report values must be safe project-relative patterns.")
|
|
229
|
+
if COVERAGE_TOOL_PATH not in args.gate_command:
|
|
230
|
+
raise ReadinessError(f"--gate-command must invoke {COVERAGE_TOOL_PATH}.")
|
|
231
|
+
if not required_gate_variables(args.gate_command):
|
|
232
|
+
raise ReadinessError(
|
|
233
|
+
"--gate-command must use $EASY_CODING_TDD_BASE_SHA and "
|
|
234
|
+
"$EASY_CODING_TDD_THRESHOLD."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
build_records = [file_record(root, value) for value in args.build_file]
|
|
238
|
+
ci_records = [file_record(root, value) for value in args.ci_file]
|
|
239
|
+
tool_records = [file_record(root, COVERAGE_TOOL_PATH)]
|
|
240
|
+
if not any(Path(item["path"]).name in JAVA_BUILD_FILE_NAMES for item in build_records):
|
|
241
|
+
raise ReadinessError("--build-file must include pom.xml, build.gradle, or build.gradle.kts.")
|
|
242
|
+
if not any(item["path"] in GITLAB_CI_ENTRY_FILES for item in ci_records):
|
|
243
|
+
raise ReadinessError(
|
|
244
|
+
"--ci-file must include the project-root .gitlab-ci.yml or .gitlab-ci.yaml."
|
|
245
|
+
)
|
|
246
|
+
build_contents = [
|
|
247
|
+
(root / record_item["path"]).read_text(encoding="utf-8")
|
|
248
|
+
for record_item in build_records
|
|
249
|
+
]
|
|
250
|
+
ci_contents = [
|
|
251
|
+
(root / record_item["path"]).read_text(encoding="utf-8") for record_item in ci_records
|
|
252
|
+
]
|
|
253
|
+
if not any("jacoco" in content.lower() for content in build_contents):
|
|
254
|
+
raise ReadinessError("Build files must configure JaCoCo before readiness can be recorded.")
|
|
255
|
+
ci_reasons = ci_contract_reasons(ci_contents)
|
|
256
|
+
if ci_reasons:
|
|
257
|
+
raise ReadinessError("; ".join(ci_reasons))
|
|
258
|
+
|
|
259
|
+
manifest = {
|
|
260
|
+
"schema": SCHEMA,
|
|
261
|
+
"provider": "gitlab",
|
|
262
|
+
"coverage_scope": COVERAGE_SCOPE,
|
|
263
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
264
|
+
"generated_by": args.agent,
|
|
265
|
+
"build_files": build_records,
|
|
266
|
+
"ci_files": ci_records,
|
|
267
|
+
"tool_files": tool_records,
|
|
268
|
+
"coverage_report_patterns": args.coverage_report,
|
|
269
|
+
"changed_line_gate_command": args.gate_command,
|
|
270
|
+
"historical_coverage_required": False,
|
|
271
|
+
}
|
|
272
|
+
receipt = root / RECEIPT
|
|
273
|
+
receipt.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
temporary = receipt.with_suffix(f".tmp-{os.getpid()}")
|
|
275
|
+
temporary.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
276
|
+
temporary.replace(receipt)
|
|
277
|
+
result = inspect(root)
|
|
278
|
+
if result["status"] != "ready":
|
|
279
|
+
raise ReadinessError("Recorded readiness receipt did not pass validation.")
|
|
280
|
+
return result
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def main() -> int:
|
|
284
|
+
parser = argparse.ArgumentParser(description="Easy Coding Java TDD readiness tool")
|
|
285
|
+
parser.add_argument("--cwd", default=".")
|
|
286
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
287
|
+
subcommands.add_parser("check")
|
|
288
|
+
record_parser = subcommands.add_parser("record")
|
|
289
|
+
record_parser.add_argument("--build-file", action="append", default=[])
|
|
290
|
+
record_parser.add_argument("--ci-file", action="append", default=[])
|
|
291
|
+
record_parser.add_argument("--coverage-report", action="append", default=[])
|
|
292
|
+
record_parser.add_argument("--gate-command", required=True)
|
|
293
|
+
record_parser.add_argument("--agent", required=True)
|
|
294
|
+
args = parser.parse_args()
|
|
295
|
+
root = Path(args.cwd).resolve()
|
|
296
|
+
try:
|
|
297
|
+
result = inspect(root) if args.command == "check" else record(args, root)
|
|
298
|
+
except (OSError, UnicodeError, ReadinessError) as error:
|
|
299
|
+
print(json.dumps({"status": "needs_init", "reasons": [str(error)]}, ensure_ascii=False))
|
|
300
|
+
return 2
|
|
301
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
302
|
+
return 0 if result["status"] == "ready" else 1
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
raise SystemExit(main())
|