generated from coulomb/repo-seed
Add lifecycle renormalization consistency repair
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user