Files
zemion 2ffdb23f69
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m30s
feat(devkit): add resumable workspace automation and UI review tooling
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.
2026-09-09 02:03:17 +02:00

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