easy-coding-harness 0.10.0-beta.0 → 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 +31 -0
- package/README.md +9 -3
- package/dist/cli.js +331 -46
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +8 -0
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +9 -2
- package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +1 -1
- package/templates/common/skills/ec-analysis/SKILL.md +38 -0
- package/templates/common/skills/ec-config/SKILL.md +68 -0
- package/templates/common/skills/ec-implementing/SKILL.md +11 -0
- package/templates/common/skills/ec-memory/SKILL.md +4 -0
- package/templates/common/skills/ec-reviewing/SKILL.md +6 -0
- package/templates/common/skills/ec-task-management/SKILL.md +6 -31
- package/templates/common/skills/ec-tdd-init/SKILL.md +99 -0
- package/templates/common/skills/ec-verification/SKILL.md +53 -1
- package/templates/common/skills/ec-workflow/SKILL.md +16 -4
- package/templates/main-constraint/AGENTS.md.tpl +13 -3
- package/templates/main-constraint/CLAUDE.md.tpl +13 -3
- package/templates/runtime/tools/easy_coding_java_coverage.py +317 -0
- package/templates/runtime/tools/easy_coding_tdd_readiness.py +306 -0
- package/templates/shared-hooks/easy_coding_state.py +846 -13
- package/templates/shared-hooks/inject-subagent-context.py +5 -0
|
@@ -5,6 +5,7 @@ import json
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
7
|
import secrets
|
|
8
|
+
import shlex
|
|
8
9
|
import subprocess
|
|
9
10
|
import time
|
|
10
11
|
import uuid
|
|
@@ -24,7 +25,8 @@ from easy_dev_spec import (
|
|
|
24
25
|
TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
|
|
25
26
|
HELP_SUFFIX = (
|
|
26
27
|
"Use `ec-workflow` to start or resume a task, "
|
|
27
|
-
"`ec-brainstorming` to brainstorm,
|
|
28
|
+
"`ec-brainstorming` to brainstorm, `ec-task-management` to manage tasks, "
|
|
29
|
+
"or `ec-config` to inspect or change modes"
|
|
28
30
|
)
|
|
29
31
|
READY_LINE = f"Ready · {HELP_SUFFIX}"
|
|
30
32
|
WAITING_INIT_LINE = "Waiting init · Use `ec-init` to initialize"
|
|
@@ -64,6 +66,7 @@ ALWAYS_AUTO_TRANSITIONS = {
|
|
|
64
66
|
}
|
|
65
67
|
READ_ONLY_COMPLETION_TRANSITION = ("IMPLEMENT", "COMPLETE")
|
|
66
68
|
NO_CODE_TASK_TYPES = {"analysis", "doc", "report"}
|
|
69
|
+
TDD_INIT_TASK_TYPE = "tdd-init"
|
|
67
70
|
APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
|
|
68
71
|
CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
|
|
69
72
|
WORKFLOW_MODES = {"fast", "standard", "strict"}
|
|
@@ -78,6 +81,16 @@ STRICT_WORKFLOW_RISK_PATTERN = re.compile(
|
|
|
78
81
|
)
|
|
79
82
|
DEFAULT_APPROVAL_MODE = "guard"
|
|
80
83
|
DEFAULT_WORKFLOW_MODE = "adaptive"
|
|
84
|
+
DEFAULT_TDD_ENABLED = False
|
|
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"}
|
|
81
94
|
CRITICAL_CONFIRM_TRANSITIONS = {
|
|
82
95
|
("ANALYSIS", "IMPLEMENT"),
|
|
83
96
|
("VERIFICATION", "MEMORY"),
|
|
@@ -310,16 +323,45 @@ def read_memory_config(root: Path) -> dict[str, int]:
|
|
|
310
323
|
return config
|
|
311
324
|
|
|
312
325
|
|
|
313
|
-
def
|
|
326
|
+
def parse_tdd_threshold(value: object, source: str) -> int:
|
|
327
|
+
if isinstance(value, bool):
|
|
328
|
+
raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
|
|
329
|
+
try:
|
|
330
|
+
threshold = int(str(value))
|
|
331
|
+
except (TypeError, ValueError) as error:
|
|
332
|
+
raise StateError(f"Invalid {source}: expected an integer from 1 to 100.") from error
|
|
333
|
+
if threshold < 1 or threshold > 100:
|
|
334
|
+
raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
|
|
335
|
+
return threshold
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def parse_yaml_bool(value: str | None, source: str) -> bool:
|
|
339
|
+
if value is None:
|
|
340
|
+
return DEFAULT_TDD_ENABLED
|
|
341
|
+
normalized = value.lower()
|
|
342
|
+
if normalized in {"true", "yes", "on"}:
|
|
343
|
+
return True
|
|
344
|
+
if normalized in {"false", "no", "off"}:
|
|
345
|
+
return False
|
|
346
|
+
raise StateError(f"Invalid {source}: expected true or false.")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
|
|
314
350
|
path = root / ".easy-coding" / "config.yaml"
|
|
315
351
|
try:
|
|
316
352
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
317
353
|
except OSError:
|
|
318
|
-
return
|
|
354
|
+
return (
|
|
355
|
+
DEFAULT_APPROVAL_MODE,
|
|
356
|
+
DEFAULT_WORKFLOW_MODE,
|
|
357
|
+
DEFAULT_TDD_ENABLED,
|
|
358
|
+
DEFAULT_TDD_COVERAGE_THRESHOLD,
|
|
359
|
+
)
|
|
319
360
|
|
|
320
361
|
in_behavior = False
|
|
321
362
|
behavior_indent = 0
|
|
322
363
|
behavior: dict[str, str] = {}
|
|
364
|
+
schema_version = 0
|
|
323
365
|
for raw_line in lines:
|
|
324
366
|
without_comment = raw_line.split("#", 1)[0].rstrip()
|
|
325
367
|
stripped = without_comment.strip()
|
|
@@ -332,6 +374,12 @@ def read_project_behavior(root: Path) -> tuple[str, str]:
|
|
|
332
374
|
continue
|
|
333
375
|
if in_behavior and indent <= behavior_indent:
|
|
334
376
|
in_behavior = False
|
|
377
|
+
if not in_behavior and indent == 0 and stripped.startswith("version:"):
|
|
378
|
+
try:
|
|
379
|
+
schema_version = int(stripped.split(":", 1)[1].strip().strip("'\""))
|
|
380
|
+
except ValueError:
|
|
381
|
+
schema_version = 0
|
|
382
|
+
continue
|
|
335
383
|
if not in_behavior or ":" not in stripped:
|
|
336
384
|
continue
|
|
337
385
|
key, value = stripped.split(":", 1)
|
|
@@ -359,16 +407,200 @@ def read_project_behavior(root: Path) -> tuple[str, str]:
|
|
|
359
407
|
"Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
|
|
360
408
|
"expected adaptive, fast, standard, or strict."
|
|
361
409
|
)
|
|
362
|
-
|
|
410
|
+
if schema_version >= 4:
|
|
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
|
+
)
|
|
416
|
+
tdd_threshold = parse_tdd_threshold(
|
|
417
|
+
behavior.get("tdd_coverage_threshold", DEFAULT_TDD_COVERAGE_THRESHOLD),
|
|
418
|
+
"behavior.tdd_coverage_threshold",
|
|
419
|
+
)
|
|
420
|
+
else:
|
|
421
|
+
tdd_enabled = DEFAULT_TDD_ENABLED
|
|
422
|
+
tdd_threshold = DEFAULT_TDD_COVERAGE_THRESHOLD
|
|
423
|
+
return approval_mode, workflow_mode, tdd_enabled, tdd_threshold
|
|
424
|
+
|
|
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}")
|
|
363
593
|
|
|
364
594
|
|
|
365
595
|
def resolve_behavior(
|
|
366
596
|
root: Path, session: dict
|
|
367
|
-
) -> tuple[str, str | None, str, str, str | None, str]:
|
|
368
|
-
project_approval, project_workflow = read_project_behavior(root)
|
|
597
|
+
) -> tuple[str, str | None, str, str, str | None, str, bool, bool | None, bool, int, int | None, int]:
|
|
598
|
+
project_approval, project_workflow, project_tdd, project_threshold = read_project_behavior(root)
|
|
369
599
|
legacy = session.get("confirm_mode")
|
|
370
600
|
session_approval = session.get("approval_mode")
|
|
371
601
|
session_workflow = session.get("workflow_mode")
|
|
602
|
+
session_tdd = session.get("tdd_enabled")
|
|
603
|
+
session_threshold = session.get("tdd_coverage_threshold")
|
|
372
604
|
if session_approval is None:
|
|
373
605
|
if legacy == "lite":
|
|
374
606
|
session_approval = "guard"
|
|
@@ -387,6 +619,12 @@ def resolve_behavior(
|
|
|
387
619
|
raise StateError(
|
|
388
620
|
"Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
|
|
389
621
|
)
|
|
622
|
+
if session_tdd is not None and not isinstance(session_tdd, bool):
|
|
623
|
+
raise StateError("Invalid session tdd_enabled: expected true or false.")
|
|
624
|
+
if session_threshold is not None:
|
|
625
|
+
session_threshold = parse_tdd_threshold(
|
|
626
|
+
session_threshold, "session tdd_coverage_threshold"
|
|
627
|
+
)
|
|
390
628
|
return (
|
|
391
629
|
project_approval,
|
|
392
630
|
str(session_approval) if session_approval else None,
|
|
@@ -394,6 +632,12 @@ def resolve_behavior(
|
|
|
394
632
|
project_workflow,
|
|
395
633
|
str(session_workflow) if session_workflow else None,
|
|
396
634
|
str(session_workflow or project_workflow),
|
|
635
|
+
project_tdd,
|
|
636
|
+
session_tdd,
|
|
637
|
+
session_tdd if session_tdd is not None else project_tdd,
|
|
638
|
+
project_threshold,
|
|
639
|
+
session_threshold,
|
|
640
|
+
session_threshold if session_threshold is not None else project_threshold,
|
|
397
641
|
)
|
|
398
642
|
|
|
399
643
|
|
|
@@ -1624,6 +1868,77 @@ def task_repository_roots(root: Path, task: dict | None, plan: dict) -> list[Pat
|
|
|
1624
1868
|
]
|
|
1625
1869
|
|
|
1626
1870
|
|
|
1871
|
+
def tdd_repositories(root: Path, task: dict, plan: dict) -> dict[str, Path]:
|
|
1872
|
+
if isinstance(task.get("spec_source"), dict):
|
|
1873
|
+
repo_paths = task.get("repo_paths")
|
|
1874
|
+
if not isinstance(repo_paths, dict):
|
|
1875
|
+
raise StateError("TDD Canonical task is missing repository bindings.")
|
|
1876
|
+
repositories: dict[str, Path] = {}
|
|
1877
|
+
for unit in plan.get("units", []):
|
|
1878
|
+
if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
|
|
1879
|
+
raise StateError("TDD Canonical unit is missing repo_id.")
|
|
1880
|
+
repo_id = str(unit["repo_id"])
|
|
1881
|
+
raw_path = repo_paths.get(repo_id)
|
|
1882
|
+
if not is_non_empty_string(raw_path):
|
|
1883
|
+
raise StateError(f"TDD repository path is missing: {repo_id}")
|
|
1884
|
+
candidate = Path(str(raw_path))
|
|
1885
|
+
resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
1886
|
+
repository = git_repository_root(resolved)
|
|
1887
|
+
if repository is None or repository.resolve() != resolved:
|
|
1888
|
+
raise StateError(f"TDD repository binding is not a Git root: {repo_id}")
|
|
1889
|
+
repositories[repo_id] = repository
|
|
1890
|
+
return repositories
|
|
1891
|
+
|
|
1892
|
+
repositories = task_repository_roots(root, task, plan)
|
|
1893
|
+
if len(repositories) != 1:
|
|
1894
|
+
raise StateError(
|
|
1895
|
+
"Non-Canonical TDD requires exactly one Git repository; use a Canonical Spec for multi-repository work."
|
|
1896
|
+
)
|
|
1897
|
+
return {"project": repositories[0]}
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def git_head_sha(repository: Path) -> str:
|
|
1901
|
+
result = run_git(repository, "rev-parse", "--verify", "HEAD")
|
|
1902
|
+
sha = result.stdout.decode("ascii", errors="ignore").strip() if result else ""
|
|
1903
|
+
if (
|
|
1904
|
+
result is None
|
|
1905
|
+
or result.returncode != 0
|
|
1906
|
+
or re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha) is None
|
|
1907
|
+
):
|
|
1908
|
+
raise StateError(f"Cannot freeze TDD Git baseline for {repository.name}.")
|
|
1909
|
+
return sha
|
|
1910
|
+
|
|
1911
|
+
|
|
1912
|
+
def tdd_baseline_marker_reasons(
|
|
1913
|
+
dev_spec_content: str, strategy_content: str, baselines: dict[str, str]
|
|
1914
|
+
) -> list[str]:
|
|
1915
|
+
reasons: list[str] = []
|
|
1916
|
+
canonical = set(baselines) != {"project"}
|
|
1917
|
+
for repo_id, baseline in sorted(baselines.items()):
|
|
1918
|
+
for artifact_name, content in (
|
|
1919
|
+
("dev-spec.md", dev_spec_content),
|
|
1920
|
+
("test-strategy.md", strategy_content),
|
|
1921
|
+
):
|
|
1922
|
+
if baseline not in content:
|
|
1923
|
+
reasons.append(
|
|
1924
|
+
f"{artifact_name} must record the immutable TDD baseline SHA for {repo_id}: {baseline}"
|
|
1925
|
+
)
|
|
1926
|
+
if canonical and repo_id not in content:
|
|
1927
|
+
reasons.append(
|
|
1928
|
+
f"{artifact_name} must map the TDD baseline to repository {repo_id}"
|
|
1929
|
+
)
|
|
1930
|
+
return reasons
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
def contains_tdd_threshold(content: str, threshold: int) -> bool:
|
|
1934
|
+
return re.search(
|
|
1935
|
+
rf"(?<!\d){threshold}\s*%|--threshold(?:\s+|=){threshold}(?!\d)|"
|
|
1936
|
+
rf"tdd_coverage_threshold\s*[:=]\s*{threshold}(?!\d)",
|
|
1937
|
+
content,
|
|
1938
|
+
re.IGNORECASE,
|
|
1939
|
+
) is not None
|
|
1940
|
+
|
|
1941
|
+
|
|
1627
1942
|
def repository_scope_pathspecs(repository: Path, scopes: list[Path]) -> list[str]:
|
|
1628
1943
|
return [
|
|
1629
1944
|
f":(literal){scope.relative_to(repository).as_posix()}"
|
|
@@ -1837,6 +2152,18 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
|
|
|
1837
2152
|
digest.update(b"workflow-mode\0")
|
|
1838
2153
|
digest.update(workflow_mode.encode("utf-8"))
|
|
1839
2154
|
digest.update(b"\0")
|
|
2155
|
+
if task and task.get("tdd_enabled") is True:
|
|
2156
|
+
digest.update(b"tdd\0enabled\0")
|
|
2157
|
+
digest.update(str(task.get("tdd_coverage_threshold") or "").encode("utf-8"))
|
|
2158
|
+
digest.update(b"\0")
|
|
2159
|
+
digest.update(
|
|
2160
|
+
json.dumps(
|
|
2161
|
+
task.get("tdd_baselines") or {},
|
|
2162
|
+
sort_keys=True,
|
|
2163
|
+
separators=(",", ":"),
|
|
2164
|
+
).encode("utf-8")
|
|
2165
|
+
)
|
|
2166
|
+
digest.update(b"\0")
|
|
1840
2167
|
digest.update(b"execution-plan\0")
|
|
1841
2168
|
digest.update(
|
|
1842
2169
|
json.dumps(
|
|
@@ -1901,23 +2228,93 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
|
|
|
1901
2228
|
return digest.hexdigest()
|
|
1902
2229
|
|
|
1903
2230
|
|
|
1904
|
-
def
|
|
2231
|
+
def config_without_frozen_tdd_settings(payload: bytes) -> bytes:
|
|
2232
|
+
"""任务冻结 TDD 契约后,从证据指纹中排除仅影响未来任务的实时 TDD 配置。"""
|
|
2233
|
+
try:
|
|
2234
|
+
lines = payload.decode("utf-8").splitlines(keepends=True)
|
|
2235
|
+
except UnicodeDecodeError:
|
|
2236
|
+
return payload
|
|
2237
|
+
filtered: list[str] = []
|
|
2238
|
+
in_behavior = False
|
|
2239
|
+
behavior_indent = 0
|
|
2240
|
+
behavior_key_indent: int | None = None
|
|
2241
|
+
for line in lines:
|
|
2242
|
+
clean = line.split("#", 1)[0].rstrip()
|
|
2243
|
+
stripped = clean.strip()
|
|
2244
|
+
indent = len(clean) - len(clean.lstrip(" "))
|
|
2245
|
+
if stripped == "behavior:":
|
|
2246
|
+
in_behavior = True
|
|
2247
|
+
behavior_indent = indent
|
|
2248
|
+
behavior_key_indent = None
|
|
2249
|
+
filtered.append(line)
|
|
2250
|
+
continue
|
|
2251
|
+
if in_behavior and stripped and indent <= behavior_indent:
|
|
2252
|
+
in_behavior = False
|
|
2253
|
+
if in_behavior and stripped:
|
|
2254
|
+
if behavior_key_indent is None:
|
|
2255
|
+
behavior_key_indent = indent
|
|
2256
|
+
key = stripped.split(":", 1)[0]
|
|
2257
|
+
if (
|
|
2258
|
+
indent == behavior_key_indent
|
|
2259
|
+
and key in {"tdd_enabled", "tdd_coverage_threshold"}
|
|
2260
|
+
):
|
|
2261
|
+
continue
|
|
2262
|
+
filtered.append(line)
|
|
2263
|
+
return "".join(filtered).encode("utf-8")
|
|
2264
|
+
|
|
2265
|
+
|
|
2266
|
+
def behavior_config_fingerprint(root: Path, task: dict | None = None) -> str:
|
|
1905
2267
|
path = root / ".easy-coding" / "config.yaml"
|
|
1906
2268
|
digest = hashlib.sha256()
|
|
1907
2269
|
try:
|
|
1908
|
-
|
|
2270
|
+
payload = path.read_bytes()
|
|
2271
|
+
if task and isinstance(task.get("tdd_enabled"), bool):
|
|
2272
|
+
payload = config_without_frozen_tdd_settings(payload)
|
|
2273
|
+
digest.update(payload)
|
|
1909
2274
|
except OSError:
|
|
1910
2275
|
digest.update(b"<missing-config>")
|
|
1911
2276
|
return digest.hexdigest()
|
|
1912
2277
|
|
|
1913
2278
|
|
|
1914
2279
|
def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
|
|
2280
|
+
task = load_task(root, task_id)
|
|
1915
2281
|
return {
|
|
1916
2282
|
"implementation_fingerprint": implementation_fingerprint(root, task_id),
|
|
1917
|
-
"config_fingerprint": behavior_config_fingerprint(root),
|
|
2283
|
+
"config_fingerprint": behavior_config_fingerprint(root, task),
|
|
1918
2284
|
}
|
|
1919
2285
|
|
|
1920
2286
|
|
|
2287
|
+
def command_option_value(command: str, option: str) -> str | None:
|
|
2288
|
+
try:
|
|
2289
|
+
tokens = shlex.split(command)
|
|
2290
|
+
except ValueError:
|
|
2291
|
+
return None
|
|
2292
|
+
for index, token in enumerate(tokens):
|
|
2293
|
+
if token == option and index + 1 < len(tokens):
|
|
2294
|
+
return tokens[index + 1]
|
|
2295
|
+
prefix = f"{option}="
|
|
2296
|
+
if token.startswith(prefix):
|
|
2297
|
+
return token[len(prefix) :]
|
|
2298
|
+
return None
|
|
2299
|
+
|
|
2300
|
+
|
|
2301
|
+
def coverage_command_matches_frozen_contract(
|
|
2302
|
+
command: object, baseline: str, threshold: int
|
|
2303
|
+
) -> bool:
|
|
2304
|
+
if not is_non_empty_string(command):
|
|
2305
|
+
return False
|
|
2306
|
+
try:
|
|
2307
|
+
tokens = shlex.split(str(command))
|
|
2308
|
+
except ValueError:
|
|
2309
|
+
return False
|
|
2310
|
+
return (
|
|
2311
|
+
any(Path(token).name == "easy_coding_java_coverage.py" for token in tokens)
|
|
2312
|
+
and "check" in tokens
|
|
2313
|
+
and command_option_value(str(command), "--base") == baseline
|
|
2314
|
+
and command_option_value(str(command), "--threshold") == str(threshold)
|
|
2315
|
+
)
|
|
2316
|
+
|
|
2317
|
+
|
|
1921
2318
|
def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -> None:
|
|
1922
2319
|
if not isinstance(task.get("spec_source"), dict):
|
|
1923
2320
|
return
|
|
@@ -2074,6 +2471,25 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
|
2074
2471
|
raise StateError(
|
|
2075
2472
|
"REVIEW cannot advance to VERIFICATION while a current review dimension is not passed or has error findings."
|
|
2076
2473
|
)
|
|
2474
|
+
if task.get("tdd_enabled") is True:
|
|
2475
|
+
if is_spec_task:
|
|
2476
|
+
missing_tdd_reviews = sorted(
|
|
2477
|
+
source_task_id
|
|
2478
|
+
for source_task_id, dimensions in reviewed_dimensions.items()
|
|
2479
|
+
if "tdd" not in {dimension.lower() for dimension in dimensions}
|
|
2480
|
+
)
|
|
2481
|
+
if missing_tdd_reviews:
|
|
2482
|
+
raise StateError(
|
|
2483
|
+
"TDD tasks require a passed TDD review dimension for every selected source task: "
|
|
2484
|
+
+ ", ".join(missing_tdd_reviews)
|
|
2485
|
+
)
|
|
2486
|
+
elif not any(
|
|
2487
|
+
str(record.get("dimension") or "").lower() == "tdd"
|
|
2488
|
+
for record in latest_by_dimension.values()
|
|
2489
|
+
):
|
|
2490
|
+
raise StateError(
|
|
2491
|
+
"TDD tasks require a passed TDD review dimension for test quality, boundaries, and mocking."
|
|
2492
|
+
)
|
|
2077
2493
|
if task.get("workflow_mode") == "strict":
|
|
2078
2494
|
if is_spec_task:
|
|
2079
2495
|
missing_strict_dimensions = sorted(
|
|
@@ -2111,6 +2527,8 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2111
2527
|
and is_non_empty_string(record.get("check"))
|
|
2112
2528
|
):
|
|
2113
2529
|
check = str(record["check"])
|
|
2530
|
+
if task.get("tdd_enabled") is True and record.get("check_type") == "coverage":
|
|
2531
|
+
check = f"{check}\0{record.get('coverage_scope') or ''}"
|
|
2114
2532
|
if is_spec_task:
|
|
2115
2533
|
check = f"{check}\0{record.get('source_task_id') or ''}"
|
|
2116
2534
|
previous = latest_by_check.get(check)
|
|
@@ -2129,7 +2547,7 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2129
2547
|
for record in latest_by_check.values():
|
|
2130
2548
|
check_type = str(record.get("check_type") or "")
|
|
2131
2549
|
if (
|
|
2132
|
-
check_type not in STRICT_VERIFICATION_CHECK_TYPES
|
|
2550
|
+
check_type not in STRICT_VERIFICATION_CHECK_TYPES | {"coverage"}
|
|
2133
2551
|
or not is_non_empty_string(record.get("timestamp"))
|
|
2134
2552
|
or (
|
|
2135
2553
|
record.get("applicable") is not False
|
|
@@ -2175,6 +2593,147 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2175
2593
|
raise StateError(
|
|
2176
2594
|
"VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
|
|
2177
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
|
+
)
|
|
2603
|
+
if task.get("tdd_enabled") is not True and any(
|
|
2604
|
+
record.get("check_type") == "coverage" for record in latest_by_check.values()
|
|
2605
|
+
):
|
|
2606
|
+
raise StateError(
|
|
2607
|
+
"Coverage verification evidence is not allowed when the frozen TDD mode is off."
|
|
2608
|
+
)
|
|
2609
|
+
if task.get("tdd_enabled") is True:
|
|
2610
|
+
require_tdd_readiness(root)
|
|
2611
|
+
coverage_records = [
|
|
2612
|
+
record
|
|
2613
|
+
for record in latest_by_check.values()
|
|
2614
|
+
if record.get("check_type") == "coverage"
|
|
2615
|
+
]
|
|
2616
|
+
if not coverage_records:
|
|
2617
|
+
raise StateError(
|
|
2618
|
+
"TDD verification requires changed-production-line JaCoCo coverage evidence."
|
|
2619
|
+
)
|
|
2620
|
+
if is_spec_task:
|
|
2621
|
+
covered_source_tasks = {
|
|
2622
|
+
str(record.get("source_task_id") or "") for record in coverage_records
|
|
2623
|
+
}
|
|
2624
|
+
missing_coverage_tasks = sorted(
|
|
2625
|
+
set(task_repositories) - covered_source_tasks
|
|
2626
|
+
)
|
|
2627
|
+
if missing_coverage_tasks:
|
|
2628
|
+
raise StateError(
|
|
2629
|
+
"TDD Canonical verification requires separate coverage evidence for every selected source task: "
|
|
2630
|
+
+ ", ".join(missing_coverage_tasks)
|
|
2631
|
+
)
|
|
2632
|
+
coverage_scopes_by_owner: dict[str, set[str]] = {}
|
|
2633
|
+
for record in coverage_records:
|
|
2634
|
+
scope = str(record.get("coverage_scope") or "")
|
|
2635
|
+
if scope not in {"local", "gitlab"}:
|
|
2636
|
+
raise StateError(
|
|
2637
|
+
"TDD coverage evidence must identify coverage_scope as local or gitlab."
|
|
2638
|
+
)
|
|
2639
|
+
owner = (
|
|
2640
|
+
str(record.get("source_task_id") or "")
|
|
2641
|
+
if is_spec_task
|
|
2642
|
+
else "project"
|
|
2643
|
+
)
|
|
2644
|
+
coverage_scopes_by_owner.setdefault(owner, set()).add(scope)
|
|
2645
|
+
expected_threshold = task.get("tdd_coverage_threshold")
|
|
2646
|
+
expected_baselines = task.get("tdd_baselines")
|
|
2647
|
+
if (
|
|
2648
|
+
type(expected_threshold) is not int
|
|
2649
|
+
or expected_threshold < 1
|
|
2650
|
+
or expected_threshold > 100
|
|
2651
|
+
):
|
|
2652
|
+
raise StateError("TDD task is missing a valid frozen coverage threshold.")
|
|
2653
|
+
if not isinstance(expected_baselines, dict) or not expected_baselines:
|
|
2654
|
+
raise StateError("TDD task is missing frozen Git baselines.")
|
|
2655
|
+
for record in coverage_records:
|
|
2656
|
+
coverage = record.get("coverage")
|
|
2657
|
+
if not isinstance(coverage, dict):
|
|
2658
|
+
raise StateError("TDD coverage evidence must include the coverage result object.")
|
|
2659
|
+
if record.get("coverage_scope") == "gitlab":
|
|
2660
|
+
ci = record.get("ci")
|
|
2661
|
+
if (
|
|
2662
|
+
not isinstance(ci, dict)
|
|
2663
|
+
or ci.get("provider") != "gitlab"
|
|
2664
|
+
or ci.get("status") != "success"
|
|
2665
|
+
or not is_non_empty_string(ci.get("pipeline_url"))
|
|
2666
|
+
or not is_non_empty_string(ci.get("job_name"))
|
|
2667
|
+
):
|
|
2668
|
+
raise StateError(
|
|
2669
|
+
"GitLab coverage evidence requires a successful pipeline URL and job name."
|
|
2670
|
+
)
|
|
2671
|
+
total = coverage.get("total_lines")
|
|
2672
|
+
covered = coverage.get("covered_lines")
|
|
2673
|
+
percentage = coverage.get("percentage")
|
|
2674
|
+
threshold = coverage.get("threshold")
|
|
2675
|
+
baseline_key = str(record.get("repo_id") or "") if is_spec_task else "project"
|
|
2676
|
+
expected_baseline = expected_baselines.get(baseline_key)
|
|
2677
|
+
if (
|
|
2678
|
+
not is_non_empty_string(expected_baseline)
|
|
2679
|
+
or coverage.get("baseline_sha") != expected_baseline
|
|
2680
|
+
or re.fullmatch(
|
|
2681
|
+
r"[0-9a-f]{40}|[0-9a-f]{64}", str(coverage.get("baseline_sha") or "")
|
|
2682
|
+
)
|
|
2683
|
+
is None
|
|
2684
|
+
or not isinstance(total, int)
|
|
2685
|
+
or not isinstance(covered, int)
|
|
2686
|
+
or not isinstance(percentage, (int, float))
|
|
2687
|
+
or threshold != expected_threshold
|
|
2688
|
+
or not isinstance(coverage.get("report_paths"), list)
|
|
2689
|
+
or not coverage.get("report_paths")
|
|
2690
|
+
or not all(
|
|
2691
|
+
is_non_empty_string(path) for path in coverage.get("report_paths", [])
|
|
2692
|
+
)
|
|
2693
|
+
or not re.fullmatch(
|
|
2694
|
+
r"[0-9a-f]{64}", str(coverage.get("report_sha256") or "")
|
|
2695
|
+
)
|
|
2696
|
+
or covered < 0
|
|
2697
|
+
or total < 0
|
|
2698
|
+
or covered > total
|
|
2699
|
+
or percentage < 0
|
|
2700
|
+
or percentage > 100
|
|
2701
|
+
or not coverage_command_matches_frozen_contract(
|
|
2702
|
+
record.get("command"), str(expected_baseline), int(expected_threshold)
|
|
2703
|
+
)
|
|
2704
|
+
):
|
|
2705
|
+
raise StateError(
|
|
2706
|
+
"TDD coverage evidence must preserve the exact gate command, baseline, counts, percentage, frozen threshold, reports, and report fingerprint."
|
|
2707
|
+
)
|
|
2708
|
+
if total == 0:
|
|
2709
|
+
if record.get("applicable") is not False or record.get("passed") is not True:
|
|
2710
|
+
raise StateError(
|
|
2711
|
+
"Coverage with no modified executable production Java lines must be explicit N/A."
|
|
2712
|
+
)
|
|
2713
|
+
elif abs(percentage - round(covered * 100.0 / total, 2)) > 0.01:
|
|
2714
|
+
raise StateError(
|
|
2715
|
+
"TDD coverage evidence percentage does not match covered/total counts."
|
|
2716
|
+
)
|
|
2717
|
+
elif (
|
|
2718
|
+
record.get("applicable") is False
|
|
2719
|
+
or record.get("passed") is not True
|
|
2720
|
+
or percentage < threshold
|
|
2721
|
+
):
|
|
2722
|
+
raise StateError(
|
|
2723
|
+
f"TDD changed-line coverage must meet the frozen {threshold}% threshold."
|
|
2724
|
+
)
|
|
2725
|
+
expected_coverage_owners = set(task_repositories) if is_spec_task else {"project"}
|
|
2726
|
+
missing_scopes = [
|
|
2727
|
+
f"{owner}:{scope}"
|
|
2728
|
+
for owner in sorted(expected_coverage_owners)
|
|
2729
|
+
for scope in ("local", "gitlab")
|
|
2730
|
+
if scope not in coverage_scopes_by_owner.get(owner, set())
|
|
2731
|
+
]
|
|
2732
|
+
if missing_scopes:
|
|
2733
|
+
raise StateError(
|
|
2734
|
+
"TDD verification requires both local and successful GitLab coverage gates: "
|
|
2735
|
+
+ ", ".join(missing_scopes)
|
|
2736
|
+
)
|
|
2178
2737
|
if task.get("workflow_mode") == "strict":
|
|
2179
2738
|
if is_spec_task:
|
|
2180
2739
|
check_types_by_repository: dict[str, set[str]] = {
|
|
@@ -2445,7 +3004,9 @@ def validate_mandatory_dev_spec_sections(content: str) -> tuple[list[str], list[
|
|
|
2445
3004
|
return missing, empty
|
|
2446
3005
|
|
|
2447
3006
|
|
|
2448
|
-
def validate_analysis_readiness(
|
|
3007
|
+
def validate_analysis_readiness(
|
|
3008
|
+
root: Path, task_id: str, session: dict | None = None
|
|
3009
|
+
) -> None:
|
|
2449
3010
|
task_dir = task_json_path(root, task_id).parent
|
|
2450
3011
|
task = load_task(root, task_id)
|
|
2451
3012
|
task_type = str(task.get("type") or "").strip().lower() if task else ""
|
|
@@ -2454,6 +3015,9 @@ def validate_analysis_readiness(root: Path, task_id: str) -> None:
|
|
|
2454
3015
|
skeleton = root / ".easy-coding" / "templates" / "dev-spec-skeleton.md"
|
|
2455
3016
|
test_strategy = task_dir / "test-strategy.md"
|
|
2456
3017
|
reasons: list[str] = []
|
|
3018
|
+
behavior = resolve_behavior(root, session or default_session())
|
|
3019
|
+
tdd_enabled = behavior[8] if task_type != TDD_INIT_TASK_TYPE else False
|
|
3020
|
+
tdd_threshold = behavior[11]
|
|
2457
3021
|
|
|
2458
3022
|
dev_spec_content = ""
|
|
2459
3023
|
if not dev_spec.exists():
|
|
@@ -2500,6 +3064,113 @@ def validate_analysis_readiness(root: Path, task_id: str) -> None:
|
|
|
2500
3064
|
plan_is_valid = has_valid_execution_plan(root, task_id)
|
|
2501
3065
|
if not plan_is_valid:
|
|
2502
3066
|
reasons.append("execution.jsonl has no valid plan record")
|
|
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
|
+
)
|
|
3074
|
+
plan = latest_execution_plan(root, task_id) or {}
|
|
3075
|
+
if re.search(
|
|
3076
|
+
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
3077
|
+
) is None:
|
|
3078
|
+
reasons.append("dev-spec.md is missing the required TDD Mode section")
|
|
3079
|
+
planned_files = [
|
|
3080
|
+
str(file_name)
|
|
3081
|
+
for unit in plan.get("units", [])
|
|
3082
|
+
if isinstance(unit, dict)
|
|
3083
|
+
for file_name in unit.get("files", [])
|
|
3084
|
+
]
|
|
3085
|
+
if not any(file_name.endswith(".java") for file_name in planned_files):
|
|
3086
|
+
reasons.append("TDD is enabled but the confirmed implementation scope has no Java source")
|
|
3087
|
+
baselines: dict[str, str] = {}
|
|
3088
|
+
if task:
|
|
3089
|
+
try:
|
|
3090
|
+
repositories = tdd_repositories(root, task, plan)
|
|
3091
|
+
baselines = {
|
|
3092
|
+
repo_id: git_head_sha(repository)
|
|
3093
|
+
for repo_id, repository in repositories.items()
|
|
3094
|
+
}
|
|
3095
|
+
except StateError as error:
|
|
3096
|
+
reasons.append(str(error))
|
|
3097
|
+
try:
|
|
3098
|
+
strategy_content = test_strategy.read_text(encoding="utf-8")
|
|
3099
|
+
except OSError:
|
|
3100
|
+
strategy_content = ""
|
|
3101
|
+
required_tdd_markers = ["TDD", "JaCoCo", "baseline", "GitLab"]
|
|
3102
|
+
missing_tdd_markers = [
|
|
3103
|
+
marker for marker in required_tdd_markers if marker.lower() not in strategy_content.lower()
|
|
3104
|
+
]
|
|
3105
|
+
if missing_tdd_markers:
|
|
3106
|
+
reasons.append(
|
|
3107
|
+
"TDD test strategy is missing: " + ", ".join(missing_tdd_markers)
|
|
3108
|
+
)
|
|
3109
|
+
if not contains_tdd_threshold(strategy_content, tdd_threshold):
|
|
3110
|
+
reasons.append(
|
|
3111
|
+
f"TDD test strategy must state the frozen {tdd_threshold}% coverage threshold"
|
|
3112
|
+
)
|
|
3113
|
+
if not contains_tdd_threshold(dev_spec_content, tdd_threshold):
|
|
3114
|
+
reasons.append(
|
|
3115
|
+
f"TDD dev spec must state the frozen {tdd_threshold}% coverage threshold"
|
|
3116
|
+
)
|
|
3117
|
+
if baselines:
|
|
3118
|
+
reasons.extend(
|
|
3119
|
+
tdd_baseline_marker_reasons(
|
|
3120
|
+
dev_spec_content, strategy_content, baselines
|
|
3121
|
+
)
|
|
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")
|
|
3149
|
+
elif not is_read_only_task:
|
|
3150
|
+
if re.search(
|
|
3151
|
+
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
3152
|
+
):
|
|
3153
|
+
reasons.append("dev-spec.md must omit the TDD Mode section when TDD is disabled")
|
|
3154
|
+
try:
|
|
3155
|
+
strategy_content = test_strategy.read_text(encoding="utf-8")
|
|
3156
|
+
except OSError:
|
|
3157
|
+
strategy_content = ""
|
|
3158
|
+
forbidden_tdd_markers = [
|
|
3159
|
+
marker
|
|
3160
|
+
for marker in (
|
|
3161
|
+
"easy_coding_java_coverage.py",
|
|
3162
|
+
"coverage_scope",
|
|
3163
|
+
"task.tdd_baselines",
|
|
3164
|
+
"RED -> GREEN -> REFACTOR",
|
|
3165
|
+
"RED/GREEN/REFACTOR",
|
|
3166
|
+
)
|
|
3167
|
+
if marker.lower() in strategy_content.lower()
|
|
3168
|
+
]
|
|
3169
|
+
if forbidden_tdd_markers:
|
|
3170
|
+
reasons.append(
|
|
3171
|
+
"test-strategy.md contains TDD-only planning while TDD is disabled: "
|
|
3172
|
+
+ ", ".join(forbidden_tdd_markers)
|
|
3173
|
+
)
|
|
2503
3174
|
if task and isinstance(task.get("spec_source"), dict):
|
|
2504
3175
|
try:
|
|
2505
3176
|
inspection, selection = inspect_task_spec(root, task)
|
|
@@ -2785,6 +3456,12 @@ def snapshot_state(
|
|
|
2785
3456
|
project_workflow_mode,
|
|
2786
3457
|
session_workflow_mode,
|
|
2787
3458
|
configured_workflow_mode,
|
|
3459
|
+
project_tdd_enabled,
|
|
3460
|
+
session_tdd_enabled,
|
|
3461
|
+
effective_tdd_enabled,
|
|
3462
|
+
project_tdd_coverage_threshold,
|
|
3463
|
+
session_tdd_coverage_threshold,
|
|
3464
|
+
effective_tdd_coverage_threshold,
|
|
2788
3465
|
) = resolve_behavior(root, resolved_session)
|
|
2789
3466
|
concrete_workflow_mode = None
|
|
2790
3467
|
if task:
|
|
@@ -2792,6 +3469,32 @@ def snapshot_state(
|
|
|
2792
3469
|
proposal = task.get("workflow_mode_proposal")
|
|
2793
3470
|
if concrete_workflow_mode is None and isinstance(proposal, dict):
|
|
2794
3471
|
concrete_workflow_mode = proposal.get("selected_mode")
|
|
3472
|
+
task_tdd_enabled = task.get("tdd_enabled") if task else None
|
|
3473
|
+
task_tdd_coverage_threshold = task.get("tdd_coverage_threshold") if task else None
|
|
3474
|
+
frozen_tdd = bool(
|
|
3475
|
+
task
|
|
3476
|
+
and status not in {"ANALYSIS", "INIT"}
|
|
3477
|
+
and isinstance(task_tdd_enabled, bool)
|
|
3478
|
+
)
|
|
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
|
+
)
|
|
3485
|
+
displayed_tdd_threshold = (
|
|
3486
|
+
task_tdd_coverage_threshold
|
|
3487
|
+
if frozen_tdd and isinstance(task_tdd_coverage_threshold, int)
|
|
3488
|
+
else effective_tdd_coverage_threshold
|
|
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
|
+
)
|
|
2795
3498
|
|
|
2796
3499
|
return {
|
|
2797
3500
|
"session_file": display_path(root, session_path),
|
|
@@ -2812,6 +3515,19 @@ def snapshot_state(
|
|
|
2812
3515
|
"session_workflow_mode": session_workflow_mode,
|
|
2813
3516
|
"configured_workflow_mode": configured_workflow_mode,
|
|
2814
3517
|
"concrete_workflow_mode": concrete_workflow_mode,
|
|
3518
|
+
"project_tdd_enabled": project_tdd_enabled,
|
|
3519
|
+
"session_tdd_enabled": session_tdd_enabled,
|
|
3520
|
+
"effective_tdd_enabled": effective_tdd_enabled,
|
|
3521
|
+
"project_tdd_coverage_threshold": project_tdd_coverage_threshold,
|
|
3522
|
+
"session_tdd_coverage_threshold": session_tdd_coverage_threshold,
|
|
3523
|
+
"effective_tdd_coverage_threshold": effective_tdd_coverage_threshold,
|
|
3524
|
+
"task_tdd_enabled": task_tdd_enabled,
|
|
3525
|
+
"task_tdd_coverage_threshold": task_tdd_coverage_threshold,
|
|
3526
|
+
"task_tdd_baselines": task.get("tdd_baselines") if task else None,
|
|
3527
|
+
"displayed_tdd_enabled": displayed_tdd_enabled,
|
|
3528
|
+
"displayed_tdd_coverage_threshold": displayed_tdd_threshold,
|
|
3529
|
+
"tdd_readiness_status": readiness["status"],
|
|
3530
|
+
"tdd_readiness_reasons": readiness["reasons"],
|
|
2815
3531
|
"spec_summary": spec_task_summary(task),
|
|
2816
3532
|
# Compatibility output aliases for pre-0.9 clients.
|
|
2817
3533
|
"project_confirm_mode": project_approval_mode,
|
|
@@ -2831,6 +3547,8 @@ def build_status_line(
|
|
|
2831
3547
|
approval = str(state["effective_approval_mode"]).capitalize()
|
|
2832
3548
|
workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
|
|
2833
3549
|
status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
|
|
3550
|
+
if state["displayed_tdd_enabled"] is True:
|
|
3551
|
+
status_brand += " · **TDD**"
|
|
2834
3552
|
task_id = state["current_task"]
|
|
2835
3553
|
if task_id:
|
|
2836
3554
|
status = str(state["status"])
|
|
@@ -2874,6 +3592,11 @@ def build_machine_breadcrumbs(
|
|
|
2874
3592
|
]
|
|
2875
3593
|
if state.get("concrete_workflow_mode"):
|
|
2876
3594
|
lines.append(f"[easy-coding:workflow-mode:{state['concrete_workflow_mode']}]")
|
|
3595
|
+
if state.get("displayed_tdd_enabled") is True:
|
|
3596
|
+
lines.append("[easy-coding:tdd:enabled]")
|
|
3597
|
+
lines.append(
|
|
3598
|
+
f"[easy-coding:tdd-coverage-threshold:{state['displayed_tdd_coverage_threshold']}]"
|
|
3599
|
+
)
|
|
2877
3600
|
|
|
2878
3601
|
if task_id:
|
|
2879
3602
|
lines.append(f"[current-task:{task_id}]")
|
|
@@ -3181,6 +3904,45 @@ def clear_session_workflow_mode(
|
|
|
3181
3904
|
return snapshot
|
|
3182
3905
|
|
|
3183
3906
|
|
|
3907
|
+
def set_session_tdd(
|
|
3908
|
+
root: Path,
|
|
3909
|
+
enabled: bool,
|
|
3910
|
+
agent: str,
|
|
3911
|
+
threshold: int | None = None,
|
|
3912
|
+
session_file: str | Path | None = None,
|
|
3913
|
+
) -> dict:
|
|
3914
|
+
if enabled:
|
|
3915
|
+
require_tdd_readiness(root)
|
|
3916
|
+
session = ensure_session(root, session_file)
|
|
3917
|
+
materialize_legacy_session_behavior(session)
|
|
3918
|
+
session["tdd_enabled"] = enabled
|
|
3919
|
+
if threshold is not None:
|
|
3920
|
+
session["tdd_coverage_threshold"] = parse_tdd_threshold(
|
|
3921
|
+
threshold, "session tdd_coverage_threshold"
|
|
3922
|
+
)
|
|
3923
|
+
session["last_agent"] = agent
|
|
3924
|
+
write_session(root, session, session_file)
|
|
3925
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
3926
|
+
snapshot["action"] = "set-tdd"
|
|
3927
|
+
return snapshot
|
|
3928
|
+
|
|
3929
|
+
|
|
3930
|
+
def clear_session_tdd(
|
|
3931
|
+
root: Path,
|
|
3932
|
+
agent: str,
|
|
3933
|
+
session_file: str | Path | None = None,
|
|
3934
|
+
) -> dict:
|
|
3935
|
+
session = ensure_session(root, session_file)
|
|
3936
|
+
materialize_legacy_session_behavior(session)
|
|
3937
|
+
session.pop("tdd_enabled", None)
|
|
3938
|
+
session.pop("tdd_coverage_threshold", None)
|
|
3939
|
+
session["last_agent"] = agent
|
|
3940
|
+
write_session(root, session, session_file)
|
|
3941
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
3942
|
+
snapshot["action"] = "clear-tdd"
|
|
3943
|
+
return snapshot
|
|
3944
|
+
|
|
3945
|
+
|
|
3184
3946
|
def set_harness_disabled(
|
|
3185
3947
|
root: Path,
|
|
3186
3948
|
disabled: bool,
|
|
@@ -3611,6 +4373,44 @@ def freeze_workflow_mode(
|
|
|
3611
4373
|
task["workflow_mode_confirmed_by"] = agent
|
|
3612
4374
|
|
|
3613
4375
|
|
|
4376
|
+
def freeze_tdd_mode(
|
|
4377
|
+
root: Path, session: dict, task_id: str, task: dict, agent: str
|
|
4378
|
+
) -> None:
|
|
4379
|
+
behavior = resolve_behavior(root, session)
|
|
4380
|
+
task_type = str(task.get("type") or "").strip().lower()
|
|
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
|
+
)
|
|
4386
|
+
task["tdd_coverage_threshold"] = behavior[11]
|
|
4387
|
+
if task["tdd_enabled"] is True:
|
|
4388
|
+
require_tdd_readiness(root)
|
|
4389
|
+
plan = latest_execution_plan(root, task_id)
|
|
4390
|
+
if plan is None:
|
|
4391
|
+
raise StateError("Cannot freeze TDD baseline without a valid execution plan.")
|
|
4392
|
+
baselines = {
|
|
4393
|
+
key: git_head_sha(repository)
|
|
4394
|
+
for key, repository in tdd_repositories(root, task, plan).items()
|
|
4395
|
+
}
|
|
4396
|
+
task_dir = task_json_path(root, task_id).parent
|
|
4397
|
+
try:
|
|
4398
|
+
dev_spec_content = (task_dir / "dev-spec.md").read_text(encoding="utf-8")
|
|
4399
|
+
strategy_content = (task_dir / "test-strategy.md").read_text(encoding="utf-8")
|
|
4400
|
+
except OSError as error:
|
|
4401
|
+
raise StateError("Cannot freeze TDD without readable analysis artifacts.") from error
|
|
4402
|
+
marker_reasons = tdd_baseline_marker_reasons(
|
|
4403
|
+
dev_spec_content, strategy_content, baselines
|
|
4404
|
+
)
|
|
4405
|
+
if marker_reasons:
|
|
4406
|
+
raise StateError("; ".join(marker_reasons))
|
|
4407
|
+
task["tdd_baselines"] = baselines
|
|
4408
|
+
else:
|
|
4409
|
+
task.pop("tdd_baselines", None)
|
|
4410
|
+
task["tdd_confirmed_at"] = now_iso()
|
|
4411
|
+
task["tdd_confirmed_by"] = agent
|
|
4412
|
+
|
|
4413
|
+
|
|
3614
4414
|
def raise_workflow_mode(
|
|
3615
4415
|
root: Path,
|
|
3616
4416
|
mode: str,
|
|
@@ -3676,7 +4476,7 @@ def request_transition(
|
|
|
3676
4476
|
"use auto-transition instead."
|
|
3677
4477
|
)
|
|
3678
4478
|
if previous == "ANALYSIS" and stage == "IMPLEMENT":
|
|
3679
|
-
validate_analysis_readiness(root, resolved_task_id)
|
|
4479
|
+
validate_analysis_readiness(root, resolved_task_id, session)
|
|
3680
4480
|
if task.get("workflow_mode_legacy") is not True:
|
|
3681
4481
|
validate_workflow_mode_proposal(
|
|
3682
4482
|
root,
|
|
@@ -3729,9 +4529,10 @@ def apply_transition(
|
|
|
3729
4529
|
if violation:
|
|
3730
4530
|
raise StateError(violation)
|
|
3731
4531
|
if previous == "ANALYSIS" and stage == "IMPLEMENT":
|
|
3732
|
-
validate_analysis_readiness(root, resolved_task_id)
|
|
4532
|
+
validate_analysis_readiness(root, resolved_task_id, session)
|
|
3733
4533
|
if task.get("workflow_mode_legacy") is not True:
|
|
3734
4534
|
freeze_workflow_mode(root, session, resolved_task_id, task, agent)
|
|
4535
|
+
freeze_tdd_mode(root, session, resolved_task_id, task, agent)
|
|
3735
4536
|
if previous == "REVIEW" and stage == "VERIFICATION":
|
|
3736
4537
|
validate_review_readiness(root, resolved_task_id, task)
|
|
3737
4538
|
if previous == "VERIFICATION" and stage == "MEMORY":
|
|
@@ -4156,6 +4957,14 @@ def main() -> int:
|
|
|
4156
4957
|
clear_workflow_mode_parser = subcommands.add_parser("clear-workflow-mode", parents=[common])
|
|
4157
4958
|
clear_workflow_mode_parser.add_argument("--agent", required=True)
|
|
4158
4959
|
|
|
4960
|
+
set_tdd_parser = subcommands.add_parser("set-tdd", parents=[common])
|
|
4961
|
+
set_tdd_parser.add_argument("--enabled", required=True, choices=["true", "false"])
|
|
4962
|
+
set_tdd_parser.add_argument("--threshold", type=int)
|
|
4963
|
+
set_tdd_parser.add_argument("--agent", required=True)
|
|
4964
|
+
|
|
4965
|
+
clear_tdd_parser = subcommands.add_parser("clear-tdd", parents=[common])
|
|
4966
|
+
clear_tdd_parser.add_argument("--agent", required=True)
|
|
4967
|
+
|
|
4159
4968
|
# Compatibility aliases for pre-0.9 callers.
|
|
4160
4969
|
set_confirm_mode_parser = subcommands.add_parser("set-confirm-mode", parents=[common])
|
|
4161
4970
|
set_confirm_mode_parser.add_argument(
|
|
@@ -4441,6 +5250,30 @@ def main() -> int:
|
|
|
4441
5250
|
session_file,
|
|
4442
5251
|
)
|
|
4443
5252
|
)
|
|
5253
|
+
elif command == "set-tdd":
|
|
5254
|
+
emit(
|
|
5255
|
+
attach_status_context(
|
|
5256
|
+
root,
|
|
5257
|
+
set_session_tdd(
|
|
5258
|
+
root,
|
|
5259
|
+
args.enabled == "true",
|
|
5260
|
+
agent,
|
|
5261
|
+
args.threshold,
|
|
5262
|
+
session_file,
|
|
5263
|
+
),
|
|
5264
|
+
agent,
|
|
5265
|
+
session_file,
|
|
5266
|
+
)
|
|
5267
|
+
)
|
|
5268
|
+
elif command == "clear-tdd":
|
|
5269
|
+
emit(
|
|
5270
|
+
attach_status_context(
|
|
5271
|
+
root,
|
|
5272
|
+
clear_session_tdd(root, agent, session_file),
|
|
5273
|
+
agent,
|
|
5274
|
+
session_file,
|
|
5275
|
+
)
|
|
5276
|
+
)
|
|
4444
5277
|
elif command == "propose-workflow-mode":
|
|
4445
5278
|
emit(
|
|
4446
5279
|
attach_status_context(
|