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.
@@ -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 = parse_yaml_bool(behavior.get("tdd_enabled"), "behavior.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
- displayed_tdd_enabled = task_tdd_enabled if frozen_tdd else effective_tdd_enabled
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"] = behavior[8] if task_type not in NO_CODE_TASK_TYPES else False
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.")