diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index b549464..1e74b82 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -35,6 +35,7 @@ Checks: C-30 scope-current-state-stale WARN No SCOPE.md Current State contradicts live workplan statuses C-31 work-record-unregistered WARN No YAML-block id matches no kind in the canon work-record type registry (sidetrack detector, CUST-WP-0060) C-32 work-record-not-indexed WARN Yes kind: intake/decision YAML block has no hub id — not indexed in DB (registration, CUST-WP-0061-T02) + C-33 work-record-index-stale WARN Yes WORK-RECORDS.md missing or stale — generated per-repo index (CUST-WP-0061-T04) Usage: python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL] @@ -973,6 +974,116 @@ def _check_work_record_registration(repo_dir: Path, report: "ConsistencyReport") ) +_WORK_RECORD_INDEX_NAME = "WORK-RECORDS.md" +_WORK_RECORD_INDEX_KIND_ORDER = { + "workplan": 0, "task": 1, "intake": 2, "decision": 3, "engagement": 4, +} + + +def _generate_work_record_index(repo_dir: Path, repo_slug: str) -> str | None: + """CUST-WP-0061-T04: the generated per-repo work-record index — every + registered work record (workplan, task, intake, decision, engagement) + with kind/id/status/lane/source, derivable purely from repo files (no + hub query — matches the ADR-001 rebuild principle). Full transclusion + rendering (markitect integration) is a follow-on; this is the flat + first cut. Returns None (no file written) when the repo has no work + records at all, to avoid clutter in small repos. + """ + rows: list[tuple[str, str, str, str, str]] = [] # kind, id, status, lane, source + + workplans_dir = repo_dir / "workplans" + if workplans_dir.is_dir(): + for wp_file in iter_workplan_files(workplans_dir, include_archived=False): + try: + text = wp_file.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + meta, body = parse_frontmatter(text) + wp_id = str(meta.get("id", "")).strip() + if not wp_id: + continue + source = str(wp_file.relative_to(repo_dir)) + rows.append(("workplan", wp_id, str(meta.get("status", "")).strip() or "—", "—", source)) + for task in get_tasks_from_workplan(meta, body): + if task.get("_parse_error"): + continue + t_id = str(task.get("id", "")).strip() + if not t_id: + continue + rows.append(("task", t_id, str(task.get("status", "")).strip() or "—", "—", source)) + + kind_registry = _load_work_record_kind_registry() + if kind_registry: + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + if md.name == _WORK_RECORD_INDEX_NAME: + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + for block in _YAML_ONLY_FENCE_RE.findall(text): + meta = _parse_yaml_block(block.strip()) + if not isinstance(meta, dict) or meta.get("_parse_error"): + continue + rid = str(meta.get("id", "")).strip() + if not rid or "NNN" in rid: + continue + kind = _classify_work_record_kind(rid, kind_registry) + if kind not in ("intake", "decision", "engagement"): + continue + status = str(meta.get("status", "")).strip() or "—" + lane = str(meta.get("lane", "")).strip() or "—" + source = str(md.relative_to(repo_dir)) + rows.append((kind, rid, status, lane, source)) + + if not rows: + return None + + rows.sort(key=lambda r: (_WORK_RECORD_INDEX_KIND_ORDER.get(r[0], 99), r[4], r[1])) + + lines = [ + f"# Work Records — {repo_slug}", + "", + "> Generated by `statehub fix-consistency` (CUST-WP-0061-T04, work-record", + "> stage 3). Do not edit by hand — edit the source file/block listed for", + "> each record and re-run fix-consistency to refresh this index. Archived", + "> workplans are omitted; closed decisions/intakes/engagements stay listed", + "> so recently-resolved work is still visible. [auto]", + "", + "| Kind | ID | Status | Lane | Source |", + "| --- | --- | --- | --- | --- |", + ] + lines.extend(f"| {kind} | {rid} | {status} | {lane} | {source} |" for kind, rid, status, lane, source in rows) + lines.append("") + return "\n".join(lines) + + +def _check_work_record_index_freshness( + repo_dir: Path, repo_slug: str, report: "ConsistencyReport" +) -> None: + """C-33: WORK-RECORDS.md missing or stale relative to the current + file-derived work-record set.""" + content = _generate_work_record_index(repo_dir, repo_slug) + if content is None: + return + index_file = repo_dir / _WORK_RECORD_INDEX_NAME + existing = index_file.read_text(encoding="utf-8") if index_file.is_file() else None + if existing == content: + return + report.add( + severity="WARN", check_id="C-33", + message=( + f"{_WORK_RECORD_INDEX_NAME} is missing or stale — regenerate via " + f"fix-consistency (CUST-WP-0061-T04 generated work-record index)" + ), + file_path=_WORK_RECORD_INDEX_NAME, + fixable=True, + _fix_context={"index_file": index_file, "content": content}, + ) + + def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = None) -> ConsistencyReport: """Run all consistency checks for a registered repo.""" repo = _api_get(api_base, f"/repos/{repo_slug}", return_error=True) @@ -1042,6 +1153,9 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N # C-32: intake/decision work-record registration (CUST-WP-0061-T02) _check_work_record_registration(repo_dir, report) + # C-33: generated per-repo work-record index (CUST-WP-0061-T04) + _check_work_record_index_freshness(repo_dir, repo_slug, report) + # C-01: workplans/ directory missing if not workplans_dir.is_dir(): report.add( @@ -2786,6 +2900,12 @@ def fix_repo( f"failed to write {id_field} back to {md_path.name}" ) + elif issue.check_id == "C-33": + ctx["index_file"].write_text(ctx["content"], encoding="utf-8") + report.fixes_applied.append( + f"C-33 fixed: regenerated {ctx['index_file'].name}" + ) + elif issue.check_id == "C-09": ws_id = ctx["ws_id"] correct_repo_id = ctx["correct_repo_id"] diff --git a/tests/test_work_record_index.py b/tests/test_work_record_index.py new file mode 100644 index 0000000..2f1febf --- /dev/null +++ b/tests/test_work_record_index.py @@ -0,0 +1,187 @@ +"""Tests for C-33 (generated per-repo work-record index, CUST-WP-0061-T04): +_generate_work_record_index() and _check_work_record_index_freshness(). +Offline, synthetic registry fixture — same conventions as +test_work_record_check.py / test_work_record_registration.py. +""" +from __future__ import annotations + +import sys +import textwrap +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from consistency_check import ( # noqa: E402 + ConsistencyReport, + _check_work_record_index_freshness, + _generate_work_record_index, +) + +REGISTRY_YAML = textwrap.dedent( + """ + version: "0.1" + kinds: + - kind: workplan + id_patterns: ['^[A-Z]+-WP-[0-9]{4}$'] + - kind: task + id_patterns: ['^[A-Z]+-WP-[0-9]{4}-T[0-9]{2,3}$'] + - kind: intake + id_patterns: ['^[A-Z]+-IN-[0-9]{4}$'] + legacy_patterns: + - pattern: '^AWQ-[0-9]{3}$' + source: binky AutopilotWorkQueue + - kind: decision + id_patterns: ['^[A-Z]+-DEC-[0-9]{4}-[0-9]{3}$'] + - kind: engagement + id_patterns: ['^[A-Z]+-ENG-[0-9]{4}-[0-9]{3}$'] + """ +) + + +@pytest.fixture +def registry_file(tmp_path, monkeypatch): + reg = tmp_path / "work-record-types.yaml" + reg.write_text(REGISTRY_YAML, encoding="utf-8") + monkeypatch.setenv("WORK_RECORD_REGISTRY", str(reg)) + return reg + + +class TestGenerateWorkRecordIndex: + def test_empty_repo_returns_none(self, tmp_path, registry_file): + assert _generate_work_record_index(tmp_path, "testrepo") is None + + def test_includes_workplan_and_tasks(self, tmp_path, registry_file): + wp_dir = tmp_path / "workplans" + wp_dir.mkdir() + (wp_dir / "CUST-WP-0001-demo.md").write_text( + textwrap.dedent( + """\ + --- + id: CUST-WP-0001 + status: active + --- + ```task + id: CUST-WP-0001-T01 + status: done + priority: high + ``` + """ + ), + encoding="utf-8", + ) + content = _generate_work_record_index(tmp_path, "testrepo") + assert content is not None + assert "| workplan | CUST-WP-0001 | active | — | workplans/CUST-WP-0001-demo.md |" in content + assert "| task | CUST-WP-0001-T01 | done | — |" in content + + def test_excludes_archived_workplans(self, tmp_path, registry_file): + wp_dir = tmp_path / "workplans" / "archived" + wp_dir.mkdir(parents=True) + (wp_dir / "260101-CUST-WP-0000-old.md").write_text( + "---\nid: CUST-WP-0000\nstatus: finished\n---\n", encoding="utf-8" + ) + assert _generate_work_record_index(tmp_path, "testrepo") is None + + def test_includes_intake_decision_engagement(self, tmp_path, registry_file): + (tmp_path / "queue.md").write_text( + textwrap.dedent( + """ + ```yaml + id: AWQ-010 + title: qonto mcp + status: open + lane: green + ``` + ```yaml + id: BINKY-DEC-2026-004 + title: qonto approval + status: resolved + lane: red + ``` + ```yaml + id: BINKY-ENG-2026-001 + title: bank call + status: queued + ``` + """ + ), + encoding="utf-8", + ) + content = _generate_work_record_index(tmp_path, "testrepo") + assert "| intake | AWQ-010 | open | green | queue.md |" in content + assert "| decision | BINKY-DEC-2026-004 | resolved | red | queue.md |" in content + assert "| engagement | BINKY-ENG-2026-001 | queued | — | queue.md |" in content + + def test_ignores_unregistered_ids(self, tmp_path, registry_file): + (tmp_path / "queue.md").write_text( + "```yaml\nid: FOO-QX-001\nstatus: open\n```\n", encoding="utf-8" + ) + assert _generate_work_record_index(tmp_path, "testrepo") is None + + def test_ignores_template_placeholders(self, tmp_path, registry_file): + (tmp_path / "AGENTS.md").write_text( + "```yaml\nid: BINKY-IN-NNNN\nstatus: open\n```\n", encoding="utf-8" + ) + assert _generate_work_record_index(tmp_path, "testrepo") is None + + def test_does_not_scan_its_own_output_file(self, tmp_path, registry_file): + (tmp_path / "WORK-RECORDS.md").write_text( + "| intake | AWQ-999 | open | green | ghost.md |\n", encoding="utf-8" + ) + assert _generate_work_record_index(tmp_path, "testrepo") is None + + def test_header_and_sort_order(self, tmp_path, registry_file): + (tmp_path / "b.md").write_text("```yaml\nid: AWQ-002\nstatus: open\n```\n", encoding="utf-8") + (tmp_path / "a.md").write_text("```yaml\nid: AWQ-001\nstatus: open\n```\n", encoding="utf-8") + content = _generate_work_record_index(tmp_path, "testrepo") + assert content.startswith("# Work Records — testrepo") + assert "[auto]" in content + # sorted by source path first (a.md before b.md) + assert content.index("AWQ-001") < content.index("AWQ-002") + + +class TestCheckWorkRecordIndexFreshness: + def _report(self, repo_dir: Path, repo_slug: str = "testrepo") -> ConsistencyReport: + report = ConsistencyReport(repo_slug=repo_slug, repo_path=str(repo_dir)) + _check_work_record_index_freshness(repo_dir, repo_slug, report) + return report + + def test_no_issue_when_no_records(self, tmp_path, registry_file): + report = self._report(tmp_path) + assert report.issues == [] + + def test_flags_missing_index(self, tmp_path, registry_file): + (tmp_path / "queue.md").write_text("```yaml\nid: AWQ-001\nstatus: open\n```\n", encoding="utf-8") + report = self._report(tmp_path) + assert len(report.issues) == 1 + assert report.issues[0].check_id == "C-33" + assert report.issues[0].fixable is True + + def test_no_issue_when_index_already_current(self, tmp_path, registry_file): + (tmp_path / "queue.md").write_text("```yaml\nid: AWQ-001\nstatus: open\n```\n", encoding="utf-8") + content = _generate_work_record_index(tmp_path, "testrepo") + (tmp_path / "WORK-RECORDS.md").write_text(content, encoding="utf-8") + report = self._report(tmp_path) + assert report.issues == [] + + def test_flags_stale_index_after_new_record_added(self, tmp_path, registry_file): + (tmp_path / "queue.md").write_text("```yaml\nid: AWQ-001\nstatus: open\n```\n", encoding="utf-8") + content = _generate_work_record_index(tmp_path, "testrepo") + (tmp_path / "WORK-RECORDS.md").write_text(content, encoding="utf-8") + # a new record appears + (tmp_path / "queue.md").write_text( + "```yaml\nid: AWQ-001\nstatus: open\n```\n```yaml\nid: AWQ-002\nstatus: open\n```\n", + encoding="utf-8", + ) + report = self._report(tmp_path) + assert len(report.issues) == 1 + assert report.issues[0].check_id == "C-33" + + def test_fix_context_carries_correct_content(self, tmp_path, registry_file): + (tmp_path / "queue.md").write_text("```yaml\nid: AWQ-001\nstatus: open\n```\n", encoding="utf-8") + report = self._report(tmp_path) + ctx = report.issues[0]._fix_context + assert ctx["index_file"] == tmp_path / "WORK-RECORDS.md" + assert "AWQ-001" in ctx["content"]