easy-coding-harness 0.10.0-beta.1 → 0.10.0-beta.3
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 +28 -0
- package/README.md +8 -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 +7 -4
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +3 -2
- package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +1 -1
- package/templates/common/skills/ec-analysis/SKILL.md +21 -9
- package/templates/common/skills/ec-config/SKILL.md +18 -2
- package/templates/common/skills/ec-memory/SKILL.md +4 -3
- package/templates/common/skills/ec-reviewing/SKILL.md +4 -2
- package/templates/common/skills/ec-tdd-init/SKILL.md +101 -0
- package/templates/common/skills/ec-verification/SKILL.md +19 -23
- package/templates/common/skills/ec-workflow/SKILL.md +11 -5
- package/templates/main-constraint/AGENTS.md.tpl +8 -4
- package/templates/main-constraint/CLAUDE.md.tpl +8 -4
- package/templates/runtime/tools/easy_coding_tdd_readiness.py +306 -0
- package/templates/shared-hooks/easy_coding_state.py +285 -38
|
@@ -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]:
|
|
@@ -2344,6 +2526,13 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2344
2526
|
and record.get("config_fingerprint") == fingerprints["config_fingerprint"]
|
|
2345
2527
|
and is_non_empty_string(record.get("check"))
|
|
2346
2528
|
):
|
|
2529
|
+
if (
|
|
2530
|
+
task.get("tdd_enabled") is True
|
|
2531
|
+
and record.get("check_type") == "coverage"
|
|
2532
|
+
and record.get("coverage_scope") == "gitlab"
|
|
2533
|
+
):
|
|
2534
|
+
# 远程 CI 只作为生成的自动化能力,历史 pending/failed 记录不再参与本地验收。
|
|
2535
|
+
continue
|
|
2347
2536
|
check = str(record["check"])
|
|
2348
2537
|
if task.get("tdd_enabled") is True and record.get("check_type") == "coverage":
|
|
2349
2538
|
check = f"{check}\0{record.get('coverage_scope') or ''}"
|
|
@@ -2411,6 +2600,13 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2411
2600
|
raise StateError(
|
|
2412
2601
|
"VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
|
|
2413
2602
|
)
|
|
2603
|
+
if str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE:
|
|
2604
|
+
readiness = tdd_readiness(root)
|
|
2605
|
+
if readiness["status"] != "ready":
|
|
2606
|
+
raise StateError(
|
|
2607
|
+
"TDD initialization cannot advance to MEMORY until readiness passes: "
|
|
2608
|
+
+ "; ".join(str(reason) for reason in readiness["reasons"])
|
|
2609
|
+
)
|
|
2414
2610
|
if task.get("tdd_enabled") is not True and any(
|
|
2415
2611
|
record.get("check_type") == "coverage" for record in latest_by_check.values()
|
|
2416
2612
|
):
|
|
@@ -2418,6 +2614,13 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2418
2614
|
"Coverage verification evidence is not allowed when the frozen TDD mode is off."
|
|
2419
2615
|
)
|
|
2420
2616
|
if task.get("tdd_enabled") is True:
|
|
2617
|
+
require_tdd_readiness(root)
|
|
2618
|
+
test_records = [
|
|
2619
|
+
record
|
|
2620
|
+
for record in latest_by_check.values()
|
|
2621
|
+
if record.get("check_type") == "test"
|
|
2622
|
+
and record.get("applicable") is not False
|
|
2623
|
+
]
|
|
2421
2624
|
coverage_records = [
|
|
2422
2625
|
record
|
|
2423
2626
|
for record in latest_by_check.values()
|
|
@@ -2428,6 +2631,17 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2428
2631
|
"TDD verification requires changed-production-line JaCoCo coverage evidence."
|
|
2429
2632
|
)
|
|
2430
2633
|
if is_spec_task:
|
|
2634
|
+
tested_source_tasks = {
|
|
2635
|
+
str(record.get("source_task_id") or "") for record in test_records
|
|
2636
|
+
}
|
|
2637
|
+
missing_test_tasks = sorted(
|
|
2638
|
+
set(task_repositories) - tested_source_tasks
|
|
2639
|
+
)
|
|
2640
|
+
if missing_test_tasks:
|
|
2641
|
+
raise StateError(
|
|
2642
|
+
"TDD Canonical verification requires local unit-test evidence for every selected source task: "
|
|
2643
|
+
+ ", ".join(missing_test_tasks)
|
|
2644
|
+
)
|
|
2431
2645
|
covered_source_tasks = {
|
|
2432
2646
|
str(record.get("source_task_id") or "") for record in coverage_records
|
|
2433
2647
|
}
|
|
@@ -2439,19 +2653,16 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2439
2653
|
"TDD Canonical verification requires separate coverage evidence for every selected source task: "
|
|
2440
2654
|
+ ", ".join(missing_coverage_tasks)
|
|
2441
2655
|
)
|
|
2442
|
-
|
|
2656
|
+
elif not test_records:
|
|
2657
|
+
raise StateError(
|
|
2658
|
+
"TDD verification requires passed local unit-test evidence."
|
|
2659
|
+
)
|
|
2443
2660
|
for record in coverage_records:
|
|
2444
2661
|
scope = str(record.get("coverage_scope") or "")
|
|
2445
|
-
if scope
|
|
2662
|
+
if scope != "local":
|
|
2446
2663
|
raise StateError(
|
|
2447
|
-
"TDD coverage evidence must identify coverage_scope as local
|
|
2664
|
+
"TDD coverage evidence must identify coverage_scope as local."
|
|
2448
2665
|
)
|
|
2449
|
-
owner = (
|
|
2450
|
-
str(record.get("source_task_id") or "")
|
|
2451
|
-
if is_spec_task
|
|
2452
|
-
else "project"
|
|
2453
|
-
)
|
|
2454
|
-
coverage_scopes_by_owner.setdefault(owner, set()).add(scope)
|
|
2455
2666
|
expected_threshold = task.get("tdd_coverage_threshold")
|
|
2456
2667
|
expected_baselines = task.get("tdd_baselines")
|
|
2457
2668
|
if (
|
|
@@ -2466,18 +2677,6 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2466
2677
|
coverage = record.get("coverage")
|
|
2467
2678
|
if not isinstance(coverage, dict):
|
|
2468
2679
|
raise StateError("TDD coverage evidence must include the coverage result object.")
|
|
2469
|
-
if record.get("coverage_scope") == "gitlab":
|
|
2470
|
-
ci = record.get("ci")
|
|
2471
|
-
if (
|
|
2472
|
-
not isinstance(ci, dict)
|
|
2473
|
-
or ci.get("provider") != "gitlab"
|
|
2474
|
-
or ci.get("status") != "success"
|
|
2475
|
-
or not is_non_empty_string(ci.get("pipeline_url"))
|
|
2476
|
-
or not is_non_empty_string(ci.get("job_name"))
|
|
2477
|
-
):
|
|
2478
|
-
raise StateError(
|
|
2479
|
-
"GitLab coverage evidence requires a successful pipeline URL and job name."
|
|
2480
|
-
)
|
|
2481
2680
|
total = coverage.get("total_lines")
|
|
2482
2681
|
covered = coverage.get("covered_lines")
|
|
2483
2682
|
percentage = coverage.get("percentage")
|
|
@@ -2532,18 +2731,6 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2532
2731
|
raise StateError(
|
|
2533
2732
|
f"TDD changed-line coverage must meet the frozen {threshold}% threshold."
|
|
2534
2733
|
)
|
|
2535
|
-
expected_coverage_owners = set(task_repositories) if is_spec_task else {"project"}
|
|
2536
|
-
missing_scopes = [
|
|
2537
|
-
f"{owner}:{scope}"
|
|
2538
|
-
for owner in sorted(expected_coverage_owners)
|
|
2539
|
-
for scope in ("local", "gitlab")
|
|
2540
|
-
if scope not in coverage_scopes_by_owner.get(owner, set())
|
|
2541
|
-
]
|
|
2542
|
-
if missing_scopes:
|
|
2543
|
-
raise StateError(
|
|
2544
|
-
"TDD verification requires both local and successful GitLab coverage gates: "
|
|
2545
|
-
+ ", ".join(missing_scopes)
|
|
2546
|
-
)
|
|
2547
2734
|
if task.get("workflow_mode") == "strict":
|
|
2548
2735
|
if is_spec_task:
|
|
2549
2736
|
check_types_by_repository: dict[str, set[str]] = {
|
|
@@ -2826,7 +3013,7 @@ def validate_analysis_readiness(
|
|
|
2826
3013
|
test_strategy = task_dir / "test-strategy.md"
|
|
2827
3014
|
reasons: list[str] = []
|
|
2828
3015
|
behavior = resolve_behavior(root, session or default_session())
|
|
2829
|
-
tdd_enabled = behavior[8]
|
|
3016
|
+
tdd_enabled = behavior[8] if task_type != TDD_INIT_TASK_TYPE else False
|
|
2830
3017
|
tdd_threshold = behavior[11]
|
|
2831
3018
|
|
|
2832
3019
|
dev_spec_content = ""
|
|
@@ -2875,6 +3062,12 @@ def validate_analysis_readiness(
|
|
|
2875
3062
|
if not plan_is_valid:
|
|
2876
3063
|
reasons.append("execution.jsonl has no valid plan record")
|
|
2877
3064
|
if tdd_enabled and not is_read_only_task:
|
|
3065
|
+
readiness = tdd_readiness(root)
|
|
3066
|
+
if readiness["status"] != "ready":
|
|
3067
|
+
reasons.append(
|
|
3068
|
+
"TDD infrastructure is not ready; run ec-tdd-init first: "
|
|
3069
|
+
+ "; ".join(str(reason) for reason in readiness["reasons"])
|
|
3070
|
+
)
|
|
2878
3071
|
plan = latest_execution_plan(root, task_id) or {}
|
|
2879
3072
|
if re.search(
|
|
2880
3073
|
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
@@ -2902,7 +3095,13 @@ def validate_analysis_readiness(
|
|
|
2902
3095
|
strategy_content = test_strategy.read_text(encoding="utf-8")
|
|
2903
3096
|
except OSError:
|
|
2904
3097
|
strategy_content = ""
|
|
2905
|
-
required_tdd_markers = [
|
|
3098
|
+
required_tdd_markers = [
|
|
3099
|
+
"TDD",
|
|
3100
|
+
"JaCoCo",
|
|
3101
|
+
"baseline",
|
|
3102
|
+
"local_test_gate: required",
|
|
3103
|
+
"remote_ci_acceptance: non-blocking",
|
|
3104
|
+
]
|
|
2906
3105
|
missing_tdd_markers = [
|
|
2907
3106
|
marker for marker in required_tdd_markers if marker.lower() not in strategy_content.lower()
|
|
2908
3107
|
]
|
|
@@ -2924,6 +3123,32 @@ def validate_analysis_readiness(
|
|
|
2924
3123
|
dev_spec_content, strategy_content, baselines
|
|
2925
3124
|
)
|
|
2926
3125
|
)
|
|
3126
|
+
elif task_type == TDD_INIT_TASK_TYPE:
|
|
3127
|
+
try:
|
|
3128
|
+
strategy_content = test_strategy.read_text(encoding="utf-8")
|
|
3129
|
+
except OSError:
|
|
3130
|
+
strategy_content = ""
|
|
3131
|
+
required_init_markers = [
|
|
3132
|
+
"JaCoCo",
|
|
3133
|
+
"GitLab",
|
|
3134
|
+
"changed production lines",
|
|
3135
|
+
"historical coverage required: no",
|
|
3136
|
+
"easy_coding_tdd_readiness.py",
|
|
3137
|
+
]
|
|
3138
|
+
missing_init_markers = [
|
|
3139
|
+
marker
|
|
3140
|
+
for marker in required_init_markers
|
|
3141
|
+
if marker.lower() not in strategy_content.lower()
|
|
3142
|
+
]
|
|
3143
|
+
if missing_init_markers:
|
|
3144
|
+
reasons.append(
|
|
3145
|
+
"TDD initialization strategy is missing: "
|
|
3146
|
+
+ ", ".join(missing_init_markers)
|
|
3147
|
+
)
|
|
3148
|
+
if re.search(
|
|
3149
|
+
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
3150
|
+
):
|
|
3151
|
+
reasons.append("tdd-init must keep TDD off and omit the TDD Mode section")
|
|
2927
3152
|
elif not is_read_only_task:
|
|
2928
3153
|
if re.search(
|
|
2929
3154
|
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
@@ -3254,12 +3479,25 @@ def snapshot_state(
|
|
|
3254
3479
|
and status not in {"ANALYSIS", "INIT"}
|
|
3255
3480
|
and isinstance(task_tdd_enabled, bool)
|
|
3256
3481
|
)
|
|
3257
|
-
|
|
3482
|
+
is_tdd_init = bool(
|
|
3483
|
+
task and str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE
|
|
3484
|
+
)
|
|
3485
|
+
displayed_tdd_enabled = (
|
|
3486
|
+
False if is_tdd_init else task_tdd_enabled if frozen_tdd else effective_tdd_enabled
|
|
3487
|
+
)
|
|
3258
3488
|
displayed_tdd_threshold = (
|
|
3259
3489
|
task_tdd_coverage_threshold
|
|
3260
3490
|
if frozen_tdd and isinstance(task_tdd_coverage_threshold, int)
|
|
3261
3491
|
else effective_tdd_coverage_threshold
|
|
3262
3492
|
)
|
|
3493
|
+
should_check_readiness = bool(
|
|
3494
|
+
effective_tdd_enabled or task_tdd_enabled is True or is_tdd_init
|
|
3495
|
+
)
|
|
3496
|
+
readiness = (
|
|
3497
|
+
tdd_readiness(root)
|
|
3498
|
+
if should_check_readiness
|
|
3499
|
+
else {"status": "not_checked", "reasons": []}
|
|
3500
|
+
)
|
|
3263
3501
|
|
|
3264
3502
|
return {
|
|
3265
3503
|
"session_file": display_path(root, session_path),
|
|
@@ -3291,6 +3529,8 @@ def snapshot_state(
|
|
|
3291
3529
|
"task_tdd_baselines": task.get("tdd_baselines") if task else None,
|
|
3292
3530
|
"displayed_tdd_enabled": displayed_tdd_enabled,
|
|
3293
3531
|
"displayed_tdd_coverage_threshold": displayed_tdd_threshold,
|
|
3532
|
+
"tdd_readiness_status": readiness["status"],
|
|
3533
|
+
"tdd_readiness_reasons": readiness["reasons"],
|
|
3294
3534
|
"spec_summary": spec_task_summary(task),
|
|
3295
3535
|
# Compatibility output aliases for pre-0.9 clients.
|
|
3296
3536
|
"project_confirm_mode": project_approval_mode,
|
|
@@ -3674,6 +3914,8 @@ def set_session_tdd(
|
|
|
3674
3914
|
threshold: int | None = None,
|
|
3675
3915
|
session_file: str | Path | None = None,
|
|
3676
3916
|
) -> dict:
|
|
3917
|
+
if enabled:
|
|
3918
|
+
require_tdd_readiness(root)
|
|
3677
3919
|
session = ensure_session(root, session_file)
|
|
3678
3920
|
materialize_legacy_session_behavior(session)
|
|
3679
3921
|
session["tdd_enabled"] = enabled
|
|
@@ -4139,9 +4381,14 @@ def freeze_tdd_mode(
|
|
|
4139
4381
|
) -> None:
|
|
4140
4382
|
behavior = resolve_behavior(root, session)
|
|
4141
4383
|
task_type = str(task.get("type") or "").strip().lower()
|
|
4142
|
-
task["tdd_enabled"] =
|
|
4384
|
+
task["tdd_enabled"] = (
|
|
4385
|
+
behavior[8]
|
|
4386
|
+
if task_type not in NO_CODE_TASK_TYPES | {TDD_INIT_TASK_TYPE}
|
|
4387
|
+
else False
|
|
4388
|
+
)
|
|
4143
4389
|
task["tdd_coverage_threshold"] = behavior[11]
|
|
4144
4390
|
if task["tdd_enabled"] is True:
|
|
4391
|
+
require_tdd_readiness(root)
|
|
4145
4392
|
plan = latest_execution_plan(root, task_id)
|
|
4146
4393
|
if plan is None:
|
|
4147
4394
|
raise StateError("Cannot freeze TDD baseline without a valid execution plan.")
|