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.
@@ -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]:
@@ -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
- coverage_scopes_by_owner: dict[str, set[str]] = {}
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 not in {"local", "gitlab"}:
2662
+ if scope != "local":
2446
2663
  raise StateError(
2447
- "TDD coverage evidence must identify coverage_scope as local or gitlab."
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 = ["TDD", "JaCoCo", "baseline", "GitLab"]
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
- displayed_tdd_enabled = task_tdd_enabled if frozen_tdd else effective_tdd_enabled
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"] = behavior[8] if task_type not in NO_CODE_TASK_TYPES else False
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.")