"""Local, source-derived review guidance. Gitea remains the only review state log.""" from __future__ import annotations import hashlib import os from pathlib import Path import re import stat from urllib.parse import urlsplit from .common import atomic_json, now, read_json, redact, reject_symlinks from .issues import coverage_notes, evidence_record, _base_url, _name, _positive from .workspace import inspect_repository, load_project, selected_repositories, source_fingerprint MAX_SOURCES = 10000 MANUAL_CHECKS = ( ("surfaces", "Confirm every route, pane, dialog, settings surface, widget, public form and contributed interface; source discovery is only a starting list."), ("display-edit", "Show compact readable data first; edit coherent groups in scoped dialogs. Record a reason for a deliberate bulk editing mode or other exception."), ("help", "Check documentation books beside meaningful visible headings/labels, including widgets, loading and configuration states; preserve optional Docs fallback."), ("actions", "Check predictable action order, Save/Cancel, unsaved drafts, destructive consequences and truthful unavailable-action explanations."), ("geometry", "Check shared cards/tables/dialogs, column resizing, pagination, long data, narrow layouts, zoom and the reported wide-window configuration."), ("states", "Exercise loading, empty, partial, error, stale, conflict and permission-denied states; verify no accidental mutation on read or cancel."), ("accessibility", "Exercise keyboard order, visible focus, accessible names, dialog focus restoration and non-color-only feedback."), ("language-docs", "Review English and German, long headings and module-owned user/admin documentation for every changed workflow and limitation."), ("boundaries", "Exercise tenant/authorization boundaries and optional-module absence; inspect headless modules' contributed interfaces rather than marking them complete automatically."), ("propagation", "Record applied principle revision, exceptions, fixes, evidence and remaining work in the module issue; propagate new rules to already-reviewed modules through the central issue."), ) def register(subparsers) -> None: parser = subparsers.add_parser("review", help="Assemble a module's local UI-review guidance (does not complete a review)") parser.add_argument("module", help="Repository name, alias, or review inventory scope ID") parser.add_argument("bundle_module", nargs="?", help="Also accepts: review bundle MODULE") parser.add_argument("--profile", default="ui", help="Plan this registered check profile, without running it") parser.add_argument("--evidence", help="Existing local run ID or explicit receipt JSON path") parser.add_argument("--output", type=Path, help="Optional local JSON artifact; not a progress tracker") parser.set_defaults(handler=handle_review) def _project_path(root: Path, value: str) -> Path: if not isinstance(value, str): raise ValueError("Review inventory/principles paths must be strings") raw = Path(value) if raw.is_absolute() or ".." in raw.parts: raise ValueError("Review configuration paths must be relative within the workspace") path = root / raw reject_symlinks(path) if not path.resolve().is_relative_to(root.resolve()): raise ValueError("Review input escapes the workspace") return path def _link(record: dict) -> dict: if not isinstance(record, dict): raise ValueError("Review issue link must be an object") repo = _name(record.get("repository")) number = _positive(record.get("number")) url = record.get("url") if not isinstance(url, str): raise ValueError("Review issue URL is missing") parsed = urlsplit(_base_url(url)) parts = parsed.path.rstrip("/").split("/") if len(parts) < 5 or parts[-3:] != [repo, "issues", str(number)]: raise ValueError("Review issue URL does not match its repository and number") _name(parts[-4]) # Snapshot status/operation fields are deliberately not projected as live state. return {"repository": repo, "number": number, "url": url} def _issue_inventory(path: Path | None) -> tuple[list[dict], dict | None]: if path is None or not path.exists(): return [], None payload = read_json(path, max_bytes=2 * 1024 * 1024) if not isinstance(payload, dict) or payload.get("schema_version") != 1: raise ValueError("Review issue inventory requires schema_version 1") rows = payload.get("issues") if not isinstance(rows, list) or len(rows) > 1024: raise ValueError("Review issue inventory must contain a bounded issues list") issues, scopes, urls = [], set(), set() for row in rows: link = _link(row) scope = row.get("scope_id") if not isinstance(scope, str) or not re.fullmatch(r"[A-Za-z0-9_:.-]{1,128}", scope) or scope in scopes or link["url"] in urls: raise ValueError("Review issue inventory has an invalid or duplicate scope/issue") scopes.add(scope) urls.add(link["url"]) issues.append({**link, "scope_id": scope, "name": str(row.get("name", scope)), "kind": str(row.get("kind", "unspecified"))}) epic = _link(payload["epic"]) if payload.get("epic") else None return issues, epic def _read_text(path: Path) -> str: reject_symlinks(path) descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) with os.fdopen(descriptor, "rb") as handle: metadata = os.fstat(handle.fileno()) if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > 1024 * 1024: raise ValueError("Principles must be a bounded regular text file") encoded = handle.read(1024 * 1024 + 1) if len(encoded) > 1024 * 1024: raise ValueError("Principles exceed the file-size bound") return encoded.decode("utf-8") def _principles(path: Path | None) -> dict: if path is None or not path.exists(): return {"path": str(path) if path else None, "available": False, "revision": None, "rules": []} text = _read_text(path) revision = re.search(r"\bUI-\d{4}-\d{2}-\d{2}\b", text) rules = [{"id": match.group(1), "title": match.group(2).strip()} for match in re.finditer(r"^##\s+(UI-\d{2})\s*[—–:-]\s*(.+)$", text, re.MULTILINE)] return {"path": str(path), "available": True, "revision": revision.group(0) if revision else None, "content_sha256": hashlib.sha256(text.encode()).hexdigest(), "rules": rules} def source_inventory(root: Path) -> dict: """Enumerate filenames only; never execute module manifests or import optional modules.""" groups = {name: [] for name in ("pages-and-navigation", "dialogs-and-embedded-editors", "settings-and-administration", "widgets-and-public-surfaces", "other-ui-sources")} manifests, skipped = [], [] count = 0 for base, mode in ((root / "webui/src", "ui"), (root / "src", "backend")): try: reject_symlinks(base) except ValueError: skipped.append(str(base.relative_to(root))) continue for directory, dirs, names in os.walk(base, followlinks=False): folder = Path(directory) safe_dirs = [] for name in sorted(dirs): child = folder / name if child.is_symlink(): skipped.append(str(child.relative_to(root))) elif name not in {"node_modules", ".git", "__pycache__", ".venv", "dist"}: safe_dirs.append(name) dirs[:] = safe_dirs for name in sorted(names): path = folder / name if path.is_symlink(): skipped.append(str(path.relative_to(root))) continue if mode == "backend": if name == "manifest.py": manifests.append(str(path.relative_to(root))) continue if path.suffix not in {".tsx", ".jsx", ".vue", ".svelte"} and name not in {"index.ts", "module.ts", "routes.ts"}: continue count += 1 if count > MAX_SOURCES: raise ValueError("Module UI source inventory exceeds its size bound") relative = str(path.relative_to(root)) lower = relative.lower() group = ("dialogs-and-embedded-editors" if any(part in lower for part in ("dialog", "modal", "editor")) else "settings-and-administration" if any(part in lower for part in ("setting", "admin")) else "widgets-and-public-surfaces" if any(part in lower for part in ("widget", "public")) else "pages-and-navigation" if any(part in lower for part in ("/pages/", "page.", "navigation", "routes.ts")) else "other-ui-sources") groups[group].append(relative) return {"basis": "Static filename discovery, not a complete runtime surface inventory or a review result.", "ui_source_count": count, "groups": groups, "manifest_paths": manifests, "skipped_symlinks": skipped, "module_code_imported": False} def _redact_tree(value): if isinstance(value, str): return redact(value) if isinstance(value, list): return [_redact_tree(item) for item in value] if isinstance(value, dict): return {key: _redact_tree(item) for key, item in value.items()} return value def handle_review(args) -> dict: name = args.module if name == "bundle": if not args.bundle_module: raise ValueError("review bundle requires a module") name = args.bundle_module elif args.bundle_module: raise ValueError("Review accepts one module; use review MODULE") workspace_root = Path(args.workspace_root).resolve() project = load_project(workspace_root, args.project) configured = project.config.get("review", {}) if args.project else {} if not isinstance(configured, dict) or set(configured) - {"issue_inventory", "principles"}: raise ValueError("Project review configuration accepts issue_inventory and principles paths") meta = next((repo.path for repo in project.repositories if repo.name == "govoplan"), None) core = next((repo.path for repo in project.repositories if repo.name == "govoplan-core"), None) inventory_path = (_project_path(workspace_root, configured["issue_inventory"]) if "issue_inventory" in configured else meta / "docs/project/ui-review-issue-inventory.json" if meta and not args.project else None) principles_path = (_project_path(workspace_root, configured["principles"]) if "principles" in configured else core / "docs/UI_DESIGN_PRINCIPLES.md" if core and not args.project else None) issues, epic = _issue_inventory(inventory_path) scope_matches = [item for item in issues if item["scope_id"] == name] selected = selected_repositories(project, [scope_matches[0]["repository"] if scope_matches else name]) if len(selected) != 1: raise ValueError("Review must resolve to exactly one registered repository") repo = selected[0] links = [item for item in issues if item["repository"] == repo.name] inventory = source_inventory(repo.path) principles = _principles(principles_path) state = inspect_repository(repo) warnings = [] if not links: warnings.append("No module issue discovery link is configured; locate/create the canonical Gitea review issue before recording review work.") if not principles["available"] or not principles["revision"]: warnings.append("The principle document or dated UI revision is unavailable; confirm the governing rules before reviewing.") if not inventory["ui_source_count"]: warnings.append("No UI source filenames were discovered. Check contributed/headless/placeholder scope manually; this is not automatic N/A or completion.") if inventory["skipped_symlinks"]: warnings.append("Symlinked source paths were not traversed; the source starting inventory is incomplete.") if state["errors"]: warnings.append("Repository inspection reported errors; missing or unreadable source is not a clean review.") try: fingerprint = source_fingerprint(project) except (OSError, ValueError): fingerprint = None warnings.append("Current workspace source identity could not be established; do not present attached historical evidence as current verification.") from .catalog import build_stages checks = build_stages(workspace_root, args.profile, [repo.name], False, project=args.project) if not checks: warnings.append("No automated stages are planned for this selection; this is not a passing verification result.") evidence = evidence_record(args.evidence, args) limits = list(dict.fromkeys([*coverage_notes(checks), *(evidence["coverage_notes"] if evidence else [])])) warnings.extend("Coverage limitation: " + note for note in limits[:8]) if len(limits) > 8: warnings.append(f"{len(limits) - 8} additional coverage limitations are retained in the JSON bundle.") result = {"schema_version": 1, "operation": "review.bundle", "generated_at": now(), "workspace_root": str(workspace_root), "project": project.name, "module": repo.name, "source_fingerprint": fingerprint, "repository": state, "issue_links": links, "central_issue": epic, "issue_inventory_path": str(inventory_path) if inventory_path else None, "state_authority": "Live Gitea issues are the canonical backlog and review state log. Discovery links do not report current issue state.", "inventory": inventory, "principles": principles, "check_plan": {"profile": args.profile, "executed": False, "stages": checks}, "manual_checklist": [{"id": identity, "prompt": prompt} for identity, prompt in MANUAL_CHECKS], "review_completion": "Not assessed. Automated checks and this bundle never complete a module review or update issue checklists.", "evidence": evidence, "coverage_notes": limits, "warnings": warnings, "summary": [f"Review bundle: {repo.name}; {inventory['ui_source_count']} UI source files, {len(checks)} planned checks (not executed).", "Principle revision: " + (principles["revision"] or "unavailable"), *["Module issue: " + item["url"] for item in links], *(["Central issue: " + epic["url"]] if epic else []), "Manual review remains unassessed; record outcomes and remaining work in Gitea.", *warnings]} result = _redact_tree(result) if args.output: result["summary"].append("Local bundle artifact: " + redact(str(args.output))) atomic_json(args.output, result) return result