Add lifecycle renormalization consistency repair

This commit is contained in:
2026-05-23 16:54:38 +02:00
parent 6496ef851a
commit 0aa02d9117
4 changed files with 284 additions and 7 deletions

View File

@@ -25,6 +25,7 @@ Checks:
C-19 workstream-planning-drift WARN Yes planning_priority/planning_order differs between file and DB
C-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph
C-22 task-description-drift WARN Yes Task description/content differs between file and DB
C-23 workstream-active-task-planning-status WARN Yes Workstream/workplan is planning while a task is in progress or blocked
Usage:
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
@@ -65,6 +66,7 @@ from api.workplan_status import ( # noqa: E402
normalize_workstream_status as _normalize_workstream_status,
ready_review_status,
)
from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402
try:
import yaml as _yaml
@@ -349,6 +351,34 @@ def _add_frontmatter_field(file_path: Path, key: str, value: str) -> None:
file_path.write_text("\n".join(lines), encoding="utf-8")
def _patch_frontmatter_field(file_path: Path, key: str, value: str) -> bool:
"""Update or insert a scalar frontmatter field without rewriting the file."""
text = file_path.read_text(encoding="utf-8")
if not text.startswith("---"):
return False
lines = text.split("\n")
close_idx = None
for i, line in enumerate(lines[1:], 1):
if line.strip() == "---":
close_idx = i
break
if close_idx is None:
return False
new_line = f"{key}: {value}"
for i in range(1, close_idx):
if re.match(rf"^\s*{re.escape(key)}\s*:", lines[i]):
if lines[i] == new_line:
return False
lines[i] = new_line
file_path.write_text("\n".join(lines), encoding="utf-8")
return True
lines.insert(close_idx, new_line)
file_path.write_text("\n".join(lines), encoding="utf-8")
return True
def _inject_task_id_into_block(
file_path: Path, field_name: str, field_value: str, match_id: str
) -> bool:
@@ -711,11 +741,55 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
)
# Continue to check drift even with mismatched repo
tasks = get_tasks_from_workplan(meta, body)
db_tasks = _api_get(api_base, "/tasks", {"workstream_id": ws_id})
file_task_statuses = [
str(task.get("status", "")).strip()
for task in tasks
if not task.get("_parse_error")
]
db_task_statuses = [
str(task.get("status", "")).strip()
for task in db_tasks
if isinstance(db_tasks, list)
] if isinstance(db_tasks, list) else []
active_task_requires_activation = should_activate_parent_for_active_tasks(
parent_workstream_status=file_status,
task_statuses=[*file_task_statuses, *db_task_statuses],
)
if active_task_requires_activation:
report.add(
severity="WARN",
check_id="C-23",
message=(
f"Lifecycle drift in '{ws.get('slug')}': workplan status "
f"{file_status!r} has an active task — repair to 'active'"
),
file_path=fname,
db_id=ws_id,
file_value=file_status,
db_value=ws.get("status", ""),
fixable=True,
_fix_context={
"ws_id": ws_id,
"wp_file": str(wp_file),
"file_status": file_status,
"db_status": ws.get("status", ""),
"target_status": "active",
},
)
# C-04: status drift — normalise file value before comparing so that
# legacy file/API aliases are not treated as drift.
# legacy file/API aliases are not treated as drift. Lifecycle repairs
# take precedence so a stale planning file cannot regress active work.
db_status = ws.get("status", "")
normalised_db_status = normalise_workstream_status(db_status)
if file_status and db_status and normalised_file_status != normalised_db_status:
if (
not active_task_requires_activation
and file_status
and db_status
and normalised_file_status != normalised_db_status
):
report.add(
severity="WARN", check_id="C-04",
message=(
@@ -806,8 +880,6 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
)
# C-10, C-11, C-12: task-level checks
tasks = get_tasks_from_workplan(meta, body)
db_tasks = _api_get(api_base, "/tasks", {"workstream_id": ws_id})
db_task_by_id: dict[str, dict] = {}
if isinstance(db_tasks, list):
for t in db_tasks:
@@ -1209,7 +1281,11 @@ def _patch_task_status_in_file(
def _git_commit_writeback(
repo_path: str, file_path: Path, changes: list[str]
repo_path: str,
file_path: Path,
changes: list[str],
*,
subject: str = "chore(consistency): sync task status from DB [auto]",
) -> bool:
"""Stage *file_path* and commit with a standard writeback message.
@@ -1219,7 +1295,7 @@ def _git_commit_writeback(
from datetime import date as _date
summary = "\n".join(f" - {c}" for c in changes)
msg = (
f"chore(consistency): sync task status from DB [auto]\n\n"
f"{subject}\n\n"
f"Updated by fix-consistency on {_date.today().isoformat()}:\n"
f"{summary}"
)
@@ -1480,6 +1556,44 @@ def fix_repo(
f"{ctx['field']}{ctx['value']!r}: {result['_error']}"
)
elif issue.check_id == "C-23":
ws_id = ctx["ws_id"]
target_status = ctx["target_status"]
result = _api_patch(api_base, f"/workstreams/{ws_id}", {"status": target_status})
if result is not None and "_error" not in result:
report.fixes_applied.append(
f"C-23 fixed: workstream {ws_id[:8]}… status → {target_status!r}"
)
elif result is not None:
report.fixes_applied.append(
f"C-23 FAILED: workstream {ws_id[:8]}"
f"status → {target_status!r}: {result['_error']}"
)
if no_writeback:
report.fixes_applied.append(
f"C-23 skipped file repair (--no-writeback): {Path(ctx['wp_file']).name}"
)
else:
wp_file = Path(ctx["wp_file"])
old_status = ctx["file_status"]
if _patch_frontmatter_field(wp_file, "status", target_status):
committed = _git_commit_writeback(
repo_path,
wp_file,
[f"workplan status: {old_status}{target_status}"],
subject="chore(consistency): renormalize lifecycle state [auto]",
)
suffix = " (committed)" if committed else " (file patched, commit failed)"
report.fixes_applied.append(
f"C-23 fixed: {wp_file.name} status "
f"{old_status}{target_status}{suffix}"
)
else:
report.fixes_applied.append(
f"C-23 SKIP: {wp_file.name} already has status {target_status!r}"
)
elif issue.check_id == "C-06":
wp_file = Path(ctx["wp_file"])
meta = ctx["meta"]