easy-coding-harness 0.9.1 → 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.
- package/CHANGELOG.md +31 -0
- package/README.md +24 -3
- package/dist/cli.js +102 -13
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +6 -0
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +28 -5
- package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +1 -1
- package/templates/common/skills/ec-analysis/SKILL.md +73 -1
- package/templates/common/skills/ec-config/SKILL.md +54 -0
- package/templates/common/skills/ec-git/SKILL.md +4 -0
- package/templates/common/skills/ec-implementing/SKILL.md +25 -3
- package/templates/common/skills/ec-memory/SKILL.md +4 -0
- package/templates/common/skills/ec-reviewing/SKILL.md +15 -0
- package/templates/common/skills/ec-task-management/SKILL.md +13 -31
- package/templates/common/skills/ec-verification/SKILL.md +69 -2
- package/templates/common/skills/ec-workflow/SKILL.md +39 -4
- package/templates/main-constraint/AGENTS.md.tpl +10 -3
- package/templates/main-constraint/CLAUDE.md.tpl +10 -3
- package/templates/runtime/templates/dev-spec-skeleton.md +12 -5
- package/templates/runtime/tools/easy_coding_java_coverage.py +317 -0
- package/templates/shared-hooks/easy_coding_state.py +1655 -60
- package/templates/shared-hooks/easy_dev_spec.py +417 -0
- package/templates/shared-hooks/easy_dev_spec_protocol.py +1971 -0
- package/templates/shared-hooks/inject-subagent-context.py +5 -0
|
@@ -13,8 +13,10 @@ then a blank line. Do not render the machine breadcrumbs to the user.
|
|
|
13
13
|
|
|
14
14
|
`{approval-mode}` is the effective approval mode and `{workflow-mode}` is the configured or
|
|
15
15
|
task-frozen execution mode; session overrides take precedence over project settings.
|
|
16
|
+
When effective/frozen TDD is enabled, insert `· **TDD**` immediately after Workflow. When it is
|
|
17
|
+
disabled, omit the TDD segment entirely and preserve the existing status-line format.
|
|
16
18
|
|
|
17
|
-
- Ready: > **Easy Coding** · **Approval: {approval-mode}** · **Workflow: {workflow-mode}** · Ready · Use `ec-workflow` to start or resume a task, `ec-brainstorming` to brainstorm,
|
|
19
|
+
- Ready: > **Easy Coding** · **Approval: {approval-mode}** · **Workflow: {workflow-mode}** · Ready · Use `ec-workflow` to start or resume a task, `ec-brainstorming` to brainstorm, `ec-task-management` to manage tasks, or `ec-config` to inspect or change modes
|
|
18
20
|
- Waiting init: > **Easy Coding** · **Approval: {approval-mode}** · **Workflow: {workflow-mode}** · Waiting init · Use `ec-init` to initialize
|
|
19
21
|
- Active task: > **Easy Coding** · **Approval: {approval-mode}** · **Workflow: {workflow-mode}** · `{current-task}` · `{workflow-state}`
|
|
20
22
|
- Handoff: > **Easy Coding** · **Approval: {approval-mode}** · **Workflow: {workflow-mode}** · `{current-task}` · `{workflow-state}` · Handoff -> `{source-agent}`
|
|
@@ -31,7 +33,7 @@ Trigger Easy Coding skills with your platform prefix — Codex: `$ec-*`, Qoder:
|
|
|
31
33
|
- `ec-brainstorming` — design exploration before building (hard design gate)
|
|
32
34
|
- `ec-analysis` `ec-implementing` `ec-reviewing` `ec-verification` — workflow stages
|
|
33
35
|
- `ec-memory` — short/long memory archive
|
|
34
|
-
- `ec-task-management` — task
|
|
36
|
+
- `ec-task-management` — task lifecycle panel · `ec-config` — Approval/Workflow/TDD settings · `ec-task-close` — interrupt a task
|
|
35
37
|
- `ec-no-harness` — bypass only Easy Coding for the current session
|
|
36
38
|
- `ec-git` — git discipline · `ec-meta` — understand/customize the harness
|
|
37
39
|
|
|
@@ -45,6 +47,11 @@ First run `ec-init`; daily work goes through `ec-workflow`.
|
|
|
45
47
|
Confirm approval waits only at ANALYSIS -> IMPLEMENT, then advances green later stages
|
|
46
48
|
automatically. Every new code task runs REVIEW; no mode changes scope, delivery form, or
|
|
47
49
|
evidence gates.
|
|
50
|
+
- TDD is session override > project `behavior.tdd_enabled` > `false`; its changed-line threshold
|
|
51
|
+
is session override > project `behavior.tdd_coverage_threshold` > `90`. ANALYSIS -> IMPLEMENT
|
|
52
|
+
freezes both. Disabled TDD adds no CI scan, JaCoCo work, commands, artifacts, or stronger gates.
|
|
53
|
+
Enabled TDD applies only to Java code tasks and requires lifecycle, review, local coverage, and
|
|
54
|
+
GitLab TEST-stage gate evidence.
|
|
48
55
|
- Confirmation-required edges use `pending_transition`; automatic edges use the restricted
|
|
49
56
|
`auto-transition` API. A read-only task creates no test-strategy.md, never enters REVIEW,
|
|
50
57
|
VERIFICATION, or MEMORY, and writes no task memory.
|
|
@@ -84,7 +91,7 @@ First run `ec-init`; daily work goes through `ec-workflow`.
|
|
|
84
91
|
|
|
85
92
|
- Workflow state operations go through `{{platform_config_dir}}/hooks/easy_coding_state.py`;
|
|
86
93
|
do not hand-edit session files, `current_task`, task `status`, `stage_history`,
|
|
87
|
-
`pending_transition`, workflow
|
|
94
|
+
`pending_transition`, workflow/TDD proposal or freeze fields, `memory_progress`, or `last_agent`.
|
|
88
95
|
- The hook injects `[easy-coding:session-file:P]`; pass that path to the state script with
|
|
89
96
|
`--session-file <P>` when changing the current task or stage.
|
|
90
97
|
- Workflow session files live at `{{workflow_state_path}}`; the CLI only installs files and
|
|
@@ -13,8 +13,10 @@ then a blank line. Do not render the machine breadcrumbs to the user.
|
|
|
13
13
|
|
|
14
14
|
`{approval-mode}` is the effective approval mode and `{workflow-mode}` is the configured or
|
|
15
15
|
task-frozen execution mode; session overrides take precedence over project settings.
|
|
16
|
+
When effective/frozen TDD is enabled, insert `· **TDD**` immediately after Workflow. When it is
|
|
17
|
+
disabled, omit the TDD segment entirely and preserve the existing status-line format.
|
|
16
18
|
|
|
17
|
-
- Ready: > **Easy Coding** · **Approval: {approval-mode}** · **Workflow: {workflow-mode}** · Ready · Use `ec-workflow` to start or resume a task, `ec-brainstorming` to brainstorm,
|
|
19
|
+
- Ready: > **Easy Coding** · **Approval: {approval-mode}** · **Workflow: {workflow-mode}** · Ready · Use `ec-workflow` to start or resume a task, `ec-brainstorming` to brainstorm, `ec-task-management` to manage tasks, or `ec-config` to inspect or change modes
|
|
18
20
|
- Waiting init: > **Easy Coding** · **Approval: {approval-mode}** · **Workflow: {workflow-mode}** · Waiting init · Use `ec-init` to initialize
|
|
19
21
|
- Active task: > **Easy Coding** · **Approval: {approval-mode}** · **Workflow: {workflow-mode}** · `{current-task}` · `{workflow-state}`
|
|
20
22
|
- Handoff: > **Easy Coding** · **Approval: {approval-mode}** · **Workflow: {workflow-mode}** · `{current-task}` · `{workflow-state}` · Handoff -> `{source-agent}`
|
|
@@ -29,7 +31,7 @@ platform prefixes such as `/` or `$`. If no status line is injected, do not inve
|
|
|
29
31
|
- `/ec-brainstorming` — design exploration before building (hard design gate)
|
|
30
32
|
- `/ec-analysis` `/ec-implementing` `/ec-reviewing` `/ec-verification` — workflow stages
|
|
31
33
|
- `/ec-memory` — short/long memory archive
|
|
32
|
-
- `/ec-task-management` — task
|
|
34
|
+
- `/ec-task-management` — task lifecycle panel · `/ec-config` — Approval/Workflow/TDD settings · `/ec-task-close` — interrupt a task
|
|
33
35
|
- `/ec-no-harness` — bypass only Easy Coding for the current session
|
|
34
36
|
- `/ec-git` — git discipline · `/ec-meta` — understand/customize the harness
|
|
35
37
|
|
|
@@ -43,6 +45,11 @@ First run `/ec-init`; daily work goes through `/ec-workflow`.
|
|
|
43
45
|
Confirm approval waits only at ANALYSIS -> IMPLEMENT, then advances green later stages
|
|
44
46
|
automatically. Every new code task runs REVIEW; no mode changes scope, delivery form, or
|
|
45
47
|
evidence gates.
|
|
48
|
+
- TDD is session override > project `behavior.tdd_enabled` > `false`; its changed-line threshold
|
|
49
|
+
is session override > project `behavior.tdd_coverage_threshold` > `90`. ANALYSIS -> IMPLEMENT
|
|
50
|
+
freezes both. Disabled TDD adds no CI scan, JaCoCo work, commands, artifacts, or stronger gates.
|
|
51
|
+
Enabled TDD applies only to Java code tasks and requires lifecycle, review, local coverage, and
|
|
52
|
+
GitLab TEST-stage gate evidence.
|
|
46
53
|
- Confirmation-required edges use `pending_transition`; automatic edges use the restricted
|
|
47
54
|
`auto-transition` API. A read-only task creates no test-strategy.md, never enters REVIEW,
|
|
48
55
|
VERIFICATION, or MEMORY, and writes no task memory.
|
|
@@ -82,7 +89,7 @@ First run `/ec-init`; daily work goes through `/ec-workflow`.
|
|
|
82
89
|
|
|
83
90
|
- Workflow state operations go through `{{platform_config_dir}}/hooks/easy_coding_state.py`;
|
|
84
91
|
do not hand-edit session files, `current_task`, task `status`, `stage_history`,
|
|
85
|
-
`pending_transition`, workflow
|
|
92
|
+
`pending_transition`, workflow/TDD proposal or freeze fields, `memory_progress`, or `last_agent`.
|
|
86
93
|
- The hook injects `[easy-coding:session-file:P]`; pass that path to the state script with
|
|
87
94
|
`--session-file <P>` when changing the current task or stage.
|
|
88
95
|
- Workflow session files live at `{{workflow_state_path}}`; the CLI only installs files and
|
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
- 需求 vs 现有代码:[[EC_TODO:填写结果或“无冲突”]]
|
|
25
25
|
- Dev-Spec vs 现有代码:[[EC_TODO:填写结果或“无冲突”]]
|
|
26
26
|
|
|
27
|
+
### Canonical Spec 来源
|
|
28
|
+
- **来源**:[[EC_TODO:非 Canonical 任务写“无”;否则填写 repo-relative path、spec_id、revision、SHA-256]]
|
|
29
|
+
- **选择任务 / 仓库**:[[EC_TODO:非 Canonical 任务写“无”;否则填写 selected task IDs 与 repo IDs]]
|
|
30
|
+
- **消费闭包**:[[EC_TODO:非 Canonical 任务写“无”;否则填写 contracts、direct dependencies、changes、steps、tests 摘要]]
|
|
31
|
+
- **基线与冲突**:[[EC_TODO:非 Canonical 任务写“无”;否则逐仓填写 exact/scope-unchanged/scope-drifted/baseline-unavailable 及处理结论]]
|
|
32
|
+
- **待闭合 integration**:[[EC_TODO:非 Canonical 任务或不存在时写“无”;否则列依赖边和证据要求]]
|
|
33
|
+
|
|
27
34
|
### 影响面分析
|
|
28
35
|
- **涉及模块**:[[EC_TODO:涉及模块]]
|
|
29
36
|
- **核心类 / 页面 / 接口**:[[EC_TODO:核心类 / 页面 / 接口]]
|
|
@@ -49,11 +56,11 @@
|
|
|
49
56
|
|
|
50
57
|
### 实施拆解
|
|
51
58
|
|
|
52
|
-
| 单元 | 说明 | 类型 | 涉及文件 | 依赖 | 验收条件 | 测试点 | 跨单元契约 |
|
|
53
|
-
|
|
54
|
-
| U1 | [[EC_TODO:单元标题]] | [[EC_TODO:backend/frontend/test/...]] | [[EC_TODO
|
|
55
|
-
| U2 | [[EC_TODO:单元标题]] | [[EC_TODO:单元类型]] | [[EC_TODO
|
|
56
|
-
| U3 | [[EC_TODO:单元标题]] | [[EC_TODO:单元类型]] | [[EC_TODO
|
|
59
|
+
| 单元 | 说明 | 类型 | 仓库 / 来源任务 / 步骤 | 涉及文件 / 符号 | 依赖 | 验收条件 | 测试点 / 命令 | 跨单元契约 |
|
|
60
|
+
|------|------|------|----------------------|-----------------|------|---------|---------------|-----------|
|
|
61
|
+
| U1 | [[EC_TODO:单元标题]] | [[EC_TODO:backend/frontend/test/...]] | [[EC_TODO:普通任务写“当前仓库 / 无 / 无”;Canonical 写 repo_id / source_task_id / source_step_ids]] | [[EC_TODO:文件与 symbols]] | — | [[EC_TODO:可验证结果]] | [[EC_TODO:定向验证与 test_commands]] | [[EC_TODO:输入输出或“无”]] |
|
|
62
|
+
| U2 | [[EC_TODO:单元标题]] | [[EC_TODO:单元类型]] | [[EC_TODO:仓库 / 来源任务 / 步骤]] | [[EC_TODO:文件与 symbols]] | — | [[EC_TODO:可验证结果]] | [[EC_TODO:定向验证与命令]] | [[EC_TODO:输入输出或“无”]] |
|
|
63
|
+
| U3 | [[EC_TODO:单元标题]] | [[EC_TODO:单元类型]] | [[EC_TODO:仓库 / 来源任务 / 步骤]] | [[EC_TODO:文件与 symbols]] | U1, U2 | [[EC_TODO:可验证结果]] | [[EC_TODO:定向验证与命令]] | [[EC_TODO:与 U1/U2 的契约]] |
|
|
57
64
|
|
|
58
65
|
**执行策略**:[[EC_TODO:parallel / sequential / single]]
|
|
59
66
|
- 第一批(并行):U1 [[EC_TODO:单元标题]] | U2 [[EC_TODO:单元标题]]
|
|
@@ -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())
|