Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
245 lines
15 KiB
Python
Executable File
245 lines
15 KiB
Python
Executable File
"""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
|