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.
This commit is contained in:
Executable
+244
@@ -0,0 +1,244 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user