easy-coding-harness 0.10.0-beta.1 → 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 +16 -0
- package/README.md +6 -4
- package/dist/cli.js +236 -40
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +5 -3
- package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +1 -1
- package/templates/common/skills/ec-analysis/SKILL.md +9 -1
- package/templates/common/skills/ec-config/SKILL.md +15 -1
- package/templates/common/skills/ec-tdd-init/SKILL.md +99 -0
- package/templates/common/skills/ec-verification/SKILL.md +9 -1
- package/templates/common/skills/ec-workflow/SKILL.md +11 -5
- package/templates/main-constraint/AGENTS.md.tpl +7 -4
- package/templates/main-constraint/CLAUDE.md.tpl +7 -4
- package/templates/runtime/tools/easy_coding_tdd_readiness.py +306 -0
- package/templates/shared-hooks/easy_coding_state.py +248 -4
|
@@ -66,6 +66,7 @@ ALWAYS_AUTO_TRANSITIONS = {
|
|
|
66
66
|
}
|
|
67
67
|
READ_ONLY_COMPLETION_TRANSITION = ("IMPLEMENT", "COMPLETE")
|
|
68
68
|
NO_CODE_TASK_TYPES = {"analysis", "doc", "report"}
|
|
69
|
+
TDD_INIT_TASK_TYPE = "tdd-init"
|
|
69
70
|
APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
|
|
70
71
|
CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
|
|
71
72
|
WORKFLOW_MODES = {"fast", "standard", "strict"}
|
|
@@ -82,6 +83,14 @@ DEFAULT_APPROVAL_MODE = "guard"
|
|
|
82
83
|
DEFAULT_WORKFLOW_MODE = "adaptive"
|
|
83
84
|
DEFAULT_TDD_ENABLED = False
|
|
84
85
|
DEFAULT_TDD_COVERAGE_THRESHOLD = 90
|
|
86
|
+
TDD_READINESS_SCHEMA = "easy-coding/tdd-readiness-v1"
|
|
87
|
+
TDD_READINESS_SCOPE = "changed-production-lines"
|
|
88
|
+
TDD_READINESS_PATH = Path(".easy-coding/tdd/readiness.json")
|
|
89
|
+
TDD_BASE_VARIABLE = "EASY_CODING_TDD_BASE_SHA"
|
|
90
|
+
TDD_THRESHOLD_VARIABLE = "EASY_CODING_TDD_THRESHOLD"
|
|
91
|
+
COVERAGE_TOOL_PATH = ".easy-coding/tools/easy_coding_java_coverage.py"
|
|
92
|
+
JAVA_BUILD_FILE_NAMES = {"pom.xml", "build.gradle", "build.gradle.kts"}
|
|
93
|
+
GITLAB_CI_ENTRY_FILES = {".gitlab-ci.yml", ".gitlab-ci.yaml"}
|
|
85
94
|
CRITICAL_CONFIRM_TRANSITIONS = {
|
|
86
95
|
("ANALYSIS", "IMPLEMENT"),
|
|
87
96
|
("VERIFICATION", "MEMORY"),
|
|
@@ -399,7 +408,11 @@ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
|
|
|
399
408
|
"expected adaptive, fast, standard, or strict."
|
|
400
409
|
)
|
|
401
410
|
if schema_version >= 4:
|
|
402
|
-
tdd_enabled =
|
|
411
|
+
tdd_enabled = (
|
|
412
|
+
parse_yaml_bool(behavior.get("tdd_enabled"), "behavior.tdd_enabled")
|
|
413
|
+
if schema_version >= 5
|
|
414
|
+
else DEFAULT_TDD_ENABLED
|
|
415
|
+
)
|
|
403
416
|
tdd_threshold = parse_tdd_threshold(
|
|
404
417
|
behavior.get("tdd_coverage_threshold", DEFAULT_TDD_COVERAGE_THRESHOLD),
|
|
405
418
|
"behavior.tdd_coverage_threshold",
|
|
@@ -410,6 +423,175 @@ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
|
|
|
410
423
|
return approval_mode, workflow_mode, tdd_enabled, tdd_threshold
|
|
411
424
|
|
|
412
425
|
|
|
426
|
+
def safe_tdd_report_pattern(value: object) -> bool:
|
|
427
|
+
if not is_non_empty_string(value):
|
|
428
|
+
return False
|
|
429
|
+
candidate = Path(str(value))
|
|
430
|
+
return not candidate.is_absolute() and ".." not in candidate.parts
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def tdd_gate_uses_task_variables(command: object) -> bool:
|
|
434
|
+
if not is_non_empty_string(command):
|
|
435
|
+
return False
|
|
436
|
+
try:
|
|
437
|
+
tokens = shlex.split(str(command))
|
|
438
|
+
except ValueError:
|
|
439
|
+
return False
|
|
440
|
+
options: dict[str, str] = {}
|
|
441
|
+
for index, token in enumerate(tokens[:-1]):
|
|
442
|
+
if token in {"--base", "--threshold"}:
|
|
443
|
+
options[token] = tokens[index + 1]
|
|
444
|
+
return options.get("--base") in {
|
|
445
|
+
f"${TDD_BASE_VARIABLE}",
|
|
446
|
+
"$" + "{" + TDD_BASE_VARIABLE + "}",
|
|
447
|
+
} and options.get("--threshold") in {
|
|
448
|
+
f"${TDD_THRESHOLD_VARIABLE}",
|
|
449
|
+
"$" + "{" + TDD_THRESHOLD_VARIABLE + "}",
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def tdd_ci_contract_reasons(contents: list[str]) -> list[str]:
|
|
454
|
+
combined = "\n".join(
|
|
455
|
+
re.sub(r"\s+#.*$", "", re.sub(r"^\s*#.*$", "", line))
|
|
456
|
+
for line in "\n".join(contents).splitlines()
|
|
457
|
+
)
|
|
458
|
+
lowered = combined.lower()
|
|
459
|
+
reasons: list[str] = []
|
|
460
|
+
for marker in (
|
|
461
|
+
"jacoco",
|
|
462
|
+
"artifacts",
|
|
463
|
+
COVERAGE_TOOL_PATH,
|
|
464
|
+
TDD_BASE_VARIABLE,
|
|
465
|
+
TDD_THRESHOLD_VARIABLE,
|
|
466
|
+
):
|
|
467
|
+
if marker.lower() not in lowered:
|
|
468
|
+
reasons.append(f"CI files do not contain required marker: {marker}")
|
|
469
|
+
if not tdd_gate_uses_task_variables(combined):
|
|
470
|
+
reasons.append(
|
|
471
|
+
"CI changed-line gate must use the task baseline and threshold variables"
|
|
472
|
+
)
|
|
473
|
+
if re.search(
|
|
474
|
+
r"(?:^|\n)\s*stage\s*:\s*['\"]?test['\"]?\s*(?:#.*)?(?:\n|$)",
|
|
475
|
+
combined,
|
|
476
|
+
re.IGNORECASE,
|
|
477
|
+
) is None:
|
|
478
|
+
reasons.append("CI files do not declare a TEST-stage job")
|
|
479
|
+
return reasons
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def tdd_readiness(root: Path) -> dict[str, object]:
|
|
483
|
+
receipt = root / TDD_READINESS_PATH
|
|
484
|
+
if not receipt.is_file():
|
|
485
|
+
return {"status": "needs_init", "reasons": ["TDD readiness receipt is missing"]}
|
|
486
|
+
try:
|
|
487
|
+
manifest = json.loads(receipt.read_text(encoding="utf-8"))
|
|
488
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
489
|
+
return {"status": "needs_init", "reasons": ["TDD readiness receipt is invalid"]}
|
|
490
|
+
if not isinstance(manifest, dict):
|
|
491
|
+
return {
|
|
492
|
+
"status": "needs_init",
|
|
493
|
+
"reasons": ["TDD readiness receipt must be a JSON object"],
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
reasons: list[str] = []
|
|
497
|
+
if manifest.get("schema") != TDD_READINESS_SCHEMA:
|
|
498
|
+
reasons.append("unsupported readiness schema")
|
|
499
|
+
if manifest.get("provider") != "gitlab":
|
|
500
|
+
reasons.append("readiness provider must be gitlab")
|
|
501
|
+
if manifest.get("coverage_scope") != TDD_READINESS_SCOPE:
|
|
502
|
+
reasons.append("coverage scope must be changed-production-lines")
|
|
503
|
+
if manifest.get("historical_coverage_required") is not False:
|
|
504
|
+
reasons.append("historical coverage must remain disabled")
|
|
505
|
+
reports = manifest.get("coverage_report_patterns")
|
|
506
|
+
if not isinstance(reports, list) or not reports or not all(
|
|
507
|
+
safe_tdd_report_pattern(item) for item in reports
|
|
508
|
+
):
|
|
509
|
+
reasons.append(
|
|
510
|
+
"coverage_report_patterns must contain safe project-relative report patterns"
|
|
511
|
+
)
|
|
512
|
+
gate = manifest.get("changed_line_gate_command")
|
|
513
|
+
if not is_non_empty_string(gate) or COVERAGE_TOOL_PATH not in str(gate):
|
|
514
|
+
reasons.append("changed-line coverage gate command is missing")
|
|
515
|
+
elif not tdd_gate_uses_task_variables(gate):
|
|
516
|
+
reasons.append(
|
|
517
|
+
"changed-line coverage gate must use the task baseline and threshold variables"
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
contents: dict[str, list[str]] = {
|
|
521
|
+
"build_files": [],
|
|
522
|
+
"ci_files": [],
|
|
523
|
+
"tool_files": [],
|
|
524
|
+
}
|
|
525
|
+
for field in contents:
|
|
526
|
+
records = manifest.get(field)
|
|
527
|
+
if not isinstance(records, list) or not records:
|
|
528
|
+
reasons.append(f"{field} must contain at least one file")
|
|
529
|
+
continue
|
|
530
|
+
for record in records:
|
|
531
|
+
if not isinstance(record, dict):
|
|
532
|
+
reasons.append(f"{field} contains an invalid record")
|
|
533
|
+
continue
|
|
534
|
+
file_name = record.get("path")
|
|
535
|
+
expected = record.get("sha256")
|
|
536
|
+
if not is_non_empty_string(file_name) or not re.fullmatch(
|
|
537
|
+
r"[a-f0-9]{64}", str(expected or "")
|
|
538
|
+
):
|
|
539
|
+
reasons.append(f"{field} contains an invalid path or SHA-256")
|
|
540
|
+
continue
|
|
541
|
+
candidate = Path(str(file_name))
|
|
542
|
+
if candidate.is_absolute():
|
|
543
|
+
reasons.append(f"readiness file must be project-relative: {file_name}")
|
|
544
|
+
continue
|
|
545
|
+
resolved = (root / candidate).resolve()
|
|
546
|
+
try:
|
|
547
|
+
resolved.relative_to(root.resolve())
|
|
548
|
+
payload = resolved.read_bytes()
|
|
549
|
+
contents[field].append(payload.decode("utf-8"))
|
|
550
|
+
if hashlib.sha256(payload).hexdigest() != expected:
|
|
551
|
+
reasons.append(f"readiness file changed: {file_name}")
|
|
552
|
+
except (OSError, UnicodeError, ValueError):
|
|
553
|
+
reasons.append(f"readiness file is missing or unreadable: {file_name}")
|
|
554
|
+
|
|
555
|
+
manifest_build_files = manifest.get("build_files")
|
|
556
|
+
manifest_ci_files = manifest.get("ci_files")
|
|
557
|
+
manifest_tool_files = manifest.get("tool_files")
|
|
558
|
+
build_paths = {
|
|
559
|
+
Path(str(item.get("path", ""))).name
|
|
560
|
+
for item in manifest_build_files
|
|
561
|
+
if isinstance(item, dict) and is_non_empty_string(item.get("path"))
|
|
562
|
+
} if isinstance(manifest_build_files, list) else set()
|
|
563
|
+
ci_paths = {
|
|
564
|
+
str(item.get("path", "")).replace("\\", "/")
|
|
565
|
+
for item in manifest_ci_files
|
|
566
|
+
if isinstance(item, dict) and is_non_empty_string(item.get("path"))
|
|
567
|
+
} if isinstance(manifest_ci_files, list) else set()
|
|
568
|
+
if not build_paths.intersection(JAVA_BUILD_FILE_NAMES):
|
|
569
|
+
reasons.append("build_files must include a Maven or Gradle Java build file")
|
|
570
|
+
if not ci_paths.intersection(GITLAB_CI_ENTRY_FILES):
|
|
571
|
+
reasons.append("ci_files must include the project-root GitLab CI entry file")
|
|
572
|
+
tool_paths = {
|
|
573
|
+
str(item.get("path", "")).replace("\\", "/")
|
|
574
|
+
for item in manifest_tool_files
|
|
575
|
+
if isinstance(item, dict) and is_non_empty_string(item.get("path"))
|
|
576
|
+
} if isinstance(manifest_tool_files, list) else set()
|
|
577
|
+
if COVERAGE_TOOL_PATH not in tool_paths:
|
|
578
|
+
reasons.append(f"tool_files must include {COVERAGE_TOOL_PATH}")
|
|
579
|
+
if not any("jacoco" in content.lower() for content in contents["build_files"]):
|
|
580
|
+
reasons.append("build files do not configure JaCoCo")
|
|
581
|
+
reasons.extend(tdd_ci_contract_reasons(contents["ci_files"]))
|
|
582
|
+
return {
|
|
583
|
+
"status": "ready" if not reasons else "needs_init",
|
|
584
|
+
"reasons": list(dict.fromkeys(reasons)),
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def require_tdd_readiness(root: Path) -> None:
|
|
589
|
+
readiness = tdd_readiness(root)
|
|
590
|
+
if readiness["status"] != "ready":
|
|
591
|
+
reasons = "; ".join(str(reason) for reason in readiness["reasons"])
|
|
592
|
+
raise StateError(f"TDD cannot be enabled before ec-tdd-init succeeds: {reasons}")
|
|
593
|
+
|
|
594
|
+
|
|
413
595
|
def resolve_behavior(
|
|
414
596
|
root: Path, session: dict
|
|
415
597
|
) -> tuple[str, str | None, str, str, str | None, str, bool, bool | None, bool, int, int | None, int]:
|
|
@@ -2411,6 +2593,13 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2411
2593
|
raise StateError(
|
|
2412
2594
|
"VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
|
|
2413
2595
|
)
|
|
2596
|
+
if str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE:
|
|
2597
|
+
readiness = tdd_readiness(root)
|
|
2598
|
+
if readiness["status"] != "ready":
|
|
2599
|
+
raise StateError(
|
|
2600
|
+
"TDD initialization cannot advance to MEMORY until readiness passes: "
|
|
2601
|
+
+ "; ".join(str(reason) for reason in readiness["reasons"])
|
|
2602
|
+
)
|
|
2414
2603
|
if task.get("tdd_enabled") is not True and any(
|
|
2415
2604
|
record.get("check_type") == "coverage" for record in latest_by_check.values()
|
|
2416
2605
|
):
|
|
@@ -2418,6 +2607,7 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2418
2607
|
"Coverage verification evidence is not allowed when the frozen TDD mode is off."
|
|
2419
2608
|
)
|
|
2420
2609
|
if task.get("tdd_enabled") is True:
|
|
2610
|
+
require_tdd_readiness(root)
|
|
2421
2611
|
coverage_records = [
|
|
2422
2612
|
record
|
|
2423
2613
|
for record in latest_by_check.values()
|
|
@@ -2826,7 +3016,7 @@ def validate_analysis_readiness(
|
|
|
2826
3016
|
test_strategy = task_dir / "test-strategy.md"
|
|
2827
3017
|
reasons: list[str] = []
|
|
2828
3018
|
behavior = resolve_behavior(root, session or default_session())
|
|
2829
|
-
tdd_enabled = behavior[8]
|
|
3019
|
+
tdd_enabled = behavior[8] if task_type != TDD_INIT_TASK_TYPE else False
|
|
2830
3020
|
tdd_threshold = behavior[11]
|
|
2831
3021
|
|
|
2832
3022
|
dev_spec_content = ""
|
|
@@ -2875,6 +3065,12 @@ def validate_analysis_readiness(
|
|
|
2875
3065
|
if not plan_is_valid:
|
|
2876
3066
|
reasons.append("execution.jsonl has no valid plan record")
|
|
2877
3067
|
if tdd_enabled and not is_read_only_task:
|
|
3068
|
+
readiness = tdd_readiness(root)
|
|
3069
|
+
if readiness["status"] != "ready":
|
|
3070
|
+
reasons.append(
|
|
3071
|
+
"TDD infrastructure is not ready; run ec-tdd-init first: "
|
|
3072
|
+
+ "; ".join(str(reason) for reason in readiness["reasons"])
|
|
3073
|
+
)
|
|
2878
3074
|
plan = latest_execution_plan(root, task_id) or {}
|
|
2879
3075
|
if re.search(
|
|
2880
3076
|
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
@@ -2924,6 +3120,32 @@ def validate_analysis_readiness(
|
|
|
2924
3120
|
dev_spec_content, strategy_content, baselines
|
|
2925
3121
|
)
|
|
2926
3122
|
)
|
|
3123
|
+
elif task_type == TDD_INIT_TASK_TYPE:
|
|
3124
|
+
try:
|
|
3125
|
+
strategy_content = test_strategy.read_text(encoding="utf-8")
|
|
3126
|
+
except OSError:
|
|
3127
|
+
strategy_content = ""
|
|
3128
|
+
required_init_markers = [
|
|
3129
|
+
"JaCoCo",
|
|
3130
|
+
"GitLab",
|
|
3131
|
+
"changed production lines",
|
|
3132
|
+
"historical coverage required: no",
|
|
3133
|
+
"easy_coding_tdd_readiness.py",
|
|
3134
|
+
]
|
|
3135
|
+
missing_init_markers = [
|
|
3136
|
+
marker
|
|
3137
|
+
for marker in required_init_markers
|
|
3138
|
+
if marker.lower() not in strategy_content.lower()
|
|
3139
|
+
]
|
|
3140
|
+
if missing_init_markers:
|
|
3141
|
+
reasons.append(
|
|
3142
|
+
"TDD initialization strategy is missing: "
|
|
3143
|
+
+ ", ".join(missing_init_markers)
|
|
3144
|
+
)
|
|
3145
|
+
if re.search(
|
|
3146
|
+
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
3147
|
+
):
|
|
3148
|
+
reasons.append("tdd-init must keep TDD off and omit the TDD Mode section")
|
|
2927
3149
|
elif not is_read_only_task:
|
|
2928
3150
|
if re.search(
|
|
2929
3151
|
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
@@ -3254,12 +3476,25 @@ def snapshot_state(
|
|
|
3254
3476
|
and status not in {"ANALYSIS", "INIT"}
|
|
3255
3477
|
and isinstance(task_tdd_enabled, bool)
|
|
3256
3478
|
)
|
|
3257
|
-
|
|
3479
|
+
is_tdd_init = bool(
|
|
3480
|
+
task and str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE
|
|
3481
|
+
)
|
|
3482
|
+
displayed_tdd_enabled = (
|
|
3483
|
+
False if is_tdd_init else task_tdd_enabled if frozen_tdd else effective_tdd_enabled
|
|
3484
|
+
)
|
|
3258
3485
|
displayed_tdd_threshold = (
|
|
3259
3486
|
task_tdd_coverage_threshold
|
|
3260
3487
|
if frozen_tdd and isinstance(task_tdd_coverage_threshold, int)
|
|
3261
3488
|
else effective_tdd_coverage_threshold
|
|
3262
3489
|
)
|
|
3490
|
+
should_check_readiness = bool(
|
|
3491
|
+
effective_tdd_enabled or task_tdd_enabled is True or is_tdd_init
|
|
3492
|
+
)
|
|
3493
|
+
readiness = (
|
|
3494
|
+
tdd_readiness(root)
|
|
3495
|
+
if should_check_readiness
|
|
3496
|
+
else {"status": "not_checked", "reasons": []}
|
|
3497
|
+
)
|
|
3263
3498
|
|
|
3264
3499
|
return {
|
|
3265
3500
|
"session_file": display_path(root, session_path),
|
|
@@ -3291,6 +3526,8 @@ def snapshot_state(
|
|
|
3291
3526
|
"task_tdd_baselines": task.get("tdd_baselines") if task else None,
|
|
3292
3527
|
"displayed_tdd_enabled": displayed_tdd_enabled,
|
|
3293
3528
|
"displayed_tdd_coverage_threshold": displayed_tdd_threshold,
|
|
3529
|
+
"tdd_readiness_status": readiness["status"],
|
|
3530
|
+
"tdd_readiness_reasons": readiness["reasons"],
|
|
3294
3531
|
"spec_summary": spec_task_summary(task),
|
|
3295
3532
|
# Compatibility output aliases for pre-0.9 clients.
|
|
3296
3533
|
"project_confirm_mode": project_approval_mode,
|
|
@@ -3674,6 +3911,8 @@ def set_session_tdd(
|
|
|
3674
3911
|
threshold: int | None = None,
|
|
3675
3912
|
session_file: str | Path | None = None,
|
|
3676
3913
|
) -> dict:
|
|
3914
|
+
if enabled:
|
|
3915
|
+
require_tdd_readiness(root)
|
|
3677
3916
|
session = ensure_session(root, session_file)
|
|
3678
3917
|
materialize_legacy_session_behavior(session)
|
|
3679
3918
|
session["tdd_enabled"] = enabled
|
|
@@ -4139,9 +4378,14 @@ def freeze_tdd_mode(
|
|
|
4139
4378
|
) -> None:
|
|
4140
4379
|
behavior = resolve_behavior(root, session)
|
|
4141
4380
|
task_type = str(task.get("type") or "").strip().lower()
|
|
4142
|
-
task["tdd_enabled"] =
|
|
4381
|
+
task["tdd_enabled"] = (
|
|
4382
|
+
behavior[8]
|
|
4383
|
+
if task_type not in NO_CODE_TASK_TYPES | {TDD_INIT_TASK_TYPE}
|
|
4384
|
+
else False
|
|
4385
|
+
)
|
|
4143
4386
|
task["tdd_coverage_threshold"] = behavior[11]
|
|
4144
4387
|
if task["tdd_enabled"] is True:
|
|
4388
|
+
require_tdd_readiness(root)
|
|
4145
4389
|
plan = latest_execution_plan(root, task_id)
|
|
4146
4390
|
if plan is None:
|
|
4147
4391
|
raise StateError("Cannot freeze TDD baseline without a valid execution plan.")
|