Why: Preserve a single Dev entry and independent repository ownership while allowing active feature branches to migrate without losing source or Git state. Evidence: SELF 29 real Git/Go workspace tests and ordinary component evidence/lock tests passed; detailed RED/GREEN runs are recorded by make evidence in the task card. Impact: Add fixed cloning, selected-version baselines, development overlays, npm source resolution, and independently preserved legacy snapshots. No other active worktree is modified. Refs: Versioned dependencies U2/U5; R15; feature/dependency-workspace
95 lines
5.0 KiB
Python
95 lines
5.0 KiB
Python
import re
|
|
from urllib.parse import urlsplit
|
|
|
|
from .common import WorkspaceError, read_json, relative_path, safe_path
|
|
|
|
ROLES = {"application", "go-library", "npm-library", "documentation", "guidelines"}
|
|
FIXED_ROLES = {"application", "documentation", "guidelines"}
|
|
SHA = re.compile(r"[0-9a-f]{40}\Z")
|
|
IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z")
|
|
|
|
|
|
def validate_repo(repo):
|
|
if not isinstance(repo, str) or not repo or repo.startswith("-") or any(c.isspace() for c in repo):
|
|
raise WorkspaceError("invalid component repository")
|
|
if "://" in repo:
|
|
parsed = urlsplit(repo)
|
|
if parsed.scheme not in {"ssh", "https", "file"} or parsed.password or parsed.query or parsed.fragment:
|
|
raise WorkspaceError("repository must be a credential-free approved Git URL")
|
|
if parsed.scheme == "https" and parsed.username:
|
|
raise WorkspaceError("repository credentials are forbidden")
|
|
if parsed.scheme == "ssh" and parsed.username not in (None, "git"):
|
|
raise WorkspaceError("unsupported SSH repository identity")
|
|
elif not repo.startswith("/"):
|
|
raise WorkspaceError("repository must be an absolute local path or approved URL")
|
|
|
|
|
|
class Manifest:
|
|
def __init__(self, root):
|
|
self.root = root
|
|
document = read_json(root / "components.json")
|
|
if not isinstance(document, dict) or document.get("schema") != 1 or not isinstance(document.get("components"), list):
|
|
raise WorkspaceError("unsupported components schema")
|
|
self.components = {}
|
|
modules = set()
|
|
for component in document["components"]:
|
|
if not isinstance(component, dict):
|
|
raise WorkspaceError("invalid component record")
|
|
name = component.get("id")
|
|
if not isinstance(name, str) or not IDENTIFIER.fullmatch(name) or name in self.components:
|
|
raise WorkspaceError("invalid or duplicate component id")
|
|
if component.get("path") != ".workspace/" + name:
|
|
raise WorkspaceError(f"component path must be .workspace/{name}")
|
|
safe_path(root, component["path"])
|
|
if component.get("legacy_path"):
|
|
safe_path(root, component["legacy_path"])
|
|
if component.get("role") not in ROLES:
|
|
raise WorkspaceError(f"invalid component role: {name}")
|
|
package = component.get("package_name")
|
|
if package is not None and (component["role"] != "npm-library" or not isinstance(package, str)
|
|
or not re.fullmatch(r"(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*", package)):
|
|
raise WorkspaceError(f"invalid npm package mapping: {name}")
|
|
validate_repo(component.get("repo"))
|
|
target = component.get("target", "main")
|
|
validate_ref(target)
|
|
entries = component.get("modules", [])
|
|
if not isinstance(entries, list):
|
|
raise WorkspaceError("modules must be a list")
|
|
for module in entries:
|
|
if not isinstance(module, dict) or not isinstance(module.get("path"), str) or not module["path"]:
|
|
raise WorkspaceError("invalid module mapping")
|
|
if module["path"] in modules or any(c.isspace() for c in module["path"]):
|
|
raise WorkspaceError("duplicate or invalid module identity")
|
|
relative_path(module.get("directory", "."), allow_dot=True)
|
|
modules.add(module["path"])
|
|
self.components[name] = component
|
|
lock = read_json(root / "components.lock.json")
|
|
if not isinstance(lock, dict) or lock.get("schema") != 1 or not isinstance(lock.get("components"), dict):
|
|
raise WorkspaceError("unsupported component lock schema")
|
|
self.lock = lock["components"]
|
|
for name, value in self.lock.items():
|
|
if name not in self.components or not isinstance(value, dict) or not SHA.fullmatch(str(value.get("commit", ""))):
|
|
raise WorkspaceError(f"invalid locked component: {name}")
|
|
if self.components[name]["role"] in ("go-library", "npm-library"):
|
|
raise WorkspaceError(f"language library {name} cannot have a competing source lock")
|
|
|
|
def select(self, name=None, fixed=False):
|
|
if name:
|
|
if name not in self.components:
|
|
raise WorkspaceError(f"unknown component: {name}")
|
|
return [self.components[name]]
|
|
return [entry for entry in self.components.values() if not fixed or entry["role"] in FIXED_ROLES]
|
|
|
|
|
|
def validate_ref(ref):
|
|
if not isinstance(ref, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", ref):
|
|
raise WorkspaceError("invalid branch/ref name")
|
|
if any(token in ref for token in ("..", "//", "/.", ".lock")) or ref.endswith(("/", ".")):
|
|
raise WorkspaceError("invalid branch/ref name")
|
|
|
|
|
|
def validate_branch(branch):
|
|
validate_ref(branch)
|
|
if not branch.startswith(("feature/", "fix/", "chore/", "codex/")):
|
|
raise WorkspaceError("editing requires a named task branch")
|