#!/usr/bin/env python3 """Validate docs/feature-matrix.md against execution-contract CSV sources. Checks: - feature-matrix contains exactly one row per ID in CSV fact sources - every ID from CSVs appears in matrix - matrix does not contain unknown IDs - every matrix row status equals DONE - every fact-source CSV row is fully DONE on Dev/Review1/Regression axes """ from __future__ import annotations import csv import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent MATRIX_PATH = ROOT / "docs" / "feature-matrix.md" CSV_FACT_SOURCES = [ ROOT / "issues" / "2026-02-09_13-39-02-p5-blueprint-v3.csv", ROOT / "issues" / "2026-02-09_23-30-00-p6-p8-remaining-work-master.csv", ROOT / "issues" / "2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv", ROOT / "issues" / "2026-02-10_10-35-30-p10-production-hardening.csv", ROOT / "issues" / "2026-02-11-p11-p12-governance-mobile.csv", ROOT / "issues" / "2026-02-12_01-09-41-p13-l5-governance.csv", ROOT / "issues" / "2026-02-13_00-11-56-p14-l5-operationalization.csv", ROOT / "issues" / "2026-03-06_14-30-00-p15a-l5-permission-decouple.csv", ROOT / "issues" / "2026-03-06_14-30-00-p15b-l5-leave-v1.csv", ROOT / "issues" / "2026-03-07_13-47-05-p16a-l5-master-data-core.csv", ROOT / "issues" / "2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv", ROOT / "issues" / "2026-03-07_17-14-39-p16c-l5-suppliers.csv", ROOT / "issues" / "2026-03-07_18-15-03-r9-g2-p19-roadmap-refresh.csv", ROOT / "issues" / "2026-03-07_18-16-00-g2-l5-cross-domain-bridge.csv", ROOT / "issues" / "2026-03-07_19-33-09-p17a-l5-procurement-core.csv", ROOT / "issues" / "2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv", ROOT / "issues" / "2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv", ROOT / "issues" / "2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv", ROOT / "issues" / "2026-03-07_23-22-00-p19-l4-integration-readiness.csv", ROOT / "issues" / "2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv", ROOT / "issues" / "2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv", ROOT / "issues" / "2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv", ROOT / "issues" / "2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv", ROOT / "issues" / "2026-03-09_10-15-00-r10-post-erp3-roadmap-refresh.csv", ROOT / "issues" / "2026-03-09_10-25-00-p19-mcp-1-l4-readonly-tools-baseline.csv", ROOT / "issues" / "2026-03-10_12-54-30-p19-ai-1-l5-suggestion-workbench-refresh.csv", ROOT / "issues" / "2026-03-10_17-32-21-p20-travel-1-l5-request-intake-refresh.csv", ROOT / "issues" / "2026-03-10_21-05-59-p20-travel-2-l5-corporate-payments-refresh.csv", ] ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z-]*$") STATUS_INDEX = 6 def load_csv_rows(path: Path) -> list[dict[str, str]]: with path.open(encoding="utf-8", newline="") as fp: return list(csv.DictReader(fp)) def collect_expected_ids_and_source_status(paths: list[Path]) -> tuple[list[str], list[str]]: ids: list[str] = [] source_errors: list[str] = [] for path in paths: rows = load_csv_rows(path) for row in rows: row_id = (row.get("ID") or "").strip() if not row_id: continue ids.append(row_id) dev_status = (row.get("Dev_Status") or "").strip().upper() review_status = (row.get("Review1_Status") or "").strip().upper() regression_status = (row.get("Regression_Status") or "").strip().upper() if not (dev_status == review_status == regression_status == "DONE"): source_errors.append( f"{path.name}:{row_id} source status not all DONE " f"(Dev={dev_status}, Review1={review_status}, Regression={regression_status})" ) return ids, source_errors def load_matrix_rows(path: Path) -> list[list[str]]: rows: list[list[str]] = [] for line in path.read_text(encoding="utf-8").splitlines(): stripped = line.strip() if not stripped.startswith("|"): continue if stripped.startswith("| ---"): continue parts = [part.strip() for part in stripped.strip("|").split("|")] if not parts: continue row_id = parts[0] if not ID_PATTERN.match(row_id): continue rows.append(parts) return rows def main() -> int: errors: list[str] = [] if not MATRIX_PATH.exists(): errors.append(f"Missing matrix file: {MATRIX_PATH}") for source in CSV_FACT_SOURCES: if not source.exists(): errors.append(f"Missing csv file: {source}") if errors: for err in errors: print(f"[ERR] {err}") return 1 expected_ids, source_status_errors = collect_expected_ids_and_source_status(CSV_FACT_SOURCES) matrix_rows = load_matrix_rows(MATRIX_PATH) matrix_ids = [row[0] for row in matrix_rows] expected_id_set = set(expected_ids) matrix_id_set = set(matrix_ids) missing_in_matrix = sorted(expected_id_set - matrix_id_set) extra_in_matrix = sorted(matrix_id_set - expected_id_set) if missing_in_matrix: errors.append(f"IDs missing in matrix: {missing_in_matrix}") if extra_in_matrix: errors.append(f"Extra IDs in matrix: {extra_in_matrix}") duplicate_matrix_ids = sorted({item for item in matrix_ids if matrix_ids.count(item) > 1}) if duplicate_matrix_ids: errors.append(f"Duplicate IDs in matrix: {duplicate_matrix_ids}") if len(matrix_ids) != len(expected_ids): errors.append(f"Row count mismatch: matrix={len(matrix_ids)} expected={len(expected_ids)}") duplicate_expected_ids = sorted({item for item in expected_ids if expected_ids.count(item) > 1}) if duplicate_expected_ids: errors.append(f"Duplicate IDs in fact-source CSVs: {duplicate_expected_ids}") for row in matrix_rows: if len(row) <= STATUS_INDEX: errors.append(f"Malformed matrix row (too few columns): {row}") continue status = row[STATUS_INDEX].upper() if status != "DONE": errors.append(f"Non-DONE status in matrix for {row[0]}: {row[STATUS_INDEX]}") errors.extend(source_status_errors) if errors: print("[ERR] feature-matrix validation failed") for err in errors: print(f" - {err}") return 1 source_names = ", ".join(path.name for path in CSV_FACT_SOURCES) print( f"[OK] feature-matrix matches fact sources ({source_names}) " f"(IDs/status/count)" ) return 0 if __name__ == "__main__": sys.exit(main())